Initial Drupal 11 with DDEV setup
This commit is contained in:
617
web/core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php
Normal file
617
web/core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php
Normal file
@ -0,0 +1,617 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Command;
|
||||
|
||||
use Drupal\BuildTests\QuickStart\QuickStartTestBase;
|
||||
use Drupal\Core\Command\GenerateTheme;
|
||||
use Drupal\Core\Serialization\Yaml;
|
||||
use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Symfony\Component\Console\Tester\Constraint\CommandIsSuccessful;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Tests the generate-theme commands.
|
||||
*
|
||||
* @requires extension pdo_sqlite
|
||||
*
|
||||
* @group Command
|
||||
*/
|
||||
class GenerateThemeTest extends QuickStartTestBase {
|
||||
|
||||
/**
|
||||
* The PHP executable path.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $php;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
$sqlite = (new \PDO('sqlite::memory:'))->query('select sqlite_version()')->fetch()[0];
|
||||
if (version_compare($sqlite, Tasks::SQLITE_MINIMUM_VERSION) < 0) {
|
||||
$this->markTestSkipped();
|
||||
}
|
||||
parent::setUp();
|
||||
$php_executable_finder = new PhpExecutableFinder();
|
||||
$this->php = $php_executable_finder->find();
|
||||
$this->copyCodebase();
|
||||
$this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer install --no-dev --no-interaction');
|
||||
chdir($this->getWorkingPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates PHP process to generate a theme from core's starterkit theme.
|
||||
*
|
||||
* @return \Symfony\Component\Process\Process
|
||||
* The PHP process
|
||||
*/
|
||||
private function generateThemeFromStarterkit($env = NULL) : Process {
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'generate-theme',
|
||||
'test_custom_theme',
|
||||
'--name="Test custom starterkit theme"',
|
||||
'--description="Custom theme generated from a starterkit theme"',
|
||||
];
|
||||
$process = new Process($install_command, NULL, $env);
|
||||
$process->setTimeout(60);
|
||||
return $process;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the theme exists. Returns the parsed *.info.yml file.
|
||||
*
|
||||
* @param string $theme_path_relative
|
||||
* The core-relative path to the theme.
|
||||
*
|
||||
* @return array
|
||||
* The parsed *.info.yml file.
|
||||
*/
|
||||
private function assertThemeExists(string $theme_path_relative): array {
|
||||
$theme_path_absolute = $this->getWorkspaceDirectory() . "/$theme_path_relative";
|
||||
$theme_name = basename($theme_path_relative);
|
||||
$info_yml_filename = "$theme_name.info.yml";
|
||||
$this->assertFileExists($theme_path_absolute . '/' . $info_yml_filename);
|
||||
$info = Yaml::decode(file_get_contents($theme_path_absolute . '/' . $info_yml_filename));
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the generate-theme command.
|
||||
*/
|
||||
public function test(): void {
|
||||
// Do not rely on \Drupal::VERSION: change the version to a concrete version
|
||||
// number, to simulate using a tagged core release.
|
||||
$starterkit_info_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.info.yml';
|
||||
$info = Yaml::decode(file_get_contents($starterkit_info_yml));
|
||||
$info['version'] = '9.4.0';
|
||||
file_put_contents($starterkit_info_yml, Yaml::encode($info));
|
||||
|
||||
$process = $this->generateThemeFromStarterkit();
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString('Theme generated successfully to themes/test_custom_theme', trim($process->getOutput()), $process->getErrorOutput());
|
||||
$this->assertSame(0, $result);
|
||||
|
||||
$theme_path_relative = 'themes/test_custom_theme';
|
||||
$info = $this->assertThemeExists($theme_path_relative);
|
||||
self::assertArrayNotHasKey('hidden', $info);
|
||||
self::assertArrayHasKey('generator', $info);
|
||||
self::assertEquals('starterkit_theme:9.4.0', $info['generator']);
|
||||
|
||||
// Confirm readme is rewritten.
|
||||
$readme_file = $this->getWorkspaceDirectory() . "/$theme_path_relative/README.md";
|
||||
$this->assertSame('"Test custom starterkit theme" theme, generated from starterkit_theme. Additional information on generating themes can be found in the [Starterkit documentation](https://www.drupal.org/docs/core-modules-and-themes/core-themes/starterkit-theme).', file_get_contents($readme_file));
|
||||
|
||||
// Ensure that the generated theme can be installed.
|
||||
$this->installQuickStart('minimal');
|
||||
$this->formLogin($this->adminUsername, $this->adminPassword);
|
||||
$this->visit('/admin/appearance');
|
||||
$this->getMink()->assertSession()->pageTextContains('Test custom starterkit');
|
||||
$this->getMink()->assertSession()->pageTextContains('Custom theme generated from a starterkit theme');
|
||||
$this->getMink()->getSession()->getPage()->clickLink('Install "Test custom starterkit theme" theme');
|
||||
$this->getMink()->assertSession()->pageTextContains('The "Test custom starterkit theme" theme has been installed.');
|
||||
|
||||
// Ensure that a new theme cannot be generated when the destination
|
||||
// directory already exists.
|
||||
$theme_path_absolute = $this->getWorkspaceDirectory() . "/$theme_path_relative";
|
||||
$this->assertFileExists($theme_path_absolute . '/test_custom_theme.theme');
|
||||
unlink($theme_path_absolute . '/test_custom_theme.theme');
|
||||
$process = $this->generateThemeFromStarterkit();
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString('Theme could not be generated because the destination directory', $process->getErrorOutput());
|
||||
$this->assertStringContainsString($theme_path_relative, $process->getErrorOutput());
|
||||
$this->assertSame(1, $result);
|
||||
$this->assertFileDoesNotExist($theme_path_absolute . '/test_custom_theme.theme');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests generating a theme from another Starterkit enabled theme.
|
||||
*/
|
||||
public function testGeneratingFromAnotherTheme(): void {
|
||||
// Do not rely on \Drupal::VERSION: change the version to a concrete version
|
||||
// number, to simulate using a tagged core release.
|
||||
$starterkit_info_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.info.yml';
|
||||
$info = Yaml::decode(file_get_contents($starterkit_info_yml));
|
||||
$info['version'] = '9.4.0';
|
||||
file_put_contents($starterkit_info_yml, Yaml::encode($info));
|
||||
|
||||
$process = $this->generateThemeFromStarterkit();
|
||||
$exit_code = $process->run();
|
||||
$this->assertStringContainsString('Theme generated successfully to themes/test_custom_theme', trim($process->getOutput()), $process->getErrorOutput());
|
||||
$this->assertSame(0, $exit_code);
|
||||
|
||||
file_put_contents($this->getWorkspaceDirectory() . '/themes/test_custom_theme/test_custom_theme.starterkit.yml', <<<YAML
|
||||
delete: []
|
||||
no_edit: []
|
||||
no_rename: []
|
||||
info:
|
||||
version: 1.0.0
|
||||
YAML
|
||||
);
|
||||
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'generate-theme',
|
||||
'generated_from_another_theme',
|
||||
'--name="Generated from another theme"',
|
||||
'--description="Custom theme generated from a theme other than starterkit_theme"',
|
||||
'--starterkit=test_custom_theme',
|
||||
];
|
||||
$process = new Process($install_command);
|
||||
$exit_code = $process->run();
|
||||
$this->assertStringContainsString('Theme generated successfully to themes/generated_from_another_theme', trim($process->getOutput()), $process->getErrorOutput());
|
||||
$this->assertSame(0, $exit_code);
|
||||
|
||||
// Confirm new .theme file.
|
||||
$dot_theme_file = $this->getWorkspaceDirectory() . '/themes/generated_from_another_theme/generated_from_another_theme.theme';
|
||||
$this->assertStringContainsString('function generated_from_another_theme_preprocess_image_widget(array &$variables): void {', file_get_contents($dot_theme_file));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the generate-theme command on a dev snapshot of Drupal core.
|
||||
*/
|
||||
public function testDevSnapshot(): void {
|
||||
// Do not rely on \Drupal::VERSION: change the version to a development
|
||||
// snapshot version number, to simulate using a branch snapshot of core.
|
||||
$starterkit_info_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.info.yml';
|
||||
$info = Yaml::decode(file_get_contents($starterkit_info_yml));
|
||||
$info['version'] = '9.4.0-dev';
|
||||
file_put_contents($starterkit_info_yml, Yaml::encode($info));
|
||||
|
||||
$process = $this->generateThemeFromStarterkit();
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString('Theme generated successfully to themes/test_custom_theme', trim($process->getOutput()), $process->getErrorOutput());
|
||||
$this->assertSame(0, $result);
|
||||
|
||||
$theme_path_relative = 'themes/test_custom_theme';
|
||||
$info = $this->assertThemeExists($theme_path_relative);
|
||||
self::assertArrayNotHasKey('hidden', $info);
|
||||
self::assertArrayHasKey('generator', $info);
|
||||
self::assertMatchesRegularExpression('/^starterkit_theme\:9.4.0-dev#[0-9a-f]+$/', $info['generator']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the generate-theme command on a theme with a release version number.
|
||||
*/
|
||||
public function testContribStarterkit(): void {
|
||||
// Change the version to a concrete version number, to simulate using a
|
||||
// contrib theme as the starterkit.
|
||||
$starterkit_info_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.info.yml';
|
||||
$info = Yaml::decode(file_get_contents($starterkit_info_yml));
|
||||
$info['version'] = '1.20';
|
||||
file_put_contents($starterkit_info_yml, Yaml::encode($info));
|
||||
|
||||
$process = $this->generateThemeFromStarterkit();
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString('Theme generated successfully to themes/test_custom_theme', trim($process->getOutput()), $process->getErrorOutput());
|
||||
$this->assertSame(0, $result);
|
||||
$info = $this->assertThemeExists('themes/test_custom_theme');
|
||||
self::assertArrayNotHasKey('hidden', $info);
|
||||
self::assertArrayHasKey('generator', $info);
|
||||
self::assertEquals('starterkit_theme:1.20', $info['generator']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the generate-theme command on a theme with a dev version number.
|
||||
*/
|
||||
public function testContribStarterkitDevSnapshot(): void {
|
||||
// Change the version to a development snapshot version number, to simulate
|
||||
// using a contrib theme as the starterkit.
|
||||
$starterkit_info_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.info.yml';
|
||||
$info = Yaml::decode(file_get_contents($starterkit_info_yml));
|
||||
$info['core_version_requirement'] = '*';
|
||||
$info['version'] = '7.x-dev';
|
||||
file_put_contents($starterkit_info_yml, Yaml::encode($info));
|
||||
|
||||
// Avoid the core git commit from being considered the source theme's: move
|
||||
// it out of core.
|
||||
Process::fromShellCommandline('mv core/themes/starterkit_theme themes/', $this->getWorkspaceDirectory())->run();
|
||||
|
||||
$process = $this->generateThemeFromStarterkit();
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString("The source theme starterkit_theme has a development version number (7.x-dev). Because it is not a git checkout, a specific commit could not be identified. This makes tracking changes in the source theme difficult. Are you sure you want to continue? (yes/no) [yes]:\n > Theme generated successfully to themes/test_custom_theme", trim($process->getOutput()), $process->getErrorOutput());
|
||||
$this->assertSame(0, $result);
|
||||
$info = $this->assertThemeExists('themes/test_custom_theme');
|
||||
self::assertArrayNotHasKey('hidden', $info);
|
||||
self::assertArrayHasKey('generator', $info);
|
||||
self::assertEquals('starterkit_theme:7.x-dev#unknown-commit', $info['generator']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the generate-theme command on a theme with a dev version without git.
|
||||
*/
|
||||
public function testContribStarterkitDevSnapshotWithGitNotInstalled(): void {
|
||||
// Change the version to a development snapshot version number, to simulate
|
||||
// using a contrib theme as the starterkit.
|
||||
$starterkit_info_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.info.yml';
|
||||
$info = Yaml::decode(file_get_contents($starterkit_info_yml));
|
||||
$info['core_version_requirement'] = '*';
|
||||
$info['version'] = '7.x-dev';
|
||||
file_put_contents($starterkit_info_yml, Yaml::encode($info));
|
||||
|
||||
// Avoid the core git commit from being considered the source theme's: move
|
||||
// it out of core.
|
||||
Process::fromShellCommandline('mv core/themes/starterkit_theme themes/', $this->getWorkspaceDirectory())->run();
|
||||
|
||||
// Confirm that 'git' is available.
|
||||
$output = [];
|
||||
exec('git --help', $output, $status);
|
||||
$this->assertEquals(0, $status);
|
||||
// Modify our $PATH so that it begins with a path that contains an
|
||||
// executable script named 'git' that always exits with 127, as if git were
|
||||
// not found. Note that we run our tests using process isolation, so we do
|
||||
// not need to restore the PATH when we are done.
|
||||
$unavailableGitPath = $this->getWorkspaceDirectory() . '/bin';
|
||||
putenv('PATH=' . $unavailableGitPath . ':' . getenv('PATH'));
|
||||
mkdir($unavailableGitPath);
|
||||
$bash = <<<SH
|
||||
#!/bin/bash
|
||||
exit 127
|
||||
|
||||
SH;
|
||||
file_put_contents($unavailableGitPath . '/git', $bash);
|
||||
chmod($unavailableGitPath . '/git', 0755);
|
||||
// Confirm that 'git' is no longer available.
|
||||
$process = new Process(['git', '--help']);
|
||||
$process->run();
|
||||
$this->assertEquals(127, $process->getExitCode(), 'Fake git used by process.');
|
||||
|
||||
$process = $this->generateThemeFromStarterkit([
|
||||
'PATH' => getenv('PATH'),
|
||||
'COLUMNS' => 80,
|
||||
]);
|
||||
$result = $process->run();
|
||||
$this->assertEquals("[ERROR] The source theme starterkit_theme has a development version number \n (7.x-dev). Determining a specific commit is not possible because git is\n not installed. Either install git or use a tagged release to generate a\n theme.", trim($process->getErrorOutput()), $process->getErrorOutput());
|
||||
$this->assertSame(1, $result);
|
||||
$this->assertFileDoesNotExist($this->getWorkspaceDirectory() . "/themes/test_custom_theme");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the generate-theme command on a theme without a version number.
|
||||
*/
|
||||
public function testCustomStarterkit(): void {
|
||||
// Omit the version, to simulate using a custom theme as the starterkit.
|
||||
$starterkit_info_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.info.yml';
|
||||
$info = Yaml::decode(file_get_contents($starterkit_info_yml));
|
||||
unset($info['version']);
|
||||
file_put_contents($starterkit_info_yml, Yaml::encode($info));
|
||||
|
||||
$process = $this->generateThemeFromStarterkit();
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString("The source theme starterkit_theme does not have a version specified. This makes tracking changes in the source theme difficult. Are you sure you want to continue? (yes/no) [yes]:\n > Theme generated successfully to themes/test_custom_theme", trim($process->getOutput()), $process->getErrorOutput());
|
||||
$this->assertSame(0, $result);
|
||||
$info = $this->assertThemeExists('themes/test_custom_theme');
|
||||
self::assertArrayNotHasKey('hidden', $info);
|
||||
self::assertArrayHasKey('generator', $info);
|
||||
self::assertEquals('starterkit_theme:unknown-version', $info['generator']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests themes that do not exist return an error.
|
||||
*/
|
||||
public function testThemeDoesNotExist(): void {
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'generate-theme',
|
||||
'test_custom_theme',
|
||||
'--name="Test custom starterkit theme"',
|
||||
'--description="Custom theme generated from a starterkit theme"',
|
||||
'--starterkit',
|
||||
'foobar',
|
||||
];
|
||||
$process = new Process($install_command, NULL);
|
||||
$process->setTimeout(60);
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString('Theme source theme foobar cannot be found.', trim($process->getErrorOutput()));
|
||||
$this->assertSame(1, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that only themes with `starterkit` flag can be used.
|
||||
*/
|
||||
public function testStarterKitFlag(): void {
|
||||
// Explicitly not a starter theme.
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'generate-theme',
|
||||
'test_custom_theme',
|
||||
'--name="Test custom starterkit theme"',
|
||||
'--description="Custom theme generated from a starterkit theme"',
|
||||
'--starterkit',
|
||||
'stark',
|
||||
];
|
||||
$process = new Process($install_command, NULL);
|
||||
$process->setTimeout(60);
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString('Theme source theme stark is not a valid starter kit.', trim($process->getErrorOutput()));
|
||||
$this->assertSame(1, $result);
|
||||
|
||||
// Has not defined `starterkit`.
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'generate-theme',
|
||||
'test_custom_theme',
|
||||
'--name="Test custom starterkit theme"',
|
||||
'--description="Custom theme generated from a starterkit theme"',
|
||||
'--starterkit',
|
||||
'olivero',
|
||||
];
|
||||
$process = new Process($install_command, NULL);
|
||||
$process->setTimeout(60);
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString('Theme source theme olivero is not a valid starter kit.', trim($process->getErrorOutput()));
|
||||
$this->assertSame(1, $result);
|
||||
}
|
||||
|
||||
public function testDeleteDirectory(): void {
|
||||
$this->writeStarterkitConfig([
|
||||
'ignore' => [
|
||||
'/src/*',
|
||||
'/starterkit_theme.starterkit.yml',
|
||||
],
|
||||
]);
|
||||
|
||||
$tester = $this->runCommand(
|
||||
[
|
||||
'machine-name' => 'test_custom_theme',
|
||||
'--name' => 'Test custom starterkit theme',
|
||||
'--description' => 'Custom theme generated from a starterkit theme',
|
||||
]
|
||||
);
|
||||
|
||||
$tester->assertCommandIsSuccessful($tester->getErrorOutput());
|
||||
$this->assertThemeExists('themes/test_custom_theme');
|
||||
$theme_path_absolute = $this->getWorkspaceDirectory() . '/themes/test_custom_theme';
|
||||
self::assertDirectoryExists($theme_path_absolute);
|
||||
self::assertFileDoesNotExist($theme_path_absolute . '/src/StarterKit.php');
|
||||
self::assertDirectoryDoesNotExist($theme_path_absolute . '/src');
|
||||
}
|
||||
|
||||
public function testNoEditMissingFilesWarning(): void {
|
||||
$this->writeStarterkitConfig([
|
||||
'no_edit' => [
|
||||
'/js/starterkit_theme.js',
|
||||
],
|
||||
]);
|
||||
|
||||
$tester = $this->runCommand(
|
||||
[
|
||||
'machine-name' => 'test_custom_theme',
|
||||
'--name' => 'Test custom starterkit theme',
|
||||
'--description' => 'Custom theme generated from a starterkit theme',
|
||||
]
|
||||
);
|
||||
|
||||
self::assertThat($tester->getStatusCode(), self::logicalNot(new CommandIsSuccessful()), trim($tester->getDisplay()));
|
||||
self::assertEquals('[ERROR] Paths were defined `no_edit` but no files found.', trim($tester->getErrorOutput()));
|
||||
$theme_path_absolute = $this->getWorkspaceDirectory() . '/themes/test_custom_theme';
|
||||
self::assertDirectoryDoesNotExist($theme_path_absolute);
|
||||
}
|
||||
|
||||
public function testNoRenameMissingFilesWarning(): void {
|
||||
$this->writeStarterkitConfig([
|
||||
'no_rename' => [
|
||||
'/js/starterkit_theme.js',
|
||||
],
|
||||
]);
|
||||
|
||||
$tester = $this->runCommand(
|
||||
[
|
||||
'machine-name' => 'test_custom_theme',
|
||||
'--name' => 'Test custom starterkit theme',
|
||||
'--description' => 'Custom theme generated from a starterkit theme',
|
||||
]
|
||||
);
|
||||
|
||||
self::assertThat($tester->getStatusCode(), self::logicalNot(new CommandIsSuccessful()), trim($tester->getDisplay()));
|
||||
self::assertEquals('[ERROR] Paths were defined `no_rename` but no files found.', trim($tester->getErrorOutput()));
|
||||
$theme_path_absolute = $this->getWorkspaceDirectory() . '/themes/test_custom_theme';
|
||||
self::assertDirectoryDoesNotExist($theme_path_absolute);
|
||||
}
|
||||
|
||||
public function testNoRename(): void {
|
||||
$this->writeStarterkitConfig([
|
||||
'no_rename' => [
|
||||
'js/starterkit_theme.js',
|
||||
'**/js/*.js',
|
||||
'js/**/*.js',
|
||||
],
|
||||
]);
|
||||
|
||||
mkdir($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/js');
|
||||
mkdir($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/js/baz');
|
||||
file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/js/starterkit_theme.js', '');
|
||||
file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/js/starterkit_theme.foo.js', '');
|
||||
file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/js/baz/starterkit_theme.bar.js', '');
|
||||
|
||||
$tester = $this->runCommand(
|
||||
[
|
||||
'machine-name' => 'test_custom_theme',
|
||||
'--name' => 'Test custom starterkit theme',
|
||||
'--description' => 'Custom theme generated from a starterkit theme',
|
||||
]
|
||||
);
|
||||
|
||||
$tester->assertCommandIsSuccessful($tester->getErrorOutput());
|
||||
$this->assertThemeExists('themes/test_custom_theme');
|
||||
$theme_path_absolute = $this->getWorkspaceDirectory() . '/themes/test_custom_theme';
|
||||
self::assertFileExists($theme_path_absolute . '/js/starterkit_theme.js');
|
||||
self::assertFileExists($theme_path_absolute . '/js/starterkit_theme.foo.js');
|
||||
self::assertFileExists($theme_path_absolute . '/js/baz/starterkit_theme.bar.js');
|
||||
}
|
||||
|
||||
public function testNoEdit(): void {
|
||||
$this->writeStarterkitConfig([
|
||||
'no_edit' => [
|
||||
'*no_edit_*',
|
||||
],
|
||||
]);
|
||||
$fixture = <<<FIXTURE
|
||||
# machine_name
|
||||
starterkit_theme
|
||||
# label
|
||||
Starterkit theme
|
||||
# machine_class_name
|
||||
StarterkitTheme
|
||||
# label_class_name
|
||||
StarterkitTheme
|
||||
FIXTURE;
|
||||
|
||||
file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/edit_fixture.txt', $fixture);
|
||||
file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/no_edit_fixture.txt', $fixture);
|
||||
file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/src/StarterkitThemePreRender.php', <<<PHP
|
||||
<?php
|
||||
|
||||
namespace Drupal\starterkit_theme;
|
||||
|
||||
use Drupal\Core\Security\TrustedCallbackInterface;
|
||||
|
||||
/**
|
||||
* Implements trusted prerender callbacks for the Starterkit theme.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class StarterkitThemePreRender implements TrustedCallbackInterface {
|
||||
|
||||
}
|
||||
PHP);
|
||||
|
||||
$tester = $this->runCommand(
|
||||
[
|
||||
'machine-name' => 'test_custom_theme',
|
||||
'--name' => 'Test custom starterkit theme',
|
||||
'--description' => 'Custom theme generated from a starterkit theme',
|
||||
]
|
||||
);
|
||||
|
||||
$tester->assertCommandIsSuccessful($tester->getErrorOutput());
|
||||
$this->assertThemeExists('themes/test_custom_theme');
|
||||
$theme_path_absolute = $this->getWorkspaceDirectory() . '/themes/test_custom_theme';
|
||||
|
||||
self::assertFileExists($theme_path_absolute . '/no_edit_fixture.txt');
|
||||
self::assertEquals($fixture, file_get_contents($theme_path_absolute . '/no_edit_fixture.txt'));
|
||||
self::assertFileExists($theme_path_absolute . '/edit_fixture.txt');
|
||||
self::assertEquals(<<<EDITED
|
||||
# machine_name
|
||||
test_custom_theme
|
||||
# label
|
||||
Test custom starterkit theme
|
||||
# machine_class_name
|
||||
TestCustomTheme
|
||||
# label_class_name
|
||||
TestCustomTheme
|
||||
EDITED, file_get_contents($theme_path_absolute . '/edit_fixture.txt'));
|
||||
|
||||
self::assertEquals(<<<EDITED
|
||||
<?php
|
||||
|
||||
namespace Drupal\\test_custom_theme;
|
||||
|
||||
use Drupal\Core\Security\TrustedCallbackInterface;
|
||||
|
||||
/**
|
||||
* Implements trusted prerender callbacks for the Test custom starterkit theme.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class TestCustomThemePreRender implements TrustedCallbackInterface {
|
||||
|
||||
}
|
||||
EDITED, file_get_contents($theme_path_absolute . '/src/TestCustomThemePreRender.php'));
|
||||
}
|
||||
|
||||
public function testInfoOverrides(): void {
|
||||
// Force `base theme` to be `false.
|
||||
$starterkit_info_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.info.yml';
|
||||
$info = Yaml::decode(file_get_contents($starterkit_info_yml));
|
||||
$info['base theme'] = FALSE;
|
||||
file_put_contents($starterkit_info_yml, Yaml::encode($info));
|
||||
$this->writeStarterkitConfig([
|
||||
'info' => [
|
||||
'libraries' => [
|
||||
'core/jquery',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$tester = $this->runCommand(
|
||||
[
|
||||
'machine-name' => 'test_custom_theme',
|
||||
'--name' => 'Test custom starterkit theme',
|
||||
'--description' => 'Custom theme generated from a starterkit theme',
|
||||
]
|
||||
);
|
||||
|
||||
$tester->assertCommandIsSuccessful($tester->getErrorOutput());
|
||||
$info = $this->assertThemeExists('themes/test_custom_theme');
|
||||
self::assertArrayHasKey('base theme', $info);
|
||||
self::assertFalse($info['base theme']);
|
||||
self::assertArrayHasKey('libraries', $info);
|
||||
self::assertEquals(['core/jquery'], $info['libraries']);
|
||||
}
|
||||
|
||||
public function testIncludeDotFiles(): void {
|
||||
file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/.gitignore', '*.map');
|
||||
$tester = $this->runCommand(
|
||||
[
|
||||
'machine-name' => 'test_custom_theme',
|
||||
'--name' => 'Test custom starterkit theme',
|
||||
'--description' => 'Custom theme generated from a starterkit theme',
|
||||
]
|
||||
);
|
||||
|
||||
$tester->assertCommandIsSuccessful($tester->getErrorOutput());
|
||||
$this->assertThemeExists('themes/test_custom_theme');
|
||||
|
||||
// Verify that the .gitignore file is present in the generated theme.
|
||||
$theme_path_absolute = $this->getWorkspaceDirectory() . '/themes/test_custom_theme';
|
||||
self::assertFileExists($theme_path_absolute . '/.gitignore');
|
||||
}
|
||||
|
||||
private function writeStarterkitConfig(array $config): void {
|
||||
$starterkit_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.starterkit.yml';
|
||||
$starterkit_config = Yaml::decode(file_get_contents($starterkit_yml));
|
||||
$starterkit_config = array_replace_recursive($starterkit_config, $config);
|
||||
file_put_contents($starterkit_yml, Yaml::encode($starterkit_config));
|
||||
}
|
||||
|
||||
private function runCommand(array $input): CommandTester {
|
||||
$tester = new CommandTester(new GenerateTheme(NULL, $this->getWorkspaceDirectory()));
|
||||
$tester->execute($input, [
|
||||
'capture_stderr_separately' => TRUE,
|
||||
]);
|
||||
return $tester;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Composer\Component;
|
||||
|
||||
use Drupal\BuildTests\Composer\ComposerBuildTestBase;
|
||||
use Drupal\Composer\Composer;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* Try to install dependencies per component, using Composer.
|
||||
*
|
||||
* @group Composer
|
||||
* @group Component
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class ComponentsIsolatedBuildTest extends ComposerBuildTestBase {
|
||||
|
||||
/**
|
||||
* Provides an array with relative paths to the component paths.
|
||||
*
|
||||
* @return array
|
||||
* An array with relative paths to the component paths.
|
||||
*/
|
||||
public static function provideComponentPaths(): array {
|
||||
$data = [];
|
||||
// During the dataProvider phase, there is not a workspace directory yet.
|
||||
// So we will find relative paths and assemble them with the workspace
|
||||
// path later.
|
||||
$drupal_root = self::getDrupalRootStatic();
|
||||
$composer_json_finder = self::getComponentPathsFinder($drupal_root);
|
||||
|
||||
/** @var \Symfony\Component\Finder\SplFileInfo $path */
|
||||
foreach ($composer_json_finder->getIterator() as $path) {
|
||||
$data[$path->getRelativePath()] = ['/' . $path->getRelativePath()];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether components' composer.json can be installed in isolation.
|
||||
*
|
||||
* @dataProvider provideComponentPaths
|
||||
*/
|
||||
public function testComponentComposerJson(string $component_path): void {
|
||||
// Only copy the components. Copy all of them because some of them depend on
|
||||
// each other.
|
||||
$finder = new Finder();
|
||||
$finder->files()
|
||||
->ignoreUnreadableDirs()
|
||||
->in($this->getDrupalRoot() . static::$componentsPath)
|
||||
->ignoreDotFiles(FALSE)
|
||||
->ignoreVCS(FALSE);
|
||||
$this->copyCodebase($finder->getIterator());
|
||||
|
||||
$working_dir = $this->getWorkingPath() . static::$componentsPath . $component_path;
|
||||
|
||||
// We add path repositories so we can wire internal dependencies together.
|
||||
$this->addExpectedRepositories($working_dir);
|
||||
|
||||
// Perform the installation.
|
||||
$this->executeCommand("composer install --working-dir=$working_dir --no-interaction --no-progress");
|
||||
$this->assertCommandSuccessful();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds expected repositories as path repositories to package under test.
|
||||
*
|
||||
* @param string $working_dir
|
||||
* The working directory.
|
||||
*/
|
||||
protected function addExpectedRepositories(string $working_dir): void {
|
||||
foreach ($this->provideComponentPaths() as $path) {
|
||||
$path = $path[0];
|
||||
$package_name = 'drupal/core' . strtolower(preg_replace('/[A-Z]/', '-$0', substr($path, 1)));
|
||||
$path_repo = $this->getWorkingPath() . static::$componentsPath . $path;
|
||||
$repo_name = strtolower($path);
|
||||
// Add path repositories with the current version number to the current
|
||||
// package under test.
|
||||
$drupal_version = Composer::drupalVersionBranch();
|
||||
$this->executeCommand("composer config repositories.$repo_name " .
|
||||
"'{\"type\": \"path\",\"url\": \"$path_repo\",\"options\": {\"versions\": {\"$package_name\": \"$drupal_version\"}}}' --working-dir=$working_dir");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Composer\Component;
|
||||
|
||||
use Drupal\BuildTests\Composer\ComposerBuildTestBase;
|
||||
use Drupal\Composer\Composer;
|
||||
|
||||
/**
|
||||
* Demonstrate that the Component generator responds to release tagging.
|
||||
*
|
||||
* @group Composer
|
||||
* @group Component
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class ComponentsTaggedReleaseTest extends ComposerBuildTestBase {
|
||||
|
||||
/**
|
||||
* Highly arbitrary version and constraint expectations.
|
||||
*
|
||||
* @return array
|
||||
* - First element is the tag that should be applied to \Drupal::version.
|
||||
* - Second element is the resulting constraint which should be present in
|
||||
* the component core dependencies.
|
||||
*/
|
||||
public static function providerVersionConstraint(): array {
|
||||
return [
|
||||
// [Tag, constraint]
|
||||
'1.0.x-dev' => ['1.0.x-dev', '1.0.x-dev'],
|
||||
'1.0.0-beta1' => ['1.0.0-beta1', '1.0.0-beta1'],
|
||||
'1.0.0-rc1' => ['1.0.0-rc1', '1.0.0-rc1'],
|
||||
'1.0.0' => ['1.0.0', '^1.0'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate release tagging and regeneration of dependencies.
|
||||
*
|
||||
* @dataProvider providerVersionConstraint
|
||||
*/
|
||||
public function testReleaseTagging(string $tag, string $constraint): void {
|
||||
$this->copyCodebase();
|
||||
$drupal_root = $this->getWorkspaceDirectory();
|
||||
|
||||
// Set the core version.
|
||||
Composer::setDrupalVersion($drupal_root, $tag);
|
||||
$this->assertDrupalVersion($tag, $drupal_root);
|
||||
|
||||
// Emulate the release script.
|
||||
// @see https://github.com/xjm/drupal_core_release/blob/main/tag.sh
|
||||
$this->executeCommand("COMPOSER_ROOT_VERSION=\"$tag\" composer update drupal/core*");
|
||||
$this->assertCommandSuccessful();
|
||||
$this->assertErrorOutputContains('generateComponentPackages');
|
||||
|
||||
// Find all the components.
|
||||
$component_finder = $this->getComponentPathsFinder($drupal_root);
|
||||
|
||||
// Loop through all the component packages.
|
||||
/** @var \Symfony\Component\Finder\SplFileInfo $composer_json */
|
||||
foreach ($component_finder->getIterator() as $composer_json) {
|
||||
$composer_json_data = json_decode(file_get_contents($composer_json->getPathname()), TRUE);
|
||||
$requires = array_merge(
|
||||
$composer_json_data['require'] ?? [],
|
||||
$composer_json_data['require-dev'] ?? []
|
||||
);
|
||||
// Required packages from drupal/core-* should have our constraint.
|
||||
foreach ($requires as $package => $req_constraint) {
|
||||
if (str_contains($package, 'drupal/core-')) {
|
||||
$this->assertEquals($constraint, $req_constraint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Composer;
|
||||
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* Base class for Composer build tests.
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
abstract class ComposerBuildTestBase extends BuildTestBase {
|
||||
|
||||
/**
|
||||
* Relative path from Drupal root to the Components directory.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected static $componentsPath = '/core/lib/Drupal/Component';
|
||||
|
||||
/**
|
||||
* Assert that the VERSION constant in Drupal.php is the expected value.
|
||||
*
|
||||
* @param string $expectedVersion
|
||||
* The expected version.
|
||||
* @param string $dir
|
||||
* The path to the site root.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
protected function assertDrupalVersion(string $expectedVersion, string $dir): void {
|
||||
$drupal_php_path = $dir . '/core/lib/Drupal.php';
|
||||
$this->assertFileExists($drupal_php_path);
|
||||
|
||||
// Read back the Drupal version that was set and assert it matches
|
||||
// expectations
|
||||
$this->executeCommand("php -r 'include \"$drupal_php_path\"; print \Drupal::VERSION;'");
|
||||
$this->assertCommandSuccessful();
|
||||
$this->assertCommandOutputContains($expectedVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all the composer.json files for components.
|
||||
*
|
||||
* @param string $drupal_root
|
||||
* The Drupal root directory.
|
||||
*
|
||||
* @return \Symfony\Component\Finder\Finder
|
||||
* A Finder object with all the composer.json files for components.
|
||||
*/
|
||||
protected static function getComponentPathsFinder(string $drupal_root): Finder {
|
||||
$finder = new Finder();
|
||||
$finder->name('composer.json')
|
||||
->in($drupal_root . static::$componentsPath)
|
||||
->ignoreUnreadableDirs()
|
||||
->depth(1);
|
||||
|
||||
return $finder;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Composer;
|
||||
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use Drupal\Tests\Composer\ComposerIntegrationTrait;
|
||||
|
||||
/**
|
||||
* @group Composer
|
||||
*/
|
||||
class ComposerValidateTest extends BuildTestBase {
|
||||
|
||||
use ComposerIntegrationTrait;
|
||||
|
||||
public static function provideComposerJson() {
|
||||
$data = [];
|
||||
$composer_json_finder = self::getComposerJsonFinder(self::getDrupalRootStatic());
|
||||
foreach ($composer_json_finder->getIterator() as $composer_json) {
|
||||
$data[] = [$composer_json->getPathname()];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideComposerJson
|
||||
*/
|
||||
public function testValidateComposer($path): void {
|
||||
$this->executeCommand('composer validate --strict --no-check-all ' . $path);
|
||||
$this->assertCommandSuccessful();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,683 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Composer\Plugin\Unpack\Functional;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
use Composer\Util\Filesystem;
|
||||
use Drupal\Tests\Composer\Plugin\Unpack\Fixtures;
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use Drupal\Tests\Composer\Plugin\ExecTrait;
|
||||
|
||||
/**
|
||||
* Tests recipe unpacking.
|
||||
*
|
||||
* @group Unpack
|
||||
*/
|
||||
class UnpackRecipeTest extends BuildTestBase {
|
||||
|
||||
use ExecTrait;
|
||||
|
||||
/**
|
||||
* Directory to perform the tests in.
|
||||
*/
|
||||
protected string $fixturesDir;
|
||||
|
||||
/**
|
||||
* The Symfony FileSystem component.
|
||||
*
|
||||
* @var \Composer\Util\Filesystem
|
||||
*/
|
||||
protected Filesystem $fileSystem;
|
||||
|
||||
/**
|
||||
* The Fixtures object.
|
||||
*
|
||||
* @var \Drupal\Tests\Composer\Plugin\Unpack\Fixtures
|
||||
*/
|
||||
protected Fixtures $fixtures;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->fileSystem = new Filesystem();
|
||||
$this->fixtures = new Fixtures();
|
||||
$this->fixtures->createIsolatedComposerCacheDir();
|
||||
$this->fixturesDir = $this->fixtures->tmpDir($this->name());
|
||||
$replacements = [
|
||||
'PROJECT_ROOT' => $this->fixtures->projectRoot(),
|
||||
'COMPOSER_INSTALLERS' => InstalledVersions::getInstallPath('composer/installers'),
|
||||
];
|
||||
$this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function tearDown(): void {
|
||||
// Remove any temporary directories that were created.
|
||||
$this->fixtures->tearDown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the dependencies unpack on install.
|
||||
*/
|
||||
public function testAutomaticUnpack(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
copy($root_project_path . '/composer.json', $root_project_path . '/composer.json.original');
|
||||
|
||||
// Run composer install and confirm the composer.lock was created.
|
||||
$this->runComposer('install');
|
||||
|
||||
// Install a module in require-dev that should be moved to require
|
||||
// by the unpacker.
|
||||
$this->runComposer('require --dev fixtures/module-a:^1.0');
|
||||
// Ensure we have added the dependency to require-dev.
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require-dev']);
|
||||
|
||||
// Install a recipe and unpack it.
|
||||
$stdout = $this->runComposer('require fixtures/recipe-a');
|
||||
$this->doTestRecipeAUnpacked($root_project_path, $stdout);
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
// The more specific constraint should have been used.
|
||||
$this->assertSame("^1.0", $root_composer_json['require']['fixtures/module-a']);
|
||||
|
||||
// Copy old composer.json back over and require recipe again to ensure it
|
||||
// is still unpacked. This tests that unpacking does not rely on composer
|
||||
// package events.
|
||||
unlink($root_project_path . '/composer.json');
|
||||
copy($root_project_path . '/composer.json.original', $root_project_path . '/composer.json');
|
||||
$stdout = $this->runComposer('require fixtures/recipe-a');
|
||||
$this->doTestRecipeAUnpacked($root_project_path, $stdout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests recursive unpacking.
|
||||
*/
|
||||
public function testRecursiveUnpacking(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Run composer install and confirm the composer.lock was created.
|
||||
$this->runComposer('config --merge --json sort-packages true');
|
||||
$this->runComposer('install');
|
||||
$stdOut = $this->runComposer('require fixtures/recipe-c fixtures/recipe-a');
|
||||
$this->assertSame("fixtures/recipe-c unpacked.\nfixtures/recipe-a unpacked.\nfixtures/recipe-b unpacked.\n", $stdOut);
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertSame([
|
||||
'ext-json',
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'fixtures/module-a',
|
||||
'fixtures/module-b',
|
||||
'fixtures/theme-a',
|
||||
], array_keys($root_composer_json['require']));
|
||||
// Ensure the resulting composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
// Ensure the recipes exist.
|
||||
$this->assertFileExists($root_project_path . '/recipes/recipe-a/recipe.yml');
|
||||
$this->assertFileExists($root_project_path . '/recipes/recipe-b/recipe.yml');
|
||||
$this->assertFileExists($root_project_path . '/recipes/recipe-c/recipe.yml');
|
||||
|
||||
// Ensure the complex constraint has been written correctly.
|
||||
$this->assertSame('>=2.0.1.0-dev, <3.0.0.0-dev', $root_composer_json['require']['fixtures/module-b']);
|
||||
|
||||
// Ensure composer.lock is ordered correctly.
|
||||
$root_composer_lock = $this->getFileContents($root_project_path . '/composer.lock');
|
||||
$this->assertSame([
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'fixtures/module-a',
|
||||
'fixtures/module-b',
|
||||
'fixtures/theme-a',
|
||||
], array_column($root_composer_lock['packages'], 'name'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the dev dependencies do not unpack on install.
|
||||
*/
|
||||
public function testNoAutomaticDevUnpack(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Run composer install and confirm the composer.lock was created.
|
||||
$this->runComposer('install');
|
||||
|
||||
// Install a module in require.
|
||||
$this->runComposer('require fixtures/module-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require']);
|
||||
|
||||
// Install a recipe as a dev dependency.
|
||||
$stdout = $this->runComposer('require --dev fixtures/recipe-a');
|
||||
$this->assertStringContainsString("Recipes required as a development dependency are not automatically unpacked.", $stdout);
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
// Assert the state of the root composer.json as no unpacking has occurred.
|
||||
$this->assertSame(['fixtures/recipe-a'], array_keys($root_composer_json['require-dev']));
|
||||
$this->assertSame(['composer/installers', 'drupal/core-recipe-unpack', 'ext-json', 'fixtures/module-a'], array_keys($root_composer_json['require']));
|
||||
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests dependency unpacking using drupal:recipe-unpack.
|
||||
*/
|
||||
public function testUnpackCommand(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Run composer install and confirm the composer.lock was created.
|
||||
$this->runComposer('install');
|
||||
|
||||
// Disable automatic unpacking as it is the default behavior,
|
||||
$this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false');
|
||||
|
||||
// Install a module in require-dev.
|
||||
$this->runComposer('require --dev fixtures/module-a');
|
||||
// Install a module in require.
|
||||
$this->runComposer('require fixtures/module-b:*');
|
||||
|
||||
// Ensure we have added the dependencies.
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertArrayHasKey('fixtures/module-b', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require-dev']);
|
||||
|
||||
// Install a recipe and check it is not unpacked.
|
||||
$stdout = $this->runComposer('require fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
// When the package is unpacked, the unpacked dependencies should be logged
|
||||
// in the stdout.
|
||||
$this->assertStringNotContainsString("unpacked.", $stdout);
|
||||
|
||||
$this->assertArrayHasKey('fixtures/recipe-a', $root_composer_json['require']);
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
|
||||
// The package dependencies should not be in the root composer.json.
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-b', $root_composer_json['require']);
|
||||
|
||||
// Try unpacking a recipe that in not in the root composer.json.
|
||||
try {
|
||||
$this->runComposer('drupal:recipe-unpack fixtures/recipe-b');
|
||||
$this->fail('Unpacking a non-existent dependency should fail');
|
||||
}
|
||||
catch (\RuntimeException $e) {
|
||||
$this->assertStringContainsString('fixtures/recipe-b not found in the root composer.json.', $e->getMessage());
|
||||
}
|
||||
|
||||
// The dev dependency has not moved.
|
||||
$this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require-dev']);
|
||||
|
||||
$stdout = $this->runComposer('drupal:recipe-unpack fixtures/recipe-a');
|
||||
$this->doTestRecipeAUnpacked($root_project_path, $stdout);
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
// The more specific constraints has been used.
|
||||
$this->assertSame("^2.0", $root_composer_json['require']['fixtures/module-b']);
|
||||
|
||||
// Try unpacking something that is not a recipe.
|
||||
try {
|
||||
$this->runComposer('drupal:recipe-unpack fixtures/module-a');
|
||||
$this->fail('Unpacking a module should fail');
|
||||
}
|
||||
catch (\RuntimeException $e) {
|
||||
$this->assertStringContainsString('fixtures/module-a is not a recipe.', $e->getMessage());
|
||||
}
|
||||
|
||||
// Try unpacking something that in not in the root composer.json.
|
||||
try {
|
||||
$this->runComposer('drupal:recipe-unpack fixtures/module-c');
|
||||
$this->fail('Unpacking a non-existent dependency should fail');
|
||||
}
|
||||
catch (\RuntimeException $e) {
|
||||
$this->assertStringContainsString('fixtures/module-c not found in the root composer.json.', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests dependency unpacking using drupal:recipe-unpack with multiple args.
|
||||
*/
|
||||
public function testUnpackCommandWithMultipleRecipes(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
$this->runComposer('install');
|
||||
|
||||
// Disable automatic unpacking as it is the default behavior,
|
||||
$this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false');
|
||||
|
||||
// Install a recipe and check it is not unpacked.
|
||||
$stdOut = $this->runComposer('require fixtures/recipe-a fixtures/recipe-d');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
// When the package is unpacked, the unpacked dependencies should be logged
|
||||
// in the stdout.
|
||||
$this->assertStringNotContainsString("unpacked.", $stdOut);
|
||||
|
||||
$this->assertArrayHasKey('fixtures/recipe-a', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/recipe-d', $root_composer_json['require']);
|
||||
|
||||
$stdOut = $this->runComposer('drupal:recipe-unpack fixtures/recipe-a fixtures/recipe-d');
|
||||
$this->assertStringContainsString("fixtures/recipe-a unpacked.", $stdOut);
|
||||
$this->assertStringContainsString("fixtures/recipe-d unpacked.", $stdOut);
|
||||
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-a', $root_composer_json['require']);
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-d', $root_composer_json['require']);
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests dependency unpacking using drupal:recipe-unpack with no arguments.
|
||||
*/
|
||||
public function testUnpackCommandWithoutRecipesArgument(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
$this->runComposer('install');
|
||||
|
||||
// Tests unpack command with no arguments and no recipes in the root
|
||||
// composer package.
|
||||
$stdOut = $this->runComposer('drupal:recipe-unpack');
|
||||
$this->assertSame("No recipes to unpack.\n", $stdOut);
|
||||
|
||||
// Disable automatic unpacking as it is the default behavior,
|
||||
$this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false');
|
||||
|
||||
// Install a recipe and check it is not unpacked.
|
||||
$stdOut = $this->runComposer('require fixtures/recipe-a fixtures/recipe-d');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
// When the package is unpacked, the unpacked dependencies should be logged
|
||||
// in the stdout.
|
||||
$this->assertStringNotContainsString("unpacked.", $stdOut);
|
||||
|
||||
$this->assertArrayHasKey('fixtures/recipe-a', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/recipe-d', $root_composer_json['require']);
|
||||
|
||||
$stdOut = $this->runComposer('drupal:recipe-unpack');
|
||||
$this->assertStringContainsString("fixtures/recipe-a unpacked.", $stdOut);
|
||||
$this->assertStringContainsString("fixtures/recipe-d unpacked.", $stdOut);
|
||||
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-a', $root_composer_json['require']);
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-d', $root_composer_json['require']);
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests unpacking a recipe in require-dev using drupal:recipe-unpack.
|
||||
*/
|
||||
public function testUnpackCommandOnDevRecipe(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Run composer install and confirm the composer.lock was created.
|
||||
$this->runComposer('install');
|
||||
|
||||
// Disable automatic unpacking, which is the default behavior.
|
||||
$this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false');
|
||||
|
||||
$this->runComposer('require fixtures/recipe-b');
|
||||
|
||||
// Install a recipe and check it is not unpacked.
|
||||
$this->runComposer('require --dev fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
$this->assertArrayHasKey('fixtures/recipe-a', $root_composer_json['require-dev']);
|
||||
$this->assertArrayHasKey('fixtures/recipe-b', $root_composer_json['require']);
|
||||
|
||||
$error_output = '';
|
||||
$stdout = $this->runComposer('drupal:recipe-unpack fixtures/recipe-a', error_output: $error_output);
|
||||
$this->assertStringContainsString("fixtures/recipe-a is present in the require-dev key. Unpacking will move the recipe's dependencies to the require key.", $error_output);
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
// Ensure recipe A's dependencies are moved to require.
|
||||
$this->doTestRecipeAUnpacked($root_project_path, $stdout);
|
||||
|
||||
// Ensure recipe B's dependencies are in require and the recipe has been
|
||||
// unpacked.
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-b', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/theme-a', $root_composer_json['require']);
|
||||
|
||||
// Ensure installed.json and installed.php are correct.
|
||||
$installed_json = $this->getFileContents($root_project_path . '/vendor/composer/installed.json');
|
||||
$installed_packages = array_column($installed_json['packages'], 'name');
|
||||
$this->assertContains('fixtures/module-b', $installed_packages);
|
||||
$this->assertNotContains('fixtures/recipe-a', $installed_packages);
|
||||
$this->assertSame([], $installed_json['dev-package-names']);
|
||||
$installed_php = include_once $root_project_path . '/vendor/composer/installed.php';
|
||||
$this->assertArrayHasKey('fixtures/module-b', $installed_php['versions']);
|
||||
$this->assertFalse($installed_php['versions']['fixtures/module-b']['dev_requirement']);
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-a', $installed_php['versions']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the unpacking a recipe that is an indirect dev dependency.
|
||||
*/
|
||||
public function testUnpackCommandOnIndirectDevDependencyRecipe(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Run composer install and confirm the composer.lock was created.
|
||||
$this->runComposer('install');
|
||||
// Disable automatic unpacking as it is the default behavior,
|
||||
$this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false');
|
||||
|
||||
$this->runComposer('require --dev fixtures/recipe-b');
|
||||
|
||||
// Install a recipe and ensure it is not unpacked.
|
||||
$this->runComposer('require fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
$this->assertArrayHasKey('fixtures/recipe-a', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/recipe-b', $root_composer_json['require-dev']);
|
||||
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
|
||||
$this->runComposer('drupal:recipe-unpack fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
// Ensure recipe A's dependencies are in require.
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-a', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/module-b', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/theme-a', $root_composer_json['require']);
|
||||
|
||||
// Ensure recipe B is still in require-dev even though all it's dependencies
|
||||
// have been unpacked to require due to unpacking recipe A.
|
||||
$this->assertSame(['fixtures/recipe-b'], array_keys($root_composer_json['require-dev']));
|
||||
|
||||
// Ensure recipe B is still list in installed.json.
|
||||
$installed_json = $this->getFileContents($root_project_path . '/vendor/composer/installed.json');
|
||||
$installed_packages = array_column($installed_json['packages'], 'name');
|
||||
$this->assertContains('fixtures/recipe-b', $installed_packages);
|
||||
$this->assertContains('fixtures/recipe-b', $installed_json['dev-package-names']);
|
||||
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a recipe can be removed and the unpack plugin does not interfere.
|
||||
*/
|
||||
public function testRemoveRecipe(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Disable automatic unpacking, which is the default behavior,
|
||||
$this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false');
|
||||
|
||||
$this->runComposer('install');
|
||||
|
||||
// Install a recipe and ensure it is not unpacked.
|
||||
$this->runComposer('require fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertSame([
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'ext-json',
|
||||
'fixtures/recipe-a',
|
||||
], array_keys($root_composer_json['require']));
|
||||
|
||||
// Removing the recipe should work as normal.
|
||||
$this->runComposer('remove fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertSame([
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'ext-json',
|
||||
], array_keys($root_composer_json['require']));
|
||||
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a recipe can be ignored and not unpacked.
|
||||
*/
|
||||
public function testIgnoreRecipe(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Disable automatic unpacking as it is the default behavior,
|
||||
$this->runComposer('config --merge --json extra.drupal-recipe-unpack.ignore \'["fixtures/recipe-a"]\'');
|
||||
|
||||
$this->runComposer('install');
|
||||
|
||||
// Install a recipe and ensure it does not get unpacked.
|
||||
$stdOut = $this->runComposer('require --verbose fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertSame("fixtures/recipe-a not unpacked because it is ignored.", trim($stdOut));
|
||||
|
||||
$this->assertSame([
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'ext-json',
|
||||
'fixtures/recipe-a',
|
||||
], array_keys($root_composer_json['require']));
|
||||
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
|
||||
// Try using the unpack command on an ignored recipe.
|
||||
try {
|
||||
$this->runComposer('drupal:recipe-unpack fixtures/recipe-a');
|
||||
$this->fail('Ignored recipes should not be unpacked.');
|
||||
}
|
||||
catch (\RuntimeException $e) {
|
||||
$this->assertStringContainsString('fixtures/recipe-a is in the extra.drupal-recipe-unpack.ignore list.', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a dependent recipe can be ignored and not unpacked.
|
||||
*/
|
||||
public function testIgnoreDependentRecipe(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Disable automatic unpacking, which is the default behavior,
|
||||
$this->runComposer('config --merge --json extra.drupal-recipe-unpack.ignore \'["fixtures/recipe-b"]\'');
|
||||
$this->runComposer('config sort-packages true');
|
||||
|
||||
$this->runComposer('install');
|
||||
|
||||
// Install a recipe and check it is not packed but not removed.
|
||||
$stdOut = $this->runComposer('require --verbose fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertStringContainsString("fixtures/recipe-b not unpacked because it is ignored.", $stdOut);
|
||||
$this->assertStringContainsString("fixtures/recipe-a unpacked.", $stdOut);
|
||||
|
||||
$this->assertSame([
|
||||
'ext-json',
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'fixtures/module-b',
|
||||
'fixtures/recipe-b',
|
||||
], array_keys($root_composer_json['require']));
|
||||
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that recipes stick around after being unpacked.
|
||||
*/
|
||||
public function testRecipeIsPhysicallyPresentAfterUnpack(): void {
|
||||
$root_project_dir = 'composer-root';
|
||||
$root_project_path = $this->fixturesDir . '/' . $root_project_dir;
|
||||
|
||||
$this->runComposer('install');
|
||||
|
||||
// Install a recipe, which should unpack it.
|
||||
$stdOut = $this->runComposer('require --verbose fixtures/recipe-b');
|
||||
$this->assertStringContainsString("fixtures/recipe-b unpacked.", $stdOut);
|
||||
$this->assertFileExists($root_project_path . '/recipes/recipe-b/recipe.yml');
|
||||
|
||||
// Require another dependency.
|
||||
$this->runComposer('require --verbose fixtures/module-b');
|
||||
|
||||
// The recipe should still be physically installed...
|
||||
$this->assertFileExists($root_project_path . '/recipes/recipe-b/recipe.yml');
|
||||
|
||||
// ...but it should NOT be in installed.json or installed.php.
|
||||
$installed_json = $this->getFileContents($root_project_path . '/vendor/composer/installed.json');
|
||||
$installed_packages = array_column($installed_json['packages'], 'name');
|
||||
$this->assertContains('fixtures/module-b', $installed_packages);
|
||||
$this->assertNotContains('fixtures/recipe-b', $installed_packages);
|
||||
$installed_php = include_once $root_project_path . '/vendor/composer/installed.php';
|
||||
$this->assertArrayHasKey('fixtures/module-b', $installed_php['versions']);
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-b', $installed_php['versions']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a recipe can be required using --no-install and installed later.
|
||||
*/
|
||||
public function testRecipeNotUnpackedIfInstallIsDeferred(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
$this->runComposer('install');
|
||||
|
||||
// Install a recipe and check it is in `composer.json` but not unpacked or
|
||||
// physically installed.
|
||||
$stdOut = $this->runComposer('require --verbose --no-install fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertSame("Recipes are not unpacked when the --no-install option is used.", trim($stdOut));
|
||||
$this->assertSame([
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'ext-json',
|
||||
'fixtures/recipe-a',
|
||||
], array_keys($root_composer_json['require']));
|
||||
$this->assertFileDoesNotExist($root_project_path . '/recipes/recipe-a/recipe.yml');
|
||||
|
||||
// After installing dependencies, the recipe should be installed, but still
|
||||
// not unpacked.
|
||||
$this->runComposer('install');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertSame([
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'ext-json',
|
||||
'fixtures/recipe-a',
|
||||
], array_keys($root_composer_json['require']));
|
||||
$this->assertFileExists($root_project_path . '/recipes/recipe-a/recipe.yml');
|
||||
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that recipes are unpacked when using `composer create-project`.
|
||||
*/
|
||||
public function testComposerCreateProject(): void {
|
||||
// Prepare the project to use for create-project.
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
$this->runComposer('require --verbose --no-install fixtures/recipe-a');
|
||||
|
||||
$stdOut = $this->runComposer('create-project --repository=\'{"type": "path","url": "' . $root_project_path . '","options": {"symlink": false}}\' fixtures/root composer-root2 -s dev', $this->fixturesDir);
|
||||
// The recipes depended upon by the project, even indirectly, should all
|
||||
// have been unpacked.
|
||||
$this->assertSame("fixtures/recipe-b unpacked.\nfixtures/recipe-a unpacked.\n", $stdOut);
|
||||
$this->doTestRecipeAUnpacked($this->fixturesDir . '/composer-root2', $stdOut);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests Recipe A is unpacked correctly.
|
||||
*
|
||||
* @param string $root_project_path
|
||||
* Path to the composer project under test.
|
||||
* @param string $stdout
|
||||
* The standard out from the composer command unpacks the recipe.
|
||||
*/
|
||||
private function doTestRecipeAUnpacked(string $root_project_path, string $stdout): void {
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
// @see core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-a/composer.json
|
||||
// @see core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-b/composer.json
|
||||
$expected_unpacked = [
|
||||
'fixtures/recipe-a' => [
|
||||
'fixtures/module-b',
|
||||
],
|
||||
'fixtures/recipe-b' => [
|
||||
'fixtures/module-a',
|
||||
'fixtures/theme-a',
|
||||
],
|
||||
];
|
||||
foreach ($expected_unpacked as $package => $dependencies) {
|
||||
// When the package is unpacked, the unpacked dependencies should be logged
|
||||
// in the stdout.
|
||||
$this->assertStringContainsString("$package unpacked.", $stdout);
|
||||
|
||||
// After being unpacked, the package should be removed from the root
|
||||
// composer.json and composer.lock.
|
||||
$this->assertArrayNotHasKey($package, $root_composer_json['require']);
|
||||
|
||||
foreach ($dependencies as $dependency) {
|
||||
// The package dependencies should be in the root composer.json.
|
||||
$this->assertArrayHasKey($dependency, $root_composer_json['require']);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate', $root_project_path);
|
||||
|
||||
// The dev dependency has moved.
|
||||
$this->assertArrayNotHasKey('require-dev', $root_composer_json);
|
||||
|
||||
// Ensure recipe files exist.
|
||||
$this->assertFileExists($root_project_path . '/recipes/recipe-a/recipe.yml');
|
||||
$this->assertFileExists($root_project_path . '/recipes/recipe-b/recipe.yml');
|
||||
|
||||
// Ensure composer.lock is ordered correctly.
|
||||
$root_composer_lock = $this->getFileContents($root_project_path . '/composer.lock');
|
||||
$this->assertSame([
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'fixtures/module-a',
|
||||
'fixtures/module-b',
|
||||
'fixtures/theme-a',
|
||||
], array_column($root_composer_lock['packages'], 'name'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a Composer command with standard options.
|
||||
*
|
||||
* @param string $command
|
||||
* The composer command to execute.
|
||||
* @param string $cwd
|
||||
* The current working directory to run the command from.
|
||||
* @param string $error_output
|
||||
* Passed by reference to allow error output to be tested.
|
||||
*
|
||||
* @return string
|
||||
* Standard output from the command.
|
||||
*/
|
||||
private function runComposer(string $command, ?string $cwd = NULL, string &$error_output = ''): string {
|
||||
$cwd ??= $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Always add --no-interaction and --no-ansi to Composer commands.
|
||||
$output = $this->mustExec("composer $command --no-interaction --no-ansi", $cwd, [], $error_output);
|
||||
if ($command === 'install') {
|
||||
$this->assertFileExists($cwd . '/composer.lock');
|
||||
}
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the contents of a file as an array.
|
||||
*
|
||||
* @param string $path
|
||||
* The path to the file.
|
||||
*
|
||||
* @return array
|
||||
* The contents of the file as an array.
|
||||
*/
|
||||
private function getFileContents(string $path): array {
|
||||
$file = file_get_contents($path);
|
||||
return json_decode($file, TRUE, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,469 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Composer\Template;
|
||||
|
||||
use Composer\Json\JsonFile;
|
||||
use Composer\Semver\VersionParser;
|
||||
use Drupal\BuildTests\Composer\ComposerBuildTestBase;
|
||||
use Drupal\Composer\Composer;
|
||||
|
||||
/**
|
||||
* Demonstrate that Composer project templates can be built as patched.
|
||||
*
|
||||
* We have to use the packages.json fixture so that Composer will use the
|
||||
* in-codebase version of the project template.
|
||||
*
|
||||
* We also have to add path repositories to the in-codebase project template or
|
||||
* else Composer will try to use packagist to resolve dependencies we'd prefer
|
||||
* it to find locally.
|
||||
*
|
||||
* This is because Composer only uses the packages.json file to resolve the
|
||||
* project template and not any other dependencies.
|
||||
*
|
||||
* @group Template
|
||||
*/
|
||||
class ComposerProjectTemplatesTest extends ComposerBuildTestBase {
|
||||
|
||||
/**
|
||||
* The minimum stability requirement for dependencies.
|
||||
*
|
||||
* @see https://getcomposer.org/doc/04-schema.md#minimum-stability
|
||||
*/
|
||||
protected const MINIMUM_STABILITY = 'stable';
|
||||
|
||||
/**
|
||||
* The order of stability strings from least stable to most stable.
|
||||
*
|
||||
* This only includes normalized stability strings: i.e., ones that are
|
||||
* returned by \Composer\Semver\VersionParser::parseStability().
|
||||
*/
|
||||
protected const STABILITY_ORDER = ['dev', 'alpha', 'beta', 'RC', 'stable'];
|
||||
|
||||
/**
|
||||
* Get Composer items that we want to be path repos, from within a directory.
|
||||
*
|
||||
* @param string $workspace_directory
|
||||
* The full path to the workspace directory.
|
||||
* @param string $subdir
|
||||
* The subdirectory to search under composer/.
|
||||
*
|
||||
* @return string[]
|
||||
* Array of paths, indexed by package name.
|
||||
*/
|
||||
public function getPathReposForType($workspace_directory, $subdir) {
|
||||
// Find the Composer items that we want to be path repos.
|
||||
/** @var \SplFileInfo[] $path_repos */
|
||||
$path_repos = Composer::composerSubprojectPaths($workspace_directory, $subdir);
|
||||
|
||||
$data = [];
|
||||
foreach ($path_repos as $path_repo) {
|
||||
$json_file = new JsonFile($path_repo->getPathname());
|
||||
$json = $json_file->read();
|
||||
$data[$json['name']] = $path_repo->getPath();
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
public static function provideTemplateCreateProject() {
|
||||
return [
|
||||
'recommended-project' => [
|
||||
'drupal/recommended-project',
|
||||
'composer/Template/RecommendedProject',
|
||||
'/web',
|
||||
],
|
||||
'legacy-project' => [
|
||||
'drupal/legacy-project',
|
||||
'composer/Template/LegacyProject',
|
||||
'',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that static::MINIMUM_STABILITY is sufficiently strict.
|
||||
*/
|
||||
public function testMinimumStabilityStrictness(): void {
|
||||
// Ensure that static::MINIMUM_STABILITY is not less stable than the
|
||||
// current core stability. For example, if we've already released a beta on
|
||||
// the branch, ensure that we no longer allow alpha dependencies.
|
||||
$this->assertGreaterThanOrEqual(array_search($this->getCoreStability(), static::STABILITY_ORDER), array_search(static::MINIMUM_STABILITY, static::STABILITY_ORDER));
|
||||
|
||||
// Ensure that static::MINIMUM_STABILITY is the same as the least stable
|
||||
// dependency.
|
||||
// - We can't set it stricter than our least stable dependency.
|
||||
// - We don't want to set it looser than we need to, because we don't want
|
||||
// to in the future accidentally commit a dependency that regresses our
|
||||
// actual stability requirement without us explicitly changing this
|
||||
// constant.
|
||||
$root = $this->getDrupalRoot();
|
||||
$process = $this->executeCommand("composer --working-dir=$root info --format=json");
|
||||
$this->assertCommandSuccessful();
|
||||
$installed = json_decode($process->getOutput(), TRUE);
|
||||
|
||||
// A lookup of the numerical position of each of the stability terms.
|
||||
$stability_order_indexes = array_flip(static::STABILITY_ORDER);
|
||||
|
||||
$minimum_stability_order_index = $stability_order_indexes[static::MINIMUM_STABILITY];
|
||||
|
||||
$exclude = [
|
||||
'drupal/core',
|
||||
'drupal/core-recipe-unpack',
|
||||
'drupal/core-project-message',
|
||||
'drupal/core-vendor-hardening',
|
||||
];
|
||||
foreach ($installed['installed'] as $project) {
|
||||
// Exclude dependencies that are required with "self.version", since
|
||||
// those stabilities will automatically match the corresponding Drupal
|
||||
// release.
|
||||
if (in_array($project['name'], $exclude, TRUE)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// VersionParser::parseStability doesn't play nice with (mostly dev-)
|
||||
// versions ending with the first seven characters of the commit ID as
|
||||
// returned by "composer info". Let's strip those suffixes here.
|
||||
$version = preg_replace('/ [0-9a-f]{7}$/i', '', $project['version']);
|
||||
|
||||
$project_stability = VersionParser::parseStability($version);
|
||||
$project_stability_order_index = $stability_order_indexes[$project_stability];
|
||||
|
||||
$project_stabilities[$project['name']] = $project_stability;
|
||||
|
||||
$this->assertGreaterThanOrEqual($minimum_stability_order_index, $project_stability_order_index, sprintf(
|
||||
"Dependency %s with stability %s does not meet minimum stability %s.",
|
||||
$project['name'],
|
||||
$project_stability,
|
||||
static::MINIMUM_STABILITY,
|
||||
));
|
||||
}
|
||||
|
||||
// At least one project should be at the minimum stability.
|
||||
$this->assertContains(static::MINIMUM_STABILITY, $project_stabilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure we've accounted for all the templates.
|
||||
*/
|
||||
public function testVerifyTemplateTestProviderIsAccurate(): void {
|
||||
$root = $this->getDrupalRoot();
|
||||
$data = $this->provideTemplateCreateProject();
|
||||
|
||||
// Find all the templates.
|
||||
$template_files = Composer::composerSubprojectPaths($root, 'Template');
|
||||
|
||||
$this->assertSameSize($template_files, $data);
|
||||
|
||||
// We could have the same number of templates but different names.
|
||||
$template_data = [];
|
||||
foreach ($data as $data_name => $data_value) {
|
||||
$template_data[$data_value[0]] = $data_name;
|
||||
}
|
||||
/** @var \SplFileInfo $file */
|
||||
foreach ($template_files as $file) {
|
||||
$json_file = new JsonFile($file->getPathname());
|
||||
$json = $json_file->read();
|
||||
$this->assertArrayHasKey('name', $json);
|
||||
// Assert that the template name is in the project created
|
||||
// from the template.
|
||||
$this->assertArrayHasKey($json['name'], $template_data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideTemplateCreateProject
|
||||
*/
|
||||
public function testTemplateCreateProject($project, $package_dir, $docroot_dir): void {
|
||||
// Make a working COMPOSER_HOME directory for setting global composer config
|
||||
$composer_home = $this->getWorkspaceDirectory() . '/composer-home';
|
||||
mkdir($composer_home);
|
||||
// Create an empty global composer.json file, just to avoid warnings.
|
||||
file_put_contents("$composer_home/composer.json", '{}');
|
||||
|
||||
// Disable packagist globally (but only in our own custom COMPOSER_HOME).
|
||||
// It is necessary to do this globally rather than in our SUT composer.json
|
||||
// in order to ensure that Packagist is disabled during the
|
||||
// `composer create-project` command.
|
||||
$this->executeCommand("COMPOSER_HOME=$composer_home composer config --no-interaction --global repo.packagist false");
|
||||
$this->assertCommandSuccessful();
|
||||
|
||||
// Create a "Composer"-type repository containing one entry for every
|
||||
// package in the vendor directory.
|
||||
$vendor_packages_path = $this->getWorkspaceDirectory() . '/vendor_packages/packages.json';
|
||||
$this->makeVendorPackage($vendor_packages_path);
|
||||
|
||||
// Make a copy of the code to alter in the workspace directory.
|
||||
$this->copyCodebase();
|
||||
|
||||
// Tests are typically run on "-dev" versions, but we want to simulate
|
||||
// running them on a tagged release at the same stability as specified in
|
||||
// static::MINIMUM_STABILITY, in order to verify that everything will work
|
||||
// if/when we make such a release.
|
||||
$simulated_core_version = \Drupal::VERSION;
|
||||
$simulated_core_version_suffix = (static::MINIMUM_STABILITY === 'stable' ? '' : '-' . static::MINIMUM_STABILITY . '99');
|
||||
$simulated_core_version = str_replace('-dev', $simulated_core_version_suffix, $simulated_core_version);
|
||||
Composer::setDrupalVersion($this->getWorkspaceDirectory(), $simulated_core_version);
|
||||
$this->assertDrupalVersion($simulated_core_version, $this->getWorkspaceDirectory());
|
||||
|
||||
// Remove the packages.drupal.org entry (and any other custom repository)
|
||||
// from the SUT's repositories section. There is no way to do this via
|
||||
// `composer config --unset`, so we read and rewrite composer.json.
|
||||
$composer_json_path = $this->getWorkspaceDirectory() . "/$package_dir/composer.json";
|
||||
$composer_json = json_decode(file_get_contents($composer_json_path), TRUE);
|
||||
unset($composer_json['repositories']);
|
||||
$json = json_encode($composer_json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
file_put_contents($composer_json_path, $json);
|
||||
|
||||
// Set up the template to use our path repos. Inclusion of metapackages is
|
||||
// reported differently, so we load up a separate set for them.
|
||||
$metapackage_path_repos = $this->getPathReposForType($this->getWorkspaceDirectory(), 'Metapackage');
|
||||
$this->assertArrayHasKey('drupal/core-recommended', $metapackage_path_repos);
|
||||
$path_repos = array_merge($metapackage_path_repos, $this->getPathReposForType($this->getWorkspaceDirectory(), 'Plugin'));
|
||||
// Always add drupal/core as a path repo.
|
||||
$path_repos['drupal/core'] = $this->getWorkspaceDirectory() . '/core';
|
||||
foreach ($path_repos as $name => $path) {
|
||||
$this->executeCommand("composer config --no-interaction repositories.$name path $path", $package_dir);
|
||||
$this->assertCommandSuccessful();
|
||||
}
|
||||
|
||||
// Change drupal/core-recommended to require the simulated version of
|
||||
// drupal/core.
|
||||
$core_recommended_dir = 'composer/Metapackage/CoreRecommended';
|
||||
$this->executeCommand("composer remove --no-interaction drupal/core --no-update", $core_recommended_dir);
|
||||
$this->assertCommandSuccessful();
|
||||
$this->executeCommand("composer require --no-interaction drupal/core:^$simulated_core_version --no-update", $core_recommended_dir);
|
||||
$this->assertCommandSuccessful();
|
||||
|
||||
// Add our vendor package repository to our SUT's repositories section.
|
||||
// Call it "local" (although the name does not matter).
|
||||
$this->executeCommand("composer config --no-interaction repositories.local composer file://" . $vendor_packages_path, $package_dir);
|
||||
$this->assertCommandSuccessful();
|
||||
|
||||
$repository_path = $this->getWorkspaceDirectory() . '/test_repository/packages.json';
|
||||
$this->makeTestPackage($repository_path, $simulated_core_version);
|
||||
|
||||
$installed_composer_json = $this->getWorkspaceDirectory() . '/test_project/composer.json';
|
||||
$autoloader = $this->getWorkspaceDirectory() . '/test_project' . $docroot_dir . '/autoload.php';
|
||||
$recipes_dir = $this->getWorkspaceDirectory() . '/test_project/recipes';
|
||||
$this->assertFileDoesNotExist($autoloader);
|
||||
$this->assertDirectoryDoesNotExist($recipes_dir);
|
||||
|
||||
$this->executeCommand("COMPOSER_HOME=$composer_home COMPOSER_ROOT_VERSION=$simulated_core_version composer create-project --no-ansi $project test_project $simulated_core_version -vvv --repository $repository_path");
|
||||
$this->assertCommandSuccessful();
|
||||
// Check the output of the project creation for the absence of warnings
|
||||
// about any non-allowed composer plugins.
|
||||
// Note: There are different warnings for disallowed composer plugins
|
||||
// depending on running in non-interactive mode or not. It seems the Drupal
|
||||
// CI environment always forces composer commands to run in the
|
||||
// non-interactive mode. The only thing these messages have in common is the
|
||||
// following string.
|
||||
$this->assertErrorOutputNotContains('See https://getcomposer.org/allow-plugins');
|
||||
|
||||
// Ensure we used the project from our codebase.
|
||||
$this->assertErrorOutputContains("Installing $project ($simulated_core_version): Symlinking from $package_dir");
|
||||
// Ensure that we used drupal/core from our codebase. This probably means
|
||||
// that drupal/core-recommended was added successfully by the project.
|
||||
$this->assertErrorOutputContains("Installing drupal/core ($simulated_core_version): Symlinking from");
|
||||
// Verify that there is an autoloader. This is written by the scaffold
|
||||
// plugin, so its existence assures us that scaffolding happened.
|
||||
$this->assertFileExists($autoloader);
|
||||
// Verify recipes directory exists.
|
||||
$this->assertDirectoryExists($recipes_dir);
|
||||
|
||||
// Verify that the minimum stability in the installed composer.json file
|
||||
// matches the stability of the simulated core version.
|
||||
$this->assertFileExists($installed_composer_json);
|
||||
$composer_json_contents = file_get_contents($installed_composer_json);
|
||||
$this->assertStringContainsString('"minimum-stability": "' . static::MINIMUM_STABILITY . '"', $composer_json_contents);
|
||||
|
||||
// In order to verify that Composer used the path repos for our project, we
|
||||
// have to get the requirements from the project composer.json so we can
|
||||
// reconcile our expectations.
|
||||
$template_json_file = $this->getWorkspaceDirectory() . '/' . $package_dir . '/composer.json';
|
||||
$this->assertFileExists($template_json_file);
|
||||
$json_file = new JsonFile($template_json_file);
|
||||
$template_json = $json_file->read();
|
||||
// Get the require and require-dev information, and ensure that our
|
||||
// requirements are not erroneously empty.
|
||||
$this->assertNotEmpty(
|
||||
$require = array_merge($template_json['require'] ?? [], $template_json['require-dev'] ?? [])
|
||||
);
|
||||
// Verify that path repo packages were installed.
|
||||
$path_repos = array_keys($path_repos);
|
||||
foreach (array_keys($require) as $package_name) {
|
||||
if (in_array($package_name, $path_repos)) {
|
||||
// Metapackages do not report that they were installed as symlinks, but
|
||||
// we still must check that their installed version matches
|
||||
// COMPOSER_CORE_VERSION.
|
||||
if (array_key_exists($package_name, $metapackage_path_repos)) {
|
||||
$this->assertErrorOutputContains("Installing $package_name ($simulated_core_version)");
|
||||
}
|
||||
else {
|
||||
$this->assertErrorOutputContains("Installing $package_name ($simulated_core_version): Symlinking from");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test package that points to the templates.
|
||||
*
|
||||
* @param string $repository_path
|
||||
* The path where to create the test package.
|
||||
* @param string $version
|
||||
* The version under test.
|
||||
*/
|
||||
protected function makeTestPackage($repository_path, $version): void {
|
||||
$json = <<<JSON
|
||||
{
|
||||
"packages": {
|
||||
"drupal/recommended-project": {
|
||||
"$version": {
|
||||
"name": "drupal/recommended-project",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "composer/Template/RecommendedProject"
|
||||
},
|
||||
"type": "project",
|
||||
"version": "$version"
|
||||
}
|
||||
},
|
||||
"drupal/legacy-project": {
|
||||
"$version": {
|
||||
"name": "drupal/legacy-project",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "composer/Template/LegacyProject"
|
||||
},
|
||||
"type": "project",
|
||||
"version": "$version"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON;
|
||||
mkdir(dirname($repository_path));
|
||||
file_put_contents($repository_path, $json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test package that points to all the projects in vendor.
|
||||
*
|
||||
* @param string $repository_path
|
||||
* The path where to create the test package.
|
||||
*/
|
||||
protected function makeVendorPackage($repository_path): void {
|
||||
$root = $this->getDrupalRoot();
|
||||
$process = $this->executeCommand("composer --working-dir=$root info --format=json");
|
||||
$this->assertCommandSuccessful();
|
||||
$installed = json_decode($process->getOutput(), TRUE);
|
||||
|
||||
// Build out package definitions for everything installed in
|
||||
// the vendor directory.
|
||||
$packages = [];
|
||||
foreach ($installed['installed'] as $project) {
|
||||
$name = $project['name'];
|
||||
$version = $project['version'];
|
||||
$path = "vendor/$name";
|
||||
$full_path = "$root/$path";
|
||||
// We are building a set of path repositories to projects in the vendor
|
||||
// directory, so we will skip any project that does not exist in vendor.
|
||||
// Also skip the projects that are symlinked in vendor. These are in our
|
||||
// metapackage. They will be represented as path repositories in the test
|
||||
// project's composer.json.
|
||||
if (is_dir($full_path) && !is_link($full_path)) {
|
||||
$packages['packages'][$name] = [
|
||||
$version => [
|
||||
"name" => $name,
|
||||
"dist" => [
|
||||
"type" => "path",
|
||||
"url" => $full_path,
|
||||
],
|
||||
"version" => $version,
|
||||
],
|
||||
];
|
||||
// Ensure composer plugins are registered correctly.
|
||||
$package_json = json_decode(file_get_contents($full_path . '/composer.json'), TRUE);
|
||||
if (isset($package_json['type']) && $package_json['type'] === 'composer-plugin') {
|
||||
$packages['packages'][$name][$version]['type'] = $package_json['type'];
|
||||
$packages['packages'][$name][$version]['require'] = $package_json['require'];
|
||||
$packages['packages'][$name][$version]['extra'] = $package_json['extra'];
|
||||
if (isset($package_json['autoload'])) {
|
||||
$packages['packages'][$name][$version]['autoload'] = $package_json['autoload'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$json = json_encode($packages, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
mkdir(dirname($repository_path));
|
||||
file_put_contents($repository_path, $json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stability of the current core version.
|
||||
*
|
||||
* If the current core version is a tagged release (not a "-dev" version),
|
||||
* this returns the stability of that version.
|
||||
*
|
||||
* If the current core version is a "-dev" version, but not a "x.y.0-dev"
|
||||
* version, this returns "stable", because it means that the corresponding
|
||||
* "x.y.0" has already been released, and only stable changes are now
|
||||
* permitted on the branch.
|
||||
*
|
||||
* If the current core version is a "x.y.0-dev" version, then this returns
|
||||
* the stability of the latest tag that matches "x.y.0-*". For example, if
|
||||
* we've already released "x.y.0-alpha1" but have not yet released
|
||||
* "x.y.0-beta1", then the current stability is "alpha". If there aren't any
|
||||
* matching tags, this returns "dev", because it means that an "alpha1" has
|
||||
* not yet been released.
|
||||
*
|
||||
* @return string
|
||||
* One of: "dev", "alpha", "beta", "RC", "stable".
|
||||
*/
|
||||
protected function getCoreStability() {
|
||||
$version = \Drupal::VERSION;
|
||||
// If the current version is x.y-dev then this is the equivalent of the main
|
||||
// branch and should be treated as a dev release.
|
||||
if (preg_match('/^(\d)+\.(\d)+-dev$/', $version)) {
|
||||
return 'dev';
|
||||
}
|
||||
$stability = VersionParser::parseStability($version);
|
||||
if ($stability === 'dev') {
|
||||
// Strip off "-dev";
|
||||
$version_towards = substr($version, 0, -4);
|
||||
|
||||
if (!str_ends_with($version_towards, '.0')) {
|
||||
// If the current version is developing towards an x.y.z release where
|
||||
// z is not 0, it means that the x.y.0 has already been released, and
|
||||
// only stable changes are permitted on the branch.
|
||||
$stability = 'stable';
|
||||
}
|
||||
else {
|
||||
// If the current version is developing towards an x.y.0 release, there
|
||||
// might be tagged pre-releases. "git describe" identifies the latest
|
||||
// one.
|
||||
$root = $this->getDrupalRoot();
|
||||
$process = $this->executeCommand("git -C \"$root\" describe --abbrev=0 --match=\"$version_towards-*\"");
|
||||
|
||||
// If there aren't any tagged pre-releases for this version yet, return
|
||||
// 'dev'. Ensure that any other error from "git describe" causes a test
|
||||
// failure.
|
||||
if (!$process->isSuccessful()) {
|
||||
$this->assertErrorOutputContains('No names found, cannot describe anything.');
|
||||
return 'dev';
|
||||
}
|
||||
|
||||
// We expect a pre-release, because:
|
||||
// - A tag should not be of "dev" stability.
|
||||
// - After a "stable" release is made, \Drupal::VERSION is incremented,
|
||||
// so there should not be a stable release on that new version.
|
||||
$stability = VersionParser::parseStability(trim($process->getOutput()));
|
||||
$this->assertContains($stability, ['alpha', 'beta', 'RC']);
|
||||
}
|
||||
}
|
||||
return $stability;
|
||||
}
|
||||
|
||||
}
|
||||
671
web/core/tests/Drupal/BuildTests/Framework/BuildTestBase.php
Normal file
671
web/core/tests/Drupal/BuildTests/Framework/BuildTestBase.php
Normal file
@ -0,0 +1,671 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Framework;
|
||||
|
||||
use Behat\Mink\Driver\BrowserKitDriver;
|
||||
use Behat\Mink\Mink;
|
||||
use Behat\Mink\Session;
|
||||
use Composer\InstalledVersions;
|
||||
use Drupal\Component\FileSystem\FileSystem as DrupalFilesystem;
|
||||
use Drupal\Tests\DrupalTestBrowser;
|
||||
use Drupal\Tests\PhpUnitCompatibilityTrait;
|
||||
use Drupal\TestTools\Extension\RequiresComposerTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Lock\Store\FlockStore;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Provides a workspace to test build processes.
|
||||
*
|
||||
* Module tests extending BuildTestBase must exist in the
|
||||
* Drupal\Tests\your_module\Build namespace and live in the
|
||||
* modules/your_module/tests/src/Build directory.
|
||||
*
|
||||
* Tests for core/lib/Drupal classes extending BuildTestBase must exist in the
|
||||
* \Drupal\BuildTests namespace and live in the core/tests/Drupal/BuildTests
|
||||
* directory.
|
||||
*
|
||||
* If you need to build a file system and then run a command from the command
|
||||
* line then this is the test framework for you.
|
||||
*
|
||||
* Tests using this interface run in separate processes.
|
||||
*
|
||||
* Tests can perform HTTP requests against the assembled codebase.
|
||||
*
|
||||
* The results of these HTTP requests can be asserted using Mink.
|
||||
*
|
||||
* This framework does not use the same Mink extensions as BrowserTestBase.
|
||||
*
|
||||
* Features:
|
||||
* - Provide complete isolation between the test runner and the site under test.
|
||||
* - Provide a workspace where filesystem build processes can be performed.
|
||||
* - Allow for the use of PHP's build-in HTTP server to send requests to the
|
||||
* site built using the filesystem.
|
||||
* - Allow for commands and HTTP requests to be made to different subdirectories
|
||||
* of the workspace filesystem, to facilitate comparison between different
|
||||
* build results, and to support Composer builds which have an alternate
|
||||
* docroot.
|
||||
* - Provide as little framework as possible. Convenience methods should be
|
||||
* built into the test, or abstract base classes.
|
||||
* - Allow parallel testing, using random/unique port numbers for different HTTP
|
||||
* servers.
|
||||
*
|
||||
* We don't use UiHelperInterface because it is too tightly integrated to
|
||||
* Drupal.
|
||||
*/
|
||||
abstract class BuildTestBase extends TestCase {
|
||||
|
||||
use RequiresComposerTrait;
|
||||
use PhpUnitCompatibilityTrait;
|
||||
|
||||
/**
|
||||
* The working directory where this test will manipulate files.
|
||||
*
|
||||
* Use getWorkspaceDirectory() to access this information.
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @see self::getWorkspaceDirectory()
|
||||
*/
|
||||
private $workspaceDir;
|
||||
|
||||
/**
|
||||
* The process that's running the HTTP server.
|
||||
*
|
||||
* @var \Symfony\Component\Process\Process
|
||||
*
|
||||
* @see self::standUpServer()
|
||||
* @see self::stopServer()
|
||||
*/
|
||||
private $serverProcess = NULL;
|
||||
|
||||
/**
|
||||
* Default to destroying build artifacts after a test finishes.
|
||||
*
|
||||
* Mainly useful for debugging.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $destroyBuild = TRUE;
|
||||
|
||||
/**
|
||||
* The docroot for the server process.
|
||||
*
|
||||
* This stores the last docroot directory used to start the server process. We
|
||||
* keep this information so we can restart the server if the desired docroot
|
||||
* changes.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $serverDocroot = NULL;
|
||||
|
||||
/**
|
||||
* Our native host name, used by PHP when it starts up the server.
|
||||
*
|
||||
* Requests should always be made to 'localhost', and not this IP address.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private static $hostName = '127.0.0.1';
|
||||
|
||||
/**
|
||||
* Port that will be tested.
|
||||
*
|
||||
* Generated internally. Use getPortNumber().
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $hostPort;
|
||||
|
||||
/**
|
||||
* A list of ports used by the test.
|
||||
*
|
||||
* Prevent the same process finding the same port by storing a list of ports
|
||||
* already discovered. This also stores locks so they are not released until
|
||||
* the test class is torn down.
|
||||
*
|
||||
* @var \Symfony\Component\Lock\LockInterface[]
|
||||
*/
|
||||
private $portLocks = [];
|
||||
|
||||
/**
|
||||
* The Mink session manager.
|
||||
*
|
||||
* @var \Behat\Mink\Mink
|
||||
*/
|
||||
private $mink;
|
||||
|
||||
/**
|
||||
* The most recent command process.
|
||||
*
|
||||
* @var \Symfony\Component\Process\Process
|
||||
*
|
||||
* @see ::executeCommand()
|
||||
*/
|
||||
private $commandProcess;
|
||||
|
||||
/**
|
||||
* The PHP executable finder.
|
||||
*
|
||||
* @var \Symfony\Component\Process\PhpExecutableFinder
|
||||
*/
|
||||
private PhpExecutableFinder $phpFinder;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->phpFinder = new PhpExecutableFinder();
|
||||
// Set up the workspace directory.
|
||||
// @todo Glean working directory from env vars, etc.
|
||||
$fs = new SymfonyFilesystem();
|
||||
$this->workspaceDir = $fs->tempnam(DrupalFilesystem::getOsTemporaryDirectory(), '/build_workspace_' . md5($this->name() . microtime(TRUE)));
|
||||
$fs->remove($this->workspaceDir);
|
||||
$fs->mkdir($this->workspaceDir);
|
||||
$this->initMink();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function tearDown(): void {
|
||||
parent::tearDown();
|
||||
|
||||
$this->stopServer();
|
||||
foreach ($this->portLocks as $lock) {
|
||||
$lock->release();
|
||||
}
|
||||
$ws = $this->getWorkspaceDirectory();
|
||||
$fs = new SymfonyFilesystem();
|
||||
if ($this->destroyBuild && $fs->exists($ws)) {
|
||||
// Filter out symlinks as chmod cannot alter them.
|
||||
$finder = new Finder();
|
||||
$finder->in($ws)
|
||||
->directories()
|
||||
->ignoreVCS(FALSE)
|
||||
->ignoreDotFiles(FALSE)
|
||||
// Composer script is a symlink and fails chmod. Ignore it.
|
||||
->notPath('/^vendor\/bin\/composer$/');
|
||||
$fs->chmod($finder->getIterator(), 0775, 0000);
|
||||
$fs->remove($ws);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the working directory within the workspace, creating if necessary.
|
||||
*
|
||||
* @param string $working_dir
|
||||
* The path within the workspace directory.
|
||||
*
|
||||
* @return string
|
||||
* The full path to the working directory within the workspace directory.
|
||||
*/
|
||||
protected function getWorkingPath($working_dir = NULL) {
|
||||
$full_path = $this->getWorkspaceDirectory();
|
||||
if ($working_dir) {
|
||||
$full_path .= '/' . $working_dir;
|
||||
}
|
||||
if (!file_exists($full_path)) {
|
||||
$fs = new SymfonyFilesystem();
|
||||
$fs->mkdir($full_path);
|
||||
}
|
||||
return $full_path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the Mink session manager.
|
||||
*
|
||||
* @return \Behat\Mink\Session
|
||||
* The Mink session.
|
||||
*/
|
||||
protected function initMink() {
|
||||
$client = new DrupalTestBrowser();
|
||||
$client->followMetaRefresh(TRUE);
|
||||
$driver = new BrowserKitDriver($client);
|
||||
$session = new Session($driver);
|
||||
$this->mink = new Mink();
|
||||
$this->mink->registerSession('default', $session);
|
||||
$this->mink->setDefaultSessionName('default');
|
||||
$session->start();
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Mink instance.
|
||||
*
|
||||
* Use the Mink object to perform assertions against the content returned by a
|
||||
* request.
|
||||
*
|
||||
* @return \Behat\Mink\Mink
|
||||
* The Mink object.
|
||||
*/
|
||||
public function getMink() {
|
||||
return $this->mink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full path to the workspace where this test can build.
|
||||
*
|
||||
* This is often a directory within the system's temporary directory.
|
||||
*
|
||||
* @return string
|
||||
* Full path to the workspace where this test can build.
|
||||
*/
|
||||
public function getWorkspaceDirectory() {
|
||||
return $this->workspaceDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that text is present in the error output of the most recent command.
|
||||
*
|
||||
* @param string $expected
|
||||
* Text we expect to find in the error output of the command.
|
||||
*/
|
||||
public function assertErrorOutputContains($expected) {
|
||||
$this->assertStringContainsString($expected, $this->commandProcess->getErrorOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert text is not present in the error output of the most recent command.
|
||||
*
|
||||
* @param string $expected
|
||||
* Text we expect not to find in the error output of the command.
|
||||
*/
|
||||
public function assertErrorOutputNotContains($expected) {
|
||||
$this->assertStringNotContainsString($expected, $this->commandProcess->getErrorOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that text is present in the output of the most recent command.
|
||||
*
|
||||
* @param string $expected
|
||||
* Text we expect to find in the output of the command.
|
||||
*/
|
||||
public function assertCommandOutputContains($expected) {
|
||||
$this->assertStringContainsString($expected, $this->commandProcess->getOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the last command ran without error.
|
||||
*
|
||||
* This assertion checks whether the last command returned an exit code of 0.
|
||||
*
|
||||
* If you need to assert a different exit code, then you can use
|
||||
* executeCommand() and perform a different assertion on the process object.
|
||||
*/
|
||||
public function assertCommandSuccessful() {
|
||||
return $this->assertCommandExitCode(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the last command returned the specified exit code.
|
||||
*
|
||||
* @param int $expected_code
|
||||
* The expected process exit code.
|
||||
*/
|
||||
public function assertCommandExitCode($expected_code) {
|
||||
$this->assertEquals($expected_code, $this->commandProcess->getExitCode(),
|
||||
'COMMAND: ' . $this->commandProcess->getCommandLine() . "\n" .
|
||||
'OUTPUT: ' . $this->commandProcess->getOutput() . "\n" .
|
||||
'ERROR: ' . $this->commandProcess->getErrorOutput() . "\n"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command.
|
||||
*
|
||||
* @param string $command_line
|
||||
* A command line to run in an isolated process.
|
||||
* @param string $working_dir
|
||||
* (optional) A working directory relative to the workspace, within which to
|
||||
* execute the command. Defaults to the workspace directory.
|
||||
*
|
||||
* @return \Symfony\Component\Process\Process
|
||||
* The process object.
|
||||
*/
|
||||
public function executeCommand($command_line, $working_dir = NULL) {
|
||||
$this->commandProcess = Process::fromShellCommandline($command_line);
|
||||
$this->commandProcess->setWorkingDirectory($this->getWorkingPath($working_dir))
|
||||
->setTimeout(360)
|
||||
->setIdleTimeout(360);
|
||||
$this->commandProcess->run();
|
||||
return $this->commandProcess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to assert that the last visit was a Drupal site.
|
||||
*
|
||||
* This method asserts that the X-Generator header shows that the site is a
|
||||
* Drupal site.
|
||||
*/
|
||||
public function assertDrupalVisit() {
|
||||
$this->getMink()->assertSession()->responseHeaderMatches('X-Generator', '/Drupal \d+ \(https:\/\/www.drupal.org\)/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit a URI on the HTTP server.
|
||||
*
|
||||
* The concept here is that there could be multiple potential docroots in the
|
||||
* workspace, so you can use whichever ones you want.
|
||||
*
|
||||
* @param string $request_uri
|
||||
* (optional) The non-host part of the URL. Example: /some/path?foo=bar.
|
||||
* Defaults to visiting the homepage.
|
||||
* @param string $working_dir
|
||||
* (optional) Relative path within the test workspace file system that will
|
||||
* be the docroot for the request. Defaults to the workspace directory.
|
||||
*
|
||||
* @return \Behat\Mink\Mink
|
||||
* The Mink object. Perform assertions against this.
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
* Thrown when $request_uri does not start with a slash.
|
||||
*/
|
||||
public function visit($request_uri = '/', $working_dir = NULL) {
|
||||
if ($request_uri[0] !== '/') {
|
||||
throw new \InvalidArgumentException('URI: ' . $request_uri . ' must be relative. Example: /some/path?foo=bar');
|
||||
}
|
||||
// Try to make a server.
|
||||
$this->standUpServer($working_dir);
|
||||
|
||||
$request = 'http://localhost:' . $this->getPortNumber() . $request_uri;
|
||||
$this->mink->getSession()->visit($request);
|
||||
return $this->mink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a local test server using PHP's internal HTTP server.
|
||||
*
|
||||
* Test authors should call visit() or assertVisit() instead.
|
||||
*
|
||||
* @param string|null $working_dir
|
||||
* (optional) Server docroot relative to the workspace file system. Defaults
|
||||
* to the workspace directory.
|
||||
*/
|
||||
protected function standUpServer($working_dir = NULL) {
|
||||
// If the user wants to test a new docroot, we have to shut down the old
|
||||
// server process and generate a new port number.
|
||||
if ($working_dir !== $this->serverDocroot && !empty($this->serverProcess)) {
|
||||
$this->stopServer();
|
||||
}
|
||||
// If there's not a server at this point, make one.
|
||||
if (!$this->serverProcess || $this->serverProcess->isTerminated()) {
|
||||
$this->serverProcess = $this->instantiateServer($this->getPortNumber(), $working_dir);
|
||||
if ($this->serverProcess) {
|
||||
$this->serverDocroot = $working_dir;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Do the work of making a server process.
|
||||
*
|
||||
* Test authors should call visit() or assertVisit() instead.
|
||||
*
|
||||
* When initializing the server, if '.ht.router.php' exists in the root, it is
|
||||
* leveraged. If testing with a version of Drupal before 8.5.x., this file
|
||||
* does not exist.
|
||||
*
|
||||
* @param int $port
|
||||
* The port number for the server.
|
||||
* @param string|null $working_dir
|
||||
* (optional) Server docroot relative to the workspace filesystem. Defaults
|
||||
* to the workspace directory.
|
||||
*
|
||||
* @return \Symfony\Component\Process\Process
|
||||
* The server process.
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
* Thrown if we were unable to start a web server.
|
||||
*/
|
||||
protected function instantiateServer($port, $working_dir = NULL) {
|
||||
$finder = new PhpExecutableFinder();
|
||||
$working_path = $this->getWorkingPath($working_dir);
|
||||
$server = [
|
||||
$finder->find(),
|
||||
'-S',
|
||||
self::$hostName . ':' . $port,
|
||||
'-t',
|
||||
$working_path,
|
||||
];
|
||||
if (file_exists($working_path . DIRECTORY_SEPARATOR . '.ht.router.php')) {
|
||||
$server[] = $working_path . DIRECTORY_SEPARATOR . '.ht.router.php';
|
||||
}
|
||||
$ps = new Process($server, $working_path);
|
||||
$ps->setIdleTimeout(30)
|
||||
->setTimeout(30)
|
||||
->start();
|
||||
// Wait until the web server has started. It is started if the port is no
|
||||
// longer available.
|
||||
for ($i = 0; $i < 50; $i++) {
|
||||
usleep(100000);
|
||||
if (!$this->checkPortIsAvailable($port)) {
|
||||
return $ps;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException(sprintf("Unable to start the web server.\nCMD: %s \nCODE: %d\nSTATUS: %s\nOUTPUT:\n%s\n\nERROR OUTPUT:\n%s", $ps->getCommandLine(), $ps->getExitCode(), $ps->getStatus(), $ps->getOutput(), $ps->getErrorOutput()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the HTTP server, zero out all necessary variables.
|
||||
*/
|
||||
protected function stopServer() {
|
||||
if (!empty($this->serverProcess)) {
|
||||
$this->serverProcess->stop();
|
||||
}
|
||||
$this->serverProcess = NULL;
|
||||
$this->serverDocroot = NULL;
|
||||
$this->hostPort = NULL;
|
||||
$this->initMink();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover an available port number.
|
||||
*
|
||||
* @return int
|
||||
* The available port number that we discovered.
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
* Thrown when there are no available ports within the range.
|
||||
*/
|
||||
protected function findAvailablePort(): int {
|
||||
$store = new FlockStore(DrupalFilesystem::getOsTemporaryDirectory());
|
||||
$lock_factory = new LockFactory($store);
|
||||
|
||||
$counter = 100;
|
||||
while ($counter--) {
|
||||
// Limit to 9999 as higher ports cause random fails on DrupalCI.
|
||||
$port = random_int(1024, 9999);
|
||||
|
||||
if (isset($this->portLocks[$port])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Take a lock so that no other process can use the same port number even
|
||||
// if the server is yet to start.
|
||||
$lock = $lock_factory->createLock('drupal-build-test-port-' . $port);
|
||||
if ($lock->acquire()) {
|
||||
if ($this->checkPortIsAvailable($port)) {
|
||||
$this->portLocks[$port] = $lock;
|
||||
return $port;
|
||||
}
|
||||
else {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException('Unable to find a port available to run the web server.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a port is available.
|
||||
*
|
||||
* @param int $port
|
||||
* A number between 1024 and 65536.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the port is available, FALSE otherwise.
|
||||
*/
|
||||
protected function checkPortIsAvailable($port): bool {
|
||||
$fp = @fsockopen(self::$hostName, $port, $errno, $errstr, 1);
|
||||
// If fsockopen() fails to connect, probably nothing is listening.
|
||||
// It could be a firewall but that's impossible to detect, so as a
|
||||
// best guess let's return it as available.
|
||||
if ($fp === FALSE) {
|
||||
return TRUE;
|
||||
}
|
||||
else {
|
||||
fclose($fp);
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the port number for requests.
|
||||
*
|
||||
* Test should never call this. Used by standUpServer().
|
||||
*
|
||||
* @return int
|
||||
* The port number.
|
||||
*/
|
||||
protected function getPortNumber(): int {
|
||||
if (empty($this->hostPort)) {
|
||||
$this->hostPort = $this->findAvailablePort();
|
||||
}
|
||||
return $this->hostPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the current working codebase into a workspace.
|
||||
*
|
||||
* Use this method to copy the current codebase, including any patched
|
||||
* changes, into the workspace.
|
||||
*
|
||||
* By default, the copy will exclude site-specific and build-related files and
|
||||
* directories. Use the $iterator parameter to override this behavior.
|
||||
*
|
||||
* @param \Iterator|null $iterator
|
||||
* (optional) An iterator of all the files to copy. Default behavior is to
|
||||
* exclude site-specific directories and files.
|
||||
* @param string|null $working_dir
|
||||
* (optional) Relative path within the test workspace file system that will
|
||||
* contain the copy of the codebase. Defaults to the workspace directory.
|
||||
*/
|
||||
public function copyCodebase(?\Iterator $iterator = NULL, $working_dir = NULL) {
|
||||
$working_path = $this->getWorkingPath($working_dir);
|
||||
|
||||
if ($iterator === NULL) {
|
||||
$iterator = $this->getCodebaseFinder()->getIterator();
|
||||
}
|
||||
|
||||
$fs = new SymfonyFilesystem();
|
||||
$options = ['override' => TRUE, 'delete' => FALSE];
|
||||
$fs->mirror($this->getComposerRoot(), $working_path, $iterator, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a default Finder object for a Drupal codebase.
|
||||
*
|
||||
* This method can be used two ways:
|
||||
* - Override this method and provide your own default Finder object for
|
||||
* copyCodebase().
|
||||
* - Call the method to get a default Finder object which can then be
|
||||
* modified for other purposes.
|
||||
*
|
||||
* Note that the vendor directory is deliberately not included in the
|
||||
* directory exclusions here, so that packages are copied and composer does
|
||||
* not attempt to download them from packagist/github during test runs.
|
||||
*
|
||||
* @return \Symfony\Component\Finder\Finder
|
||||
* A Finder object ready to iterate over core codebase.
|
||||
*/
|
||||
public function getCodebaseFinder() {
|
||||
$drupal_root = $this->getWorkingPathDrupalRoot() ?? '';
|
||||
$finder = new Finder();
|
||||
$finder->files()
|
||||
->followLinks()
|
||||
->ignoreUnreadableDirs()
|
||||
->in($this->getComposerRoot())
|
||||
->notPath("#^{$drupal_root}sites/default/files#")
|
||||
->notPath("#^{$drupal_root}sites/simpletest#")
|
||||
->notPath("#^{$drupal_root}core/node_modules#")
|
||||
->notPath("#^{$drupal_root}sites/default/settings\..*php#")
|
||||
->ignoreDotFiles(FALSE)
|
||||
->ignoreVCS(FALSE);
|
||||
return $finder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the root path of this Drupal codebase.
|
||||
*
|
||||
* @return string
|
||||
* The full path to the root of this Drupal codebase.
|
||||
*/
|
||||
public function getDrupalRoot() {
|
||||
return self::getDrupalRootStatic();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the root path of this Drupal codebase.
|
||||
*
|
||||
* @return string
|
||||
* The full path to the root of this Drupal codebase.
|
||||
*/
|
||||
public static function getDrupalRootStatic() {
|
||||
// Given this code is in the drupal/core package, $core cannot be NULL.
|
||||
/** @var string $core */
|
||||
$core = InstalledVersions::getInstallPath('drupal/core');
|
||||
return realpath(dirname($core));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the Composer root directory.
|
||||
*
|
||||
* @return string
|
||||
* The absolute path to the Composer root directory.
|
||||
*/
|
||||
public function getComposerRoot(): string {
|
||||
$root = InstalledVersions::getRootPackage();
|
||||
return realpath($root['install_path']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to Drupal root in the workspace directory.
|
||||
*
|
||||
* @return string
|
||||
* The absolute path to the Drupal root directory in the workspace.
|
||||
*/
|
||||
public function getWorkspaceDrupalRoot(): string {
|
||||
$dir = $this->getWorkspaceDirectory();
|
||||
$drupal_root = $this->getWorkingPathDrupalRoot();
|
||||
if ($drupal_root !== NULL) {
|
||||
$dir = $dir . DIRECTORY_SEPARATOR . $drupal_root;
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the working path for Drupal core.
|
||||
*
|
||||
* @return string|null
|
||||
* The relative path to Drupal's root directory or NULL if it is the same
|
||||
* as the composer root directory.
|
||||
*/
|
||||
public function getWorkingPathDrupalRoot(): ?string {
|
||||
$composer_root = $this->getComposerRoot();
|
||||
$drupal_root = $this->getDrupalRoot();
|
||||
if ($composer_root === $drupal_root) {
|
||||
return NULL;
|
||||
}
|
||||
return (new SymfonyFilesystem())->makePathRelative($drupal_root, $composer_root);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Framework\Tests;
|
||||
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use org\bovigo\vfs\vfsStream;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \Drupal\BuildTests\Framework\BuildTestBase
|
||||
* @group Build
|
||||
*/
|
||||
class BuildTestTest extends BuildTestBase {
|
||||
|
||||
/**
|
||||
* Ensure that workspaces work.
|
||||
*/
|
||||
public function testWorkspace(): void {
|
||||
$test_directory = 'test_directory';
|
||||
|
||||
// Execute an empty command through the shell to build out a working
|
||||
// directory.
|
||||
$process = $this->executeCommand('', $test_directory);
|
||||
$this->assertCommandSuccessful();
|
||||
|
||||
// Assert that our working directory exists and is in use by the process.
|
||||
$workspace = $this->getWorkspaceDirectory();
|
||||
$working_path = $workspace . '/' . $test_directory;
|
||||
$this->assertDirectoryExists($working_path);
|
||||
$this->assertEquals($working_path, $process->getWorkingDirectory());
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers ::copyCodebase
|
||||
*/
|
||||
public function testCopyCodebase(): void {
|
||||
$test_directory = 'copied_codebase';
|
||||
$this->copyCodebase(NULL, $test_directory);
|
||||
$full_path = $this->getWorkspaceDirectory() . '/' . $test_directory;
|
||||
$files = [
|
||||
'autoload.php',
|
||||
'composer.json',
|
||||
'index.php',
|
||||
'README.md',
|
||||
'.git',
|
||||
'.ht.router.php',
|
||||
];
|
||||
foreach ($files as $file) {
|
||||
$this->assertFileExists($full_path . '/' . $file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we're not copying directories we wish to exclude.
|
||||
*
|
||||
* @covers ::copyCodebase
|
||||
*/
|
||||
public function testCopyCodebaseExclude(): void {
|
||||
// Create a virtual file system containing items that should be
|
||||
// excluded. Exception being modules directory.
|
||||
vfsStream::setup('drupal', NULL, [
|
||||
'sites' => [
|
||||
'default' => [
|
||||
'files' => [
|
||||
'a_file.txt' => 'some file.',
|
||||
],
|
||||
'settings.php' => '<?php $settings = stuff;',
|
||||
'settings.local.php' => '<?php $settings = override;',
|
||||
],
|
||||
'simpletest' => [
|
||||
'simpletest_hash' => [
|
||||
'some_results.xml' => '<xml/>',
|
||||
],
|
||||
],
|
||||
],
|
||||
'modules' => [
|
||||
'my_module' => [
|
||||
'vendor' => [
|
||||
'my_vendor' => [
|
||||
'composer.json' => "{\n}",
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Mock BuildTestBase so that it thinks our VFS is the Composer and Drupal
|
||||
// roots.
|
||||
/** @var \PHPUnit\Framework\MockObject\MockBuilder|\Drupal\BuildTests\Framework\BuildTestBase $base */
|
||||
$base = $this->getMockBuilder(BuildTestBase::class)
|
||||
->onlyMethods(['getDrupalRoot', 'getComposerRoot'])
|
||||
->setConstructorArgs(['test'])
|
||||
->getMock();
|
||||
$base->expects($this->exactly(1))
|
||||
->method('getDrupalRoot')
|
||||
->willReturn(vfsStream::url('drupal'));
|
||||
$base->expects($this->exactly(3))
|
||||
->method('getComposerRoot')
|
||||
->willReturn(vfsStream::url('drupal'));
|
||||
|
||||
$base->setUp();
|
||||
|
||||
// Perform the copy.
|
||||
$test_directory = 'copied_codebase';
|
||||
$base->copyCodebase(NULL, $test_directory);
|
||||
$full_path = $base->getWorkspaceDirectory() . '/' . $test_directory;
|
||||
|
||||
$this->assertDirectoryExists($full_path);
|
||||
|
||||
// Verify nested vendor directory was not excluded. Then remove it for next
|
||||
// validation.
|
||||
$this->assertFileExists($full_path . DIRECTORY_SEPARATOR . 'modules/my_module/vendor/my_vendor/composer.json');
|
||||
$file_system = new Filesystem();
|
||||
$file_system->remove($full_path . DIRECTORY_SEPARATOR . 'modules');
|
||||
|
||||
// Use scandir() to determine if our target directory is empty. It should
|
||||
// only contain the system dot directories.
|
||||
$this->assertTrue(
|
||||
($files = @scandir($full_path)) && count($files) <= 2,
|
||||
'Directory is not empty: ' . implode(', ', $files)
|
||||
);
|
||||
|
||||
$base->tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests copying codebase when Drupal and Composer roots are different.
|
||||
*
|
||||
* @covers ::copyCodebase
|
||||
*/
|
||||
public function testCopyCodebaseDocRoot(): void {
|
||||
// Create a virtual file system containing items that should be
|
||||
// excluded. Exception being modules directory.
|
||||
vfsStream::setup('drupal', NULL, [
|
||||
'docroot' => [
|
||||
'sites' => [
|
||||
'default' => [
|
||||
'files' => [
|
||||
'a_file.txt' => 'some file.',
|
||||
],
|
||||
'settings.php' => '<?php $settings = "stuff";',
|
||||
'settings.local.php' => '<?php $settings = "override";',
|
||||
'default.settings.php' => '<?php $settings = "default";',
|
||||
],
|
||||
'simpletest' => [
|
||||
'simpletest_hash' => [
|
||||
'some_results.xml' => '<xml/>',
|
||||
],
|
||||
],
|
||||
],
|
||||
'modules' => [
|
||||
'my_module' => [
|
||||
'vendor' => [
|
||||
'my_vendor' => [
|
||||
'composer.json' => "{\n}",
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'vendor' => [
|
||||
'test.txt' => 'File exists',
|
||||
],
|
||||
]);
|
||||
|
||||
// Mock BuildTestBase so that it thinks our VFS is the Composer and Drupal
|
||||
// roots.
|
||||
/** @var \PHPUnit\Framework\MockObject\MockBuilder|\Drupal\BuildTests\Framework\BuildTestBase $base */
|
||||
$base = $this->getMockBuilder(BuildTestBase::class)
|
||||
->onlyMethods(['getDrupalRoot', 'getComposerRoot'])
|
||||
->setConstructorArgs(['test'])
|
||||
->getMock();
|
||||
$base->expects($this->exactly(3))
|
||||
->method('getDrupalRoot')
|
||||
->willReturn(vfsStream::url('drupal/docroot'));
|
||||
$base->expects($this->exactly(5))
|
||||
->method('getComposerRoot')
|
||||
->willReturn(vfsStream::url('drupal'));
|
||||
|
||||
$base->setUp();
|
||||
|
||||
// Perform the copy.
|
||||
$base->copyCodebase();
|
||||
$full_path = $base->getWorkspaceDirectory();
|
||||
|
||||
$this->assertDirectoryExists($full_path . '/docroot');
|
||||
|
||||
// Verify expected files exist.
|
||||
$this->assertFileExists($full_path . DIRECTORY_SEPARATOR . 'docroot/modules/my_module/vendor/my_vendor/composer.json');
|
||||
$this->assertFileExists($full_path . DIRECTORY_SEPARATOR . 'docroot/sites/default/default.settings.php');
|
||||
$this->assertFileExists($full_path . DIRECTORY_SEPARATOR . 'vendor');
|
||||
|
||||
// Verify expected files do not exist
|
||||
$this->assertFileDoesNotExist($full_path . DIRECTORY_SEPARATOR . 'docroot/sites/default/settings.php');
|
||||
$this->assertFileDoesNotExist($full_path . DIRECTORY_SEPARATOR . 'docroot/sites/default/settings.local.php');
|
||||
$this->assertFileDoesNotExist($full_path . DIRECTORY_SEPARATOR . 'docroot/sites/default/files');
|
||||
|
||||
// Ensure that the workspace Drupal root is calculated correctly.
|
||||
$this->assertSame($full_path . '/docroot/', $base->getWorkspaceDrupalRoot());
|
||||
$this->assertSame('docroot/', $base->getWorkingPathDrupalRoot());
|
||||
|
||||
$base->tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers ::findAvailablePort
|
||||
*/
|
||||
public function testPortMany(): void {
|
||||
$iterator = (new Finder())->in($this->getDrupalRoot())
|
||||
->ignoreDotFiles(FALSE)
|
||||
->exclude(['sites/simpletest'])
|
||||
->path('/^.ht.router.php$/')
|
||||
->getIterator();
|
||||
$this->copyCodebase($iterator);
|
||||
/** @var \Symfony\Component\Process\Process[] $processes */
|
||||
$processes = [];
|
||||
$count = 15;
|
||||
for ($i = 0; $i <= $count; $i++) {
|
||||
$port = $this->findAvailablePort();
|
||||
$this->assertArrayNotHasKey($port, $processes, 'Port ' . $port . ' was already in use by a process.');
|
||||
$processes[$port] = $this->instantiateServer($port);
|
||||
$this->assertNotEmpty($processes[$port]);
|
||||
$this->assertTrue($processes[$port]->isRunning(), 'Process on port ' . $port . ' is not still running.');
|
||||
$this->assertFalse($this->checkPortIsAvailable($port));
|
||||
}
|
||||
|
||||
// Clean up after ourselves.
|
||||
foreach ($processes as $process) {
|
||||
$process->stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers ::standUpServer
|
||||
*/
|
||||
public function testStandUpServer(): void {
|
||||
// Stand up a server with working directory 'first'.
|
||||
$this->standUpServer('first');
|
||||
|
||||
// Get the process object for the server.
|
||||
$ref_process = new \ReflectionProperty(parent::class, 'serverProcess');
|
||||
$first_process = $ref_process->getValue($this);
|
||||
|
||||
// Standing up the server again should not change the server process.
|
||||
$this->standUpServer('first');
|
||||
$this->assertSame($first_process, $ref_process->getValue($this));
|
||||
|
||||
// Standing up the server with working directory 'second' should give us a
|
||||
// new server process.
|
||||
$this->standUpServer('second');
|
||||
$this->assertNotSame(
|
||||
$first_process,
|
||||
$second_process = $ref_process->getValue($this)
|
||||
);
|
||||
|
||||
// And even with the original working directory name, we should get a new
|
||||
// server process.
|
||||
$this->standUpServer('first');
|
||||
$this->assertNotSame($first_process, $ref_process->getValue($this));
|
||||
$this->assertNotSame($second_process, $ref_process->getValue($this));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Framework\Tests;
|
||||
|
||||
use Drupal\BuildTests\QuickStart\QuickStartTestBase;
|
||||
use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \Drupal\BuildTests\Framework\BuildTestBase
|
||||
* @group Build
|
||||
* @requires extension pdo_sqlite
|
||||
*/
|
||||
class HtRouterTest extends QuickStartTestBase {
|
||||
|
||||
/**
|
||||
* @covers ::instantiateServer
|
||||
*/
|
||||
public function testHtRouter(): void {
|
||||
$sqlite = (new \PDO('sqlite::memory:'))->query('select sqlite_version()')->fetch()[0];
|
||||
if (version_compare($sqlite, Tasks::SQLITE_MINIMUM_VERSION) < 0) {
|
||||
$this->markTestSkipped();
|
||||
}
|
||||
|
||||
$this->copyCodebase();
|
||||
$this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer install --no-dev --no-interaction');
|
||||
$this->assertErrorOutputContains('Generating autoload files');
|
||||
$this->installQuickStart('minimal');
|
||||
$this->formLogin($this->adminUsername, $this->adminPassword);
|
||||
$this->visit('/.well-known/change-password');
|
||||
$this->assertDrupalVisit();
|
||||
$url = $this->getMink()->getSession()->getCurrentUrl();
|
||||
$this->assertEquals('http://localhost:' . $this->getPortNumber() . '/user/1/edit', $url);
|
||||
}
|
||||
|
||||
}
|
||||
300
web/core/tests/Drupal/BuildTests/QuickStart/QuickStartTest.php
Normal file
300
web/core/tests/Drupal/BuildTests/QuickStart/QuickStartTest.php
Normal file
@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\QuickStart;
|
||||
|
||||
use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks;
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use Drupal\Core\Test\TestDatabase;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Cookie\CookieJar;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use PHPUnit\Framework\Attributes\PreserveGlobalState;
|
||||
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
|
||||
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Tests the quick-start commands.
|
||||
*
|
||||
* These tests are run in a separate process because they load Drupal code via
|
||||
* an include.
|
||||
*/
|
||||
#[Group('Command')]
|
||||
#[PreserveGlobalState(FALSE)]
|
||||
#[RequiresPhpExtension('pdo_sqlite')]
|
||||
#[RunTestsInSeparateProcesses]
|
||||
class QuickStartTest extends BuildTestBase {
|
||||
|
||||
/**
|
||||
* The PHP executable path.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $php;
|
||||
|
||||
/**
|
||||
* A test database object.
|
||||
*
|
||||
* @var \Drupal\Core\Test\TestDatabase
|
||||
*/
|
||||
protected $testDb;
|
||||
|
||||
/**
|
||||
* The Drupal root directory.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $root;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$php_executable_finder = new PhpExecutableFinder();
|
||||
$this->php = $php_executable_finder->find();
|
||||
$this->root = dirname(substr(__DIR__, 0, -strlen(__NAMESPACE__)), 2);
|
||||
chdir($this->root);
|
||||
if (!is_writable("{$this->root}/sites/simpletest")) {
|
||||
$this->markTestSkipped('This test requires a writable sites/simpletest directory');
|
||||
}
|
||||
// Get a lock and a valid site path.
|
||||
$this->testDb = new TestDatabase();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function tearDown(): void {
|
||||
if ($this->testDb) {
|
||||
$test_site_directory = $this->root . DIRECTORY_SEPARATOR . $this->testDb->getTestSitePath();
|
||||
if (file_exists($test_site_directory)) {
|
||||
// @todo use the tear down command from
|
||||
// https://www.drupal.org/project/drupal/issues/2926633
|
||||
// Delete test site directory.
|
||||
$this->fileUnmanagedDeleteRecursive($test_site_directory, [
|
||||
BrowserTestBase::class,
|
||||
'filePreDeleteCallback',
|
||||
]);
|
||||
}
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the quick-start command.
|
||||
*/
|
||||
public function testQuickStartCommand(): void {
|
||||
$sqlite = (new \PDO('sqlite::memory:'))->query('select sqlite_version()')->fetch()[0];
|
||||
if (version_compare($sqlite, Tasks::SQLITE_MINIMUM_VERSION) < 0) {
|
||||
$this->markTestSkipped();
|
||||
}
|
||||
|
||||
// Install a site using the standard profile to ensure the one time login
|
||||
// link generation works.
|
||||
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'quick-start',
|
||||
'standard',
|
||||
"--site-name='Test site {$this->testDb->getDatabasePrefix()}'",
|
||||
'--suppress-login',
|
||||
];
|
||||
$process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
|
||||
$process->setTimeout(500);
|
||||
$process->start();
|
||||
$guzzle = new Client();
|
||||
$port = FALSE;
|
||||
$process->waitUntil(function ($type, $output) use (&$port) {
|
||||
if (preg_match('/127.0.0.1:(\d+)/', $output, $match)) {
|
||||
$port = $match[1];
|
||||
return TRUE;
|
||||
}
|
||||
});
|
||||
// The progress bar uses STDERR to write messages.
|
||||
$this->assertStringContainsString('Congratulations, you installed Drupal!', $process->getErrorOutput());
|
||||
// Ensure the command does not trigger any PHP deprecations.
|
||||
$this->assertStringNotContainsString('Deprecated', $process->getErrorOutput());
|
||||
$this->assertNotFalse($port, "Web server running on port $port");
|
||||
|
||||
// Give the server a couple of seconds to be ready.
|
||||
sleep(2);
|
||||
$this->assertStringContainsString("127.0.0.1:$port/user/reset/1/", $process->getOutput());
|
||||
|
||||
// Generate a cookie so we can make a request against the installed site.
|
||||
define('DRUPAL_TEST_IN_CHILD_SITE', FALSE);
|
||||
chmod($this->testDb->getTestSitePath(), 0755);
|
||||
$cookieJar = CookieJar::fromArray([
|
||||
'SIMPLETEST_USER_AGENT' => drupal_generate_test_ua($this->testDb->getDatabasePrefix()),
|
||||
], '127.0.0.1');
|
||||
|
||||
$response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]);
|
||||
$content = (string) $response->getBody();
|
||||
$this->assertStringContainsString('Test site ' . $this->testDb->getDatabasePrefix(), $content);
|
||||
|
||||
// Stop the web server.
|
||||
$process->stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the quick-start commands.
|
||||
*/
|
||||
public function testQuickStartInstallAndServerCommands(): void {
|
||||
$sqlite = (new \PDO('sqlite::memory:'))->query('select sqlite_version()')->fetch()[0];
|
||||
if (version_compare($sqlite, Tasks::SQLITE_MINIMUM_VERSION) < 0) {
|
||||
$this->markTestSkipped();
|
||||
}
|
||||
|
||||
// Install a site.
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'install',
|
||||
'minimal',
|
||||
"--password='secret'",
|
||||
"--site-name='Test site {$this->testDb->getDatabasePrefix()}'",
|
||||
];
|
||||
$install_process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
|
||||
$install_process->setTimeout(500);
|
||||
$result = $install_process->run();
|
||||
// The progress bar uses STDERR to write messages.
|
||||
$this->assertStringContainsString('Congratulations, you installed Drupal!', $install_process->getErrorOutput());
|
||||
$this->assertStringContainsString("Password: 'secret'", $install_process->getOutput());
|
||||
$this->assertSame(0, $result);
|
||||
|
||||
// Run the PHP built-in webserver.
|
||||
$server_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'server',
|
||||
'--suppress-login',
|
||||
];
|
||||
$server_process = new Process($server_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
|
||||
$server_process->start();
|
||||
$guzzle = new Client();
|
||||
$port = FALSE;
|
||||
$server_process->waitUntil(function ($type, $output) use (&$port) {
|
||||
if (preg_match('/127.0.0.1:(\d+)\/user\/reset\/1\//', $output, $match)) {
|
||||
$port = $match[1];
|
||||
return TRUE;
|
||||
}
|
||||
});
|
||||
$this->assertEquals('', $server_process->getErrorOutput());
|
||||
$this->assertStringContainsString("127.0.0.1:$port/user/reset/1/", $server_process->getOutput());
|
||||
$this->assertNotFalse($port, "Web server running on port $port");
|
||||
|
||||
// Give the server a couple of seconds to be ready.
|
||||
sleep(2);
|
||||
|
||||
// Generate a cookie so we can make a request against the installed site.
|
||||
define('DRUPAL_TEST_IN_CHILD_SITE', FALSE);
|
||||
chmod($this->testDb->getTestSitePath(), 0755);
|
||||
$cookieJar = CookieJar::fromArray([
|
||||
'SIMPLETEST_USER_AGENT' => drupal_generate_test_ua($this->testDb->getDatabasePrefix()),
|
||||
], '127.0.0.1');
|
||||
|
||||
$response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]);
|
||||
$content = (string) $response->getBody();
|
||||
$this->assertStringContainsString('Test site ' . $this->testDb->getDatabasePrefix(), $content);
|
||||
|
||||
// Try to re-install over the top of an existing site.
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'install',
|
||||
'testing',
|
||||
"--site-name='Test another site {$this->testDb->getDatabasePrefix()}'",
|
||||
];
|
||||
$install_process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
|
||||
$install_process->setTimeout(500);
|
||||
$result = $install_process->run();
|
||||
$this->assertStringContainsString('Drupal is already installed.', $install_process->getOutput());
|
||||
$this->assertSame(0, $result);
|
||||
|
||||
// Ensure the site name has not changed.
|
||||
$response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]);
|
||||
$content = (string) $response->getBody();
|
||||
$this->assertStringContainsString('Test site ' . $this->testDb->getDatabasePrefix(), $content);
|
||||
|
||||
// Stop the web server.
|
||||
$server_process->stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the install command with an invalid profile.
|
||||
*/
|
||||
public function testQuickStartCommandProfileValidation(): void {
|
||||
// Install a site using the standard profile to ensure the one time login
|
||||
// link generation works.
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'quick-start',
|
||||
'umami',
|
||||
"--site-name='Test site {$this->testDb->getDatabasePrefix()}' --suppress-login",
|
||||
];
|
||||
$process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
|
||||
$process->run();
|
||||
$this->assertMatchesRegularExpression("/'umami' is not a valid install profile or recipe\. Did you mean \W*'demo_umami'?/", $process->getErrorOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the server command when there is no installation.
|
||||
*/
|
||||
public function testServerWithNoInstall(): void {
|
||||
$server_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'server',
|
||||
'--suppress-login',
|
||||
];
|
||||
$server_process = new Process($server_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
|
||||
$server_process->run();
|
||||
$this->assertStringContainsString('No installation found. Use the \'install\' command.', $server_process->getErrorOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all files and directories in the specified path recursively.
|
||||
*
|
||||
* Note this method has no dependencies on Drupal core to ensure that the
|
||||
* test site can be torn down even if something in the test site is broken.
|
||||
*
|
||||
* @param string $path
|
||||
* A string containing either a URI or a file or directory path.
|
||||
* @param callable $callback
|
||||
* (optional) Callback function to run on each file prior to deleting it and
|
||||
* on each directory prior to traversing it. For example, can be used to
|
||||
* modify permissions.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE for success or if path does not exist, FALSE in the event of an
|
||||
* error.
|
||||
*
|
||||
* @see \Drupal\Core\File\FileSystemInterface::deleteRecursive()
|
||||
*/
|
||||
protected function fileUnmanagedDeleteRecursive($path, $callback = NULL): bool {
|
||||
if (isset($callback)) {
|
||||
call_user_func($callback, $path);
|
||||
}
|
||||
if (is_dir($path)) {
|
||||
$dir = dir($path);
|
||||
while (($entry = $dir->read()) !== FALSE) {
|
||||
if ($entry == '.' || $entry == '..') {
|
||||
continue;
|
||||
}
|
||||
$entry_path = $path . '/' . $entry;
|
||||
$this->fileUnmanagedDeleteRecursive($entry_path, $callback);
|
||||
}
|
||||
$dir->close();
|
||||
|
||||
return rmdir($path);
|
||||
}
|
||||
return unlink($path);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\QuickStart;
|
||||
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
/**
|
||||
* Helper methods for using the quickstart feature of Drupal.
|
||||
*/
|
||||
abstract class QuickStartTestBase extends BuildTestBase {
|
||||
|
||||
/**
|
||||
* User name of the admin account generated during install.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $adminUsername;
|
||||
|
||||
/**
|
||||
* Password of the admin account generated during install.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $adminPassword;
|
||||
|
||||
/**
|
||||
* Install a Drupal site using the quick start feature.
|
||||
*
|
||||
* @param string $profile
|
||||
* Drupal profile to install.
|
||||
* @param string $working_dir
|
||||
* (optional) A working directory relative to the workspace, within which to
|
||||
* execute the command. Defaults to the workspace directory.
|
||||
*/
|
||||
public function installQuickStart($profile, $working_dir = NULL) {
|
||||
$php_finder = new PhpExecutableFinder();
|
||||
$install_process = $this->executeCommand($php_finder->find() . ' ./core/scripts/drupal install ' . $profile, $working_dir);
|
||||
$this->assertCommandOutputContains('Username:');
|
||||
preg_match('/Username: (.+)\vPassword: (.+)/', $install_process->getOutput(), $matches);
|
||||
$this->assertNotEmpty($this->adminUsername = $matches[1]);
|
||||
$this->assertNotEmpty($this->adminPassword = $matches[2]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that uses Drupal's user/login form to log in.
|
||||
*
|
||||
* @param string $username
|
||||
* Username.
|
||||
* @param string $password
|
||||
* Password.
|
||||
* @param string $working_dir
|
||||
* (optional) A working directory within which to login. Defaults to the
|
||||
* workspace directory.
|
||||
*/
|
||||
public function formLogin($username, $password, $working_dir = NULL) {
|
||||
$this->visit('/user/login', $working_dir);
|
||||
$assert = $this->getMink()->assertSession();
|
||||
$assert->statusCodeEquals(200);
|
||||
$assert->fieldExists('edit-name')->setValue($username);
|
||||
$assert->fieldExists('edit-pass')->setValue($password);
|
||||
$session = $this->getMink()->getSession();
|
||||
$session->getPage()->findButton('Log in')->submit();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\QuickStart;
|
||||
|
||||
use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks;
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use Drupal\Core\Test\TestDatabase;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Cookie\CookieJar;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use PHPUnit\Framework\Attributes\PreserveGlobalState;
|
||||
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
|
||||
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Tests the quick-start command with recipes.
|
||||
*
|
||||
* These tests are run in a separate process because they load Drupal code via
|
||||
* an include.
|
||||
*/
|
||||
#[Group('Command')]
|
||||
#[Group('Recipe')]
|
||||
#[PreserveGlobalState(FALSE)]
|
||||
#[RequiresPhpExtension('pdo_sqlite')]
|
||||
#[RunTestsInSeparateProcesses]
|
||||
class RecipeQuickStartTest extends BuildTestBase {
|
||||
|
||||
/**
|
||||
* The PHP executable path.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $php;
|
||||
|
||||
/**
|
||||
* A test database object.
|
||||
*
|
||||
* @var \Drupal\Core\Test\TestDatabase
|
||||
*/
|
||||
protected TestDatabase $testDb;
|
||||
|
||||
/**
|
||||
* The Drupal root directory.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $root;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$php_executable_finder = new PhpExecutableFinder();
|
||||
$this->php = (string) $php_executable_finder->find();
|
||||
$this->root = dirname(substr(__DIR__, 0, -strlen(__NAMESPACE__)), 2);
|
||||
if (!is_writable("{$this->root}/sites/simpletest")) {
|
||||
$this->markTestSkipped('This test requires a writable sites/simpletest directory');
|
||||
}
|
||||
// Get a lock and a valid site path.
|
||||
$this->testDb = new TestDatabase();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function tearDown(): void {
|
||||
if ($this->testDb) {
|
||||
$test_site_directory = $this->root . DIRECTORY_SEPARATOR . $this->testDb->getTestSitePath();
|
||||
if (file_exists($test_site_directory)) {
|
||||
// @todo use the tear down command from
|
||||
// https://www.drupal.org/project/drupal/issues/2926633
|
||||
// Delete test site directory.
|
||||
$this->fileUnmanagedDeleteRecursive($test_site_directory, BrowserTestBase::filePreDeleteCallback(...));
|
||||
}
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the quick-start command with a recipe.
|
||||
*/
|
||||
public function testQuickStartRecipeCommand(): void {
|
||||
$sqlite = (string) (new \PDO('sqlite::memory:'))->query('select sqlite_version()')->fetch()[0];
|
||||
if (version_compare($sqlite, Tasks::SQLITE_MINIMUM_VERSION) < 0) {
|
||||
$this->markTestSkipped();
|
||||
}
|
||||
|
||||
// Install a site using the standard recipe to ensure the one time login
|
||||
// link generation works.
|
||||
|
||||
$script = $this->root . '/core/scripts/drupal';
|
||||
$install_command = [
|
||||
$this->php,
|
||||
$script,
|
||||
'quick-start',
|
||||
'core/recipes/standard',
|
||||
"--site-name='Test site {$this->testDb->getDatabasePrefix()}'",
|
||||
'--suppress-login',
|
||||
];
|
||||
$this->assertFileExists($script, "Install script is found in $script");
|
||||
|
||||
$process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
|
||||
$process->setTimeout(500);
|
||||
$process->start();
|
||||
$guzzle = new Client();
|
||||
$port = FALSE;
|
||||
$process->waitUntil(function ($type, $output) use (&$port) {
|
||||
if (preg_match('/127.0.0.1:(\d+)/', $output, $match)) {
|
||||
$port = $match[1];
|
||||
return TRUE;
|
||||
}
|
||||
});
|
||||
// The progress bar uses STDERR to write messages.
|
||||
$this->assertStringContainsString('Congratulations, you installed Drupal!', $process->getErrorOutput());
|
||||
// Ensure the command does not trigger any PHP deprecations.
|
||||
$this->assertStringNotContainsStringIgnoringCase('deprecated', $process->getErrorOutput());
|
||||
$this->assertNotFalse($port, "Web server running on port $port");
|
||||
|
||||
// Give the server a couple of seconds to be ready.
|
||||
sleep(2);
|
||||
$this->assertStringContainsString("127.0.0.1:$port/user/reset/1/", $process->getOutput());
|
||||
|
||||
// Generate a cookie so we can make a request against the installed site.
|
||||
define('DRUPAL_TEST_IN_CHILD_SITE', FALSE);
|
||||
chmod($this->root . '/' . $this->testDb->getTestSitePath(), 0755);
|
||||
$cookieJar = CookieJar::fromArray([
|
||||
'SIMPLETEST_USER_AGENT' => drupal_generate_test_ua($this->testDb->getDatabasePrefix()),
|
||||
], '127.0.0.1');
|
||||
|
||||
$response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]);
|
||||
$content = (string) $response->getBody();
|
||||
$this->assertStringContainsString('Test site ' . $this->testDb->getDatabasePrefix(), $content);
|
||||
// Test content from Standard front page.
|
||||
$this->assertStringContainsString('Congratulations and welcome to the Drupal community.', $content);
|
||||
|
||||
// Stop the web server.
|
||||
$process->stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all files and directories in the specified path recursively.
|
||||
*
|
||||
* Note this method has no dependencies on Drupal core to ensure that the
|
||||
* test site can be torn down even if something in the test site is broken.
|
||||
*
|
||||
* @param string $path
|
||||
* A string containing either a URI or a file or directory path.
|
||||
* @param callable $callback
|
||||
* (optional) Callback function to run on each file prior to deleting it and
|
||||
* on each directory prior to traversing it. For example, can be used to
|
||||
* modify permissions.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE for success or if path does not exist, FALSE in the event of an
|
||||
* error.
|
||||
*
|
||||
* @see \Drupal\Core\File\FileSystemInterface::deleteRecursive()
|
||||
*/
|
||||
protected function fileUnmanagedDeleteRecursive($path, $callback = NULL): bool {
|
||||
if (isset($callback)) {
|
||||
call_user_func($callback, $path);
|
||||
}
|
||||
if (is_dir($path)) {
|
||||
$dir = dir($path);
|
||||
assert($dir instanceof \Directory);
|
||||
while (($entry = $dir->read()) !== FALSE) {
|
||||
if ($entry == '.' || $entry == '..') {
|
||||
continue;
|
||||
}
|
||||
$entry_path = $path . '/' . $entry;
|
||||
$this->fileUnmanagedDeleteRecursive($entry_path, $callback);
|
||||
}
|
||||
$dir->close();
|
||||
|
||||
return rmdir($path);
|
||||
}
|
||||
return unlink($path);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\TestSiteApplication;
|
||||
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
/**
|
||||
* @group Build
|
||||
* @group TestSiteApplication
|
||||
*/
|
||||
class InstallTest extends BuildTestBase {
|
||||
|
||||
public function testInstall(): void {
|
||||
$sqlite = (new \PDO('sqlite::memory:'))->query('select sqlite_version()')->fetch()[0];
|
||||
if (version_compare($sqlite, Tasks::SQLITE_MINIMUM_VERSION) < 0) {
|
||||
$this->markTestSkipped();
|
||||
}
|
||||
$this->copyCodebase();
|
||||
$fs = new Filesystem();
|
||||
$fs->chmod($this->getWorkspaceDirectory() . '/sites/default', 0700, 0000);
|
||||
|
||||
// Composer tells you stuff in error output.
|
||||
$this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer install --no-interaction');
|
||||
$this->assertErrorOutputContains('Generating autoload files');
|
||||
|
||||
// We have to stand up the server first so we can know the port number to
|
||||
// pass along to the install command.
|
||||
$this->standUpServer();
|
||||
|
||||
$php_finder = new PhpExecutableFinder();
|
||||
$install_command = [
|
||||
$php_finder->find(),
|
||||
'./core/scripts/test-site.php',
|
||||
'install',
|
||||
'--base-url=http://localhost:' . $this->getPortNumber(),
|
||||
'--db-url=sqlite://localhost/foo.sqlite',
|
||||
'--install-profile=minimal',
|
||||
'--json',
|
||||
];
|
||||
$this->assertNotEmpty($output_json = $this->executeCommand(implode(' ', $install_command))->getOutput());
|
||||
$this->assertCommandSuccessful();
|
||||
$connection_details = json_decode($output_json, TRUE);
|
||||
foreach (['db_prefix', 'user_agent', 'site_path'] as $key) {
|
||||
$this->assertArrayHasKey($key, $connection_details);
|
||||
}
|
||||
|
||||
// Visit paths with expectations.
|
||||
$this->visit();
|
||||
$this->assertDrupalVisit();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user