diff --git a/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php index 2b9feb8b2d91f0509168a620912ceca868fce367..3009388294ee9fc4b2f39a54369a4f95c4005227 100644 --- a/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php +++ b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php @@ -3,11 +3,12 @@ namespace Drupal\Tests\auto_updates\Build; use Drupal\Component\Utility\Html; +use Drupal\Tests\package_manager\Build\TemplateProjectTestBase; /** * Base class for tests that perform in-place updates. */ -abstract class UpdateTestBase extends TemplateProjectSiteTestBase { +abstract class UpdateTestBase extends TemplateProjectTestBase { /** * A secondary server instance, to serve XML metadata about available updates. @@ -32,16 +33,8 @@ protected function tearDown(): void { protected function createTestProject(string $template): void { parent::createTestProject($template); - // Install Drupal. Always allow test modules to be installed in the UI and, - // for easier debugging, always display errors in their dubious glory. + // Install Drupal, Automatic Updates, and other modules needed for testing. $this->installQuickStart('minimal'); - $php = <<<END -\$settings['extension_discovery_scan_tests'] = TRUE; -\$config['system.logging']['error_level'] = 'verbose'; -END; - $this->writeSettings($php); - - // Install Automatic Updates and other modules needed for testing. $this->formLogin($this->adminUsername, $this->adminPassword); $this->installModules([ 'auto_updates', @@ -50,27 +43,6 @@ protected function createTestProject(string $template): void { ]); } - /** - * Appends PHP code to the test site's settings.php. - * - * @param string $php - * The PHP code to append to the test site's settings.php. - */ - protected function writeSettings(string $php): void { - // Ensure settings are writable, since this is the only way we can set - // configuration values that aren't accessible in the UI. - $file = $this->getWebRoot() . '/sites/default/settings.php'; - $this->assertFileExists($file); - chmod(dirname($file), 0744); - chmod($file, 0744); - $this->assertFileIsWritable($file); - - $stream = fopen($file, 'a'); - $this->assertIsResource($stream); - $this->assertIsInt(fwrite($stream, $php)); - $this->assertTrue(fclose($stream)); - } - /** * Prepares the test site to serve an XML feed of available release metadata. * @@ -99,33 +71,6 @@ protected function setReleaseMetadata(array $xml_map): void { $this->writeSettings($code); } - /** - * Installs modules in the UI. - * - * Assumes that a user with the appropriate permissions is logged in. - * - * @param string[] $modules - * The machine names of the modules to install. - */ - protected function installModules(array $modules): void { - $mink = $this->getMink(); - $page = $mink->getSession()->getPage(); - $assert_session = $mink->assertSession(); - - $this->visit('/admin/modules'); - foreach ($modules as $module) { - $page->checkField("modules[$module][enable]"); - } - $page->pressButton('Install'); - - $form_id = $assert_session->elementExists('css', 'input[type="hidden"][name="form_id"]') - ->getValue(); - if (preg_match('/^system_modules_(experimental_|non_stable_)?confirm_form$/', $form_id)) { - $page->pressButton('Continue'); - $assert_session->statusCodeEquals(200); - } - } - /** * Checks for available updates. * diff --git a/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php b/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php index 2d71da3d93c0f8b96c76cbeb98b3f365bfda815c..20890c80c59f9d3a4bc04e641f88a12e0f8af68b 100644 --- a/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php +++ b/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php @@ -86,7 +86,9 @@ public function testCorrectVersionsStaged() { // invocation recorder, rather than a regular mock, in order to test that // the invocation recorder itself works. // The production requirements are changed first, followed by the dev - // requirements. Then the installed packages are updated. + // requirements. Then the installed packages are updated. This is tested + // functionally in Package Manager. + // @see \Drupal\Tests\package_manager\Build\StagedUpdateTest $expected_arguments = [ [ 'require', diff --git a/core/modules/package_manager/src/ProcessFactory.php b/core/modules/package_manager/src/ProcessFactory.php index 4eb5195cbf1659893a3dc9d2e7bccd0dd47e7640..78ef902b2d909cb6e7dfb460ed3fe9612d5fa0e4 100644 --- a/core/modules/package_manager/src/ProcessFactory.php +++ b/core/modules/package_manager/src/ProcessFactory.php @@ -87,7 +87,7 @@ public function create(array $command): Process { */ private function getComposerHomePath(): string { $home_path = $this->fileSystem->getTempDirectory(); - $home_path .= '/auto_updates_composer_home-'; + $home_path .= '/package_manager_composer_home-'; $home_path .= $this->configFactory->get('system.site')->get('uuid'); $this->fileSystem->prepareDirectory($home_path, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS); diff --git a/core/modules/package_manager/src/Validator/ComposerExecutableValidator.php b/core/modules/package_manager/src/Validator/ComposerExecutableValidator.php index db516e5089a0b643015e0fbd4adb68a6d8cd8258..f2e4e46157be32f80aa96d7cfe2833d91d014176 100644 --- a/core/modules/package_manager/src/Validator/ComposerExecutableValidator.php +++ b/core/modules/package_manager/src/Validator/ComposerExecutableValidator.php @@ -2,9 +2,9 @@ namespace Drupal\package_manager\Validator; +use Composer\Semver\Comparator; use Drupal\package_manager\Event\PreCreateEvent; use Drupal\package_manager\Event\PreOperationStageEvent; -use Drupal\Core\Extension\ExtensionVersion; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\StringTranslation\TranslationInterface; use PhpTuf\ComposerStager\Domain\Process\OutputCallbackInterface; @@ -18,6 +18,13 @@ class ComposerExecutableValidator implements PreOperationStageValidatorInterface use StringTranslationTrait; + /** + * The minimum required version of Composer. + * + * @var string + */ + public const MINIMUM_COMPOSER_VERSION = '2.2.4'; + /** * The Composer runner. * @@ -60,13 +67,11 @@ public function validateStagePreOperation(PreOperationStageEvent $event): void { } if ($this->version) { - $major_version = ExtensionVersion::createFromVersionString($this->version) - ->getMajorVersion(); - - if ($major_version < 2) { + if (Comparator::lessThan($this->version, static::MINIMUM_COMPOSER_VERSION)) { $event->addError([ - $this->t('Composer 2 or later is required, but version @version was detected.', [ - '@version' => $this->version, + $this->t('Composer @minimum_version or later is required, but version @detected_version was detected.', [ + '@minimum_version' => static::MINIMUM_COMPOSER_VERSION, + '@detected_version' => $this->version, ]), ]); } diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.info.yml b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..6a64061f1ced4f499e98e8faf152b722fa1e02f6 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.info.yml @@ -0,0 +1,6 @@ +name: 'Package Manager Test API' +description: 'Provides API endpoints for doing stage operations in functional tests.' +type: module +package: Testing +dependencies: + - auto_updates:package_manager diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml new file mode 100644 index 0000000000000000000000000000000000000000..4b5cf58ca1f5f30d96cb984bb097e2bccdf6fae0 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml @@ -0,0 +1,6 @@ +package_manager_test_api.require: + path: '/package-manager-test-api/require' + defaults: + _controller: 'Drupal\package_manager_test_api\ApiController::require' + requirements: + _access: 'TRUE' diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php new file mode 100644 index 0000000000000000000000000000000000000000..a9f116ff2bc82ac62497304e31790a7c1953a208 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php @@ -0,0 +1,81 @@ +<?php + +namespace Drupal\package_manager_test_api; + +use Drupal\Core\Controller\ControllerBase; +use Drupal\package_manager\Stage; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; + +/** + * Provides API endpoints to interact with a staging area in functional tests. + */ +class ApiController extends ControllerBase { + + /** + * The stage. + * + * @var \Drupal\package_manager\Stage + */ + private $stage; + + /** + * Constructs an ApiController object. + * + * @param \Drupal\package_manager\Stage $stage + * The stage. + */ + public function __construct(Stage $stage) { + $this->stage = $stage; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + $stage = new Stage( + $container->get('package_manager.path_locator'), + $container->get('package_manager.beginner'), + $container->get('package_manager.stager'), + $container->get('package_manager.committer'), + $container->get('file_system'), + $container->get('event_dispatcher'), + $container->get('tempstore.shared'), + ); + return new static($stage); + } + + /** + * Creates a staging area and requires packages into it. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request. The runtime and dev dependencies are expected to be in + * either the query string or request body, under the 'runtime' and 'dev' + * keys, respectively. There may also be a 'files_to_return' key, which + * contains an array of file paths, relative to the stage directory, whose + * contents should be returned in the response. + * + * @return \Symfony\Component\HttpFoundation\JsonResponse + * A JSON response containing an associative array of the contents of the + * staged files listed in the 'files_to_return' request key. The array will + * be keyed by path, relative to the stage directory. + */ + public function require(Request $request): JsonResponse { + $this->stage->create(); + $this->stage->require( + $request->get('runtime', []), + $request->get('dev', []) + ); + + $stage_dir = $this->stage->getStageDirectory(); + $staged_file_contents = []; + foreach ($request->get('files_to_return', []) as $path) { + $staged_file_contents[$path] = file_get_contents($stage_dir . '/' . $path); + } + $this->stage->destroy(); + + return new JsonResponse($staged_file_contents); + } + +} diff --git a/core/modules/package_manager/tests/src/Build/StagedUpdateTest.php b/core/modules/package_manager/tests/src/Build/StagedUpdateTest.php new file mode 100644 index 0000000000000000000000000000000000000000..8dbf40363380cadb19729d7d43dc343ae59bdf3e --- /dev/null +++ b/core/modules/package_manager/tests/src/Build/StagedUpdateTest.php @@ -0,0 +1,87 @@ +<?php + +namespace Drupal\Tests\package_manager\Build; + +/** + * Tests updating packages in a staging area. + * + * @group package_manager + */ +class StagedUpdateTest extends TemplateProjectTestBase { + + /** + * Tests that a stage only updates packages with changed constraints. + */ + public function testStagedUpdate(): void { + $this->createTestProject('RecommendedProject'); + + $this->createModule('alpha'); + $this->createModule('bravo'); + $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require drupal/alpha drupal/bravo --update-with-all-dependencies', 'project'); + + $this->installQuickStart('minimal'); + $this->formLogin($this->adminUsername, $this->adminPassword); + $this->installModules(['package_manager_test_api']); + + // Change both modules' upstream version. + $this->runComposer('composer config version 1.1.0', 'alpha'); + $this->runComposer('composer config version 1.1.0', 'bravo'); + + // Use the API endpoint to create a stage and update bravo to 1.1.0. Even + // though both modules are at version 1.1.0, only bravo should be updated. + // We ask the API to return the contents of both modules' staged + // composer.json files, so we can assert that the staged versions are what + // we expect. + // @see \Drupal\package_manager_test_api\ApiController::require() + $query = http_build_query([ + 'runtime' => [ + 'drupal/bravo:1.1.0', + ], + 'files_to_return' => [ + 'web/modules/contrib/alpha/composer.json', + 'web/modules/contrib/bravo/composer.json', + ], + ]); + $this->visit("/package-manager-test-api/require?$query"); + $mink = $this->getMink(); + $mink->assertSession()->statusCodeEquals(200); + + $staged_file_contents = $mink->getSession()->getPage()->getContent(); + $staged_file_contents = json_decode($staged_file_contents, TRUE); + + $expected_versions = [ + 'alpha' => '1.0.0', + 'bravo' => '1.1.0', + ]; + foreach ($expected_versions as $module_name => $expected_version) { + $path = "web/modules/contrib/$module_name/composer.json"; + $staged_composer_json = json_decode($staged_file_contents[$path]); + $this->assertSame($expected_version, $staged_composer_json->version); + } + } + + /** + * Creates an empty module for testing purposes. + * + * @param string $name + * The machine name of the module, which can be added to the test site as + * 'drupal/$name'. + */ + private function createModule(string $name): void { + $dir = $this->getWorkspaceDirectory() . '/' . $name; + mkdir($dir); + $this->assertDirectoryExists($dir); + $this->runComposer("composer init --name drupal/$name --type drupal-module", $name); + $this->runComposer('composer config version 1.0.0', $name); + + $repository = json_encode([ + 'type' => 'path', + 'url' => $dir, + 'options' => [ + 'symlink' => FALSE, + ], + ]); + $this->runComposer("composer config repo.$name '$repository'", 'project'); + } + +} diff --git a/core/modules/auto_updates/tests/src/Build/TemplateProjectSiteTestBase.php b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php similarity index 80% rename from core/modules/auto_updates/tests/src/Build/TemplateProjectSiteTestBase.php rename to core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php index dbf0d3d8301cfcf27365dff267666a4155627063..21df43a3b6ad0969dbdc8559f247cef1fc1082e3 100644 --- a/core/modules/auto_updates/tests/src/Build/TemplateProjectSiteTestBase.php +++ b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php @@ -1,14 +1,19 @@ <?php -namespace Drupal\Tests\auto_updates\Build; +namespace Drupal\Tests\package_manager\Build; use Drupal\BuildTests\QuickStart\QuickStartTestBase; use Drupal\Composer\Composer; /** * Base class for tests which create a test site from a core project template. + * + * The test site will be created from one of the core Composer project templates + * (drupal/recommended-project or drupal/legacy-project) and contain complete + * copies of Drupal core and all installed dependencies, completely independent + * of the currently running code base. */ -abstract class TemplateProjectSiteTestBase extends QuickStartTestBase { +abstract class TemplateProjectTestBase extends QuickStartTestBase { /** * The web root of the test site, relative to the workspace directory. @@ -30,20 +35,6 @@ public function providerTemplate(): array { ]; } - /** - * {@inheritdoc} - */ - public function copyCodebase(\Iterator $iterator = NULL, $working_dir = NULL) { - parent::copyCodebase($iterator, $working_dir); - - // In certain situations, like Drupal CI, auto_updates might be - // required into the code base by Composer. This may cause it to be added to - // the drupal/core-recommended metapackage, which can prevent the test site - // from being built correctly, among other deleterious effects. To prevent - // such shenanigans, always remove drupal/auto_updates from - // drupal/core-recommended. - $this->runComposer('composer remove --no-update drupal/auto_updates', 'composer/Metapackage/CoreRecommended'); - } /** * {@inheritdoc} @@ -77,6 +68,14 @@ protected function instantiateServer($port, $working_dir = NULL) { */ public function installQuickStart($profile, $working_dir = NULL) { parent::installQuickStart($profile, $working_dir ?: $this->webRoot); + + // Always allow test modules to be installed in the UI and, for easier + // debugging, always display errors in their dubious glory. + $php = <<<END +\$settings['extension_discovery_scan_tests'] = TRUE; +\$config['system.logging']['error_level'] = 'verbose'; +END; + $this->writeSettings($php); } /** @@ -197,6 +196,7 @@ protected function createTestProject(string $template): void { // Now that we know the project was created successfully, we can set the // web root with confidence. $this->webRoot = 'project/' . $this->runComposer('composer config extra.drupal-scaffold.locations.web-root', 'project'); + } /** @@ -292,4 +292,54 @@ protected function runComposer(string $command, string $working_dir = NULL, bool return $output; } + /** + * Appends PHP code to the test site's settings.php. + * + * @param string $php + * The PHP code to append to the test site's settings.php. + */ + protected function writeSettings(string $php): void { + // Ensure settings are writable, since this is the only way we can set + // configuration values that aren't accessible in the UI. + $file = $this->getWebRoot() . '/sites/default/settings.php'; + $this->assertFileExists($file); + chmod(dirname($file), 0744); + chmod($file, 0744); + $this->assertFileIsWritable($file); + + $stream = fopen($file, 'a'); + $this->assertIsResource($stream); + $this->assertIsInt(fwrite($stream, $php)); + $this->assertTrue(fclose($stream)); + } + + /** + * Installs modules in the UI. + * + * Assumes that a user with the appropriate permissions is logged in. + * + * @param string[] $modules + * The machine names of the modules to install. + */ + protected function installModules(array $modules): void { + $mink = $this->getMink(); + $page = $mink->getSession()->getPage(); + $assert_session = $mink->assertSession(); + + $this->visit('/admin/modules'); + foreach ($modules as $module) { + $page->checkField("modules[$module][enable]"); + } + $page->pressButton('Install'); + + // If there is a confirmation form warning about additional dependencies + // or non-stable modules, submit it. + $form_id = $assert_session->elementExists('css', 'input[type="hidden"][name="form_id"]') + ->getValue(); + if (preg_match('/^system_modules_(experimental_|non_stable_)?confirm_form$/', $form_id)) { + $page->pressButton('Continue'); + $assert_session->statusCodeEquals(200); + } + } + } diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php index 12b4fe415ca89cc14febdada697392017b38e5a0..9b8236a786fd6b309a033deb601b51e24dca154b 100644 --- a/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php +++ b/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php @@ -52,17 +52,22 @@ public function providerComposerVersionValidation(): array { // in the validation result, so we need a function to churn out those fake // results for the test method. $unsupported_version = function (string $version): ValidationResult { + $minimum_version = ComposerExecutableValidator::MINIMUM_COMPOSER_VERSION; + return ValidationResult::createError([ - "Composer 2 or later is required, but version $version was detected.", + "Composer $minimum_version or later is required, but version $version was detected.", ]); }; return [ - // A valid 2.x version of Composer should not produce any errors. [ - '2.1.6', + ComposerExecutableValidator::MINIMUM_COMPOSER_VERSION, [], ], + [ + '2.1.6', + [$unsupported_version('2.1.6')], + ], [ '1.10.22', [$unsupported_version('1.10.22')], @@ -73,11 +78,11 @@ public function providerComposerVersionValidation(): array { ], [ '2.0.0-alpha3', - [], + [$unsupported_version('2.0.0-alpha3')], ], [ '2.1.0-RC1', - [], + [$unsupported_version('2.1.0-RC1')], ], [ '1.0.0-RC',