diff --git a/automatic_updates_extensions/automatic_updates_extensions.info.yml b/automatic_updates_extensions/automatic_updates_extensions.info.yml index 921c13feeb4acc770e3a6bd81f413e6bb6b6f692..9f27bcc0f3cadee71511a07b4302777eef618406 100644 --- a/automatic_updates_extensions/automatic_updates_extensions.info.yml +++ b/automatic_updates_extensions/automatic_updates_extensions.info.yml @@ -4,4 +4,3 @@ description: 'Allows updates to themes and modules' core_version_requirement: ^9.2 dependencies: - drupal:automatic_updates -hidden: true diff --git a/automatic_updates_extensions/src/ExtensionUpdater.php b/automatic_updates_extensions/src/ExtensionUpdater.php index dbb577cf361bc946d687b960c6db4b7a2182e3b4..e88a5693255b06daaedbcce9b53771b8539734bd 100644 --- a/automatic_updates_extensions/src/ExtensionUpdater.php +++ b/automatic_updates_extensions/src/ExtensionUpdater.php @@ -2,10 +2,94 @@ namespace Drupal\automatic_updates_extensions; +use Drupal\automatic_updates\Exception\UpdateException; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\Exception\StageValidationException; use Drupal\package_manager\Stage; /** * Defines a service to perform updates for modules and themes. */ class ExtensionUpdater extends Stage { + + /** + * Begins the update. + * + * @param string[] $project_versions + * The versions of the packages to update to, keyed by package name. + * + * @return string + * The unique ID of the stage. + * + * @throws \InvalidArgumentException + * Thrown if no project version for Drupal core is provided. + */ + public function begin(array $project_versions): string { + $composer = $this->getActiveComposer(); + $package_versions = [ + 'production' => [], + 'dev' => [], + ]; + + $require_dev = $composer->getComposer() + ->getPackage() + ->getDevRequires(); + foreach ($project_versions as $project_name => $version) { + $package = "drupal/$project_name"; + $group = array_key_exists($package, $require_dev) ? 'dev' : 'production'; + $package_versions[$group][$package] = $version; + } + + // Ensure that package versions are available to pre-create event + // subscribers. We can't use ::setMetadata() here because it requires the + // stage to be claimed, but that only happens during ::create(). + $this->tempStore->set(static::TEMPSTORE_METADATA_KEY, [ + 'packages' => $package_versions, + ]); + return parent::create(); + } + + /** + * Returns the package versions that will be required during the update. + * + * @return string[][] + * An array with two sub-arrays: 'production' and 'dev'. Each is a set of + * package versions, where the keys are package names and the values are + * version constraints understood by Composer. + */ + public function getPackageVersions(): array { + return $this->getMetadata('packages'); + } + + /** + * Stages the update. + */ + public function stage(): void { + $this->checkOwnership(); + + // Convert an associative array of package versions, keyed by name, to + // command-line arguments in the form `vendor/name:version`. + $map = function (array $versions): array { + $requirements = []; + foreach ($versions as $package => $version) { + $requirements[] = "$package:$version"; + } + return $requirements; + }; + $versions = array_map($map, $this->getPackageVersions()); + $this->require($versions['production'], $versions['dev']); + } + + /** + * {@inheritdoc} + */ + protected function dispatch(StageEvent $event, callable $on_error = NULL): void { + try { + parent::dispatch($event, $on_error); + } + catch (StageValidationException $e) { + throw new UpdateException($e->getResults(), $e->getMessage() ?: "Unable to complete the update because of errors.", $e->getCode(), $e); + } + } + } diff --git a/automatic_updates_extensions/tests/fixtures/fake-site/composer.json b/automatic_updates_extensions/tests/fixtures/fake-site/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..57b1c92348806f8c5743d53559b6ef52f37188ed --- /dev/null +++ b/automatic_updates_extensions/tests/fixtures/fake-site/composer.json @@ -0,0 +1,9 @@ +{ + "require": { + "drupal/core-recommended": "^9", + "drupal/my_module": "^9" + }, + "require-dev": { + "drupal/my_dev_module": "^9" + } +} diff --git a/automatic_updates_extensions/tests/fixtures/fake-site/composer.lock b/automatic_updates_extensions/tests/fixtures/fake-site/composer.lock new file mode 100644 index 0000000000000000000000000000000000000000..1baca28450cf75958ef17a9fc40cd286e5c17db1 --- /dev/null +++ b/automatic_updates_extensions/tests/fixtures/fake-site/composer.lock @@ -0,0 +1,21 @@ +{ + "packages": [ + { + "name": "drupal/core-recommended", + "version": "9.8.0", + "require": { + "drupal/core": "9.8.0" + } + }, + { + "name": "drupal/my_module", + "version": "9.8.0" + } + ], + "packages-dev": [ + { + "name": "drupal/my_dev_module", + "version": "9.8.1" + } + ] +} diff --git a/automatic_updates_extensions/tests/fixtures/fake-site/vendor/composer/installed.json b/automatic_updates_extensions/tests/fixtures/fake-site/vendor/composer/installed.json new file mode 100644 index 0000000000000000000000000000000000000000..dffe8ff002d4a3cb434d6548a41e4e5b829adad2 --- /dev/null +++ b/automatic_updates_extensions/tests/fixtures/fake-site/vendor/composer/installed.json @@ -0,0 +1,27 @@ +{ + "packages": [ + { + "name": "drupal/core-recommended", + "version": "9.8.0", + "require": { + "drupal/core": "9.8.0" + } + }, + { + "name": "drupal/core", + "version": "9.8.0" + }, + { + "name": "drupal/my_module", + "version": "9.8.0" + }, + { + "name": "drupal/my_dev_module", + "version": "9.8.1" + } + ], + "dev": true, + "dev-package-names": [ + "drupal/my_dev_module" + ] +} diff --git a/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.info.yml b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..81a15e3ea75a4d46e75392c73d1ffe786b46b377 --- /dev/null +++ b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.info.yml @@ -0,0 +1,6 @@ +name: 'Automatic Updates Extensions Test API' +description: 'Provides API endpoints for doing stage operations in functional tests.' +type: module +package: Testing +dependencies: + - automatic_updates:automatic_updates_extensions diff --git a/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.routing.yml b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.routing.yml new file mode 100644 index 0000000000000000000000000000000000000000..c74bb67edc0dcb48ec5c5a7d628669e08a4c95ca --- /dev/null +++ b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.routing.yml @@ -0,0 +1,6 @@ +automatic_updates_extensions_test_api: + path: '/automatic-updates-extensions-test-api' + defaults: + _controller: 'Drupal\automatic_updates_extensions_test_api\ApiController::run' + requirements: + _access: 'TRUE' diff --git a/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/src/ApiController.php b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/src/ApiController.php new file mode 100644 index 0000000000000000000000000000000000000000..fc1c2207c8e558d1a299483a6b01eb6ff930093c --- /dev/null +++ b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/src/ApiController.php @@ -0,0 +1,87 @@ +<?php + +namespace Drupal\automatic_updates_extensions_test_api; + +use Drupal\automatic_updates_extensions\ExtensionUpdater; +use Drupal\Core\Controller\ControllerBase; +use Drupal\package_manager\PathLocator; +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 extension updater. + * + * @var \Drupal\automatic_updates_extensions\ExtensionUpdater + */ + private $extensionUpdater; + + /** + * The path locator service. + * + * @var \Drupal\package_manager\PathLocator + */ + private $pathLocator; + + /** + * Constructs an ApiController object. + * + * @param \Drupal\automatic_updates_extensions\ExtensionUpdater $extensionUpdater + * The updater. + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. + */ + public function __construct(ExtensionUpdater $extensionUpdater, PathLocator $path_locator) { + $this->extensionUpdater = $extensionUpdater; + $this->pathLocator = $path_locator; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('automatic_updates_extensions.updater'), + $container->get('package_manager.path_locator') + ); + } + + /** + * Runs a complete stage life cycle. + * + * Creates a staging area, requires packages into it, applies changes to the + * active directory, and destroys the stage. + * + * @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 project root, 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 + * files listed in the 'files_to_return' request key. The array will be + * keyed by path, relative to the project root. + */ + public function run(Request $request): JsonResponse { + $this->extensionUpdater->begin($request->get('projects', [])); + $this->extensionUpdater->stage(); + $this->extensionUpdater->apply(); + $this->extensionUpdater->destroy(); + + $dir = $this->pathLocator->getProjectRoot(); + $file_contents = []; + foreach ($request->get('files_to_return', []) as $path) { + $file_contents[$path] = file_get_contents($dir . '/' . $path); + } + return new JsonResponse($file_contents); + } + +} diff --git a/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php b/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f84c55f974b18818b01faca7831228a934cc9d9d --- /dev/null +++ b/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php @@ -0,0 +1,54 @@ +<?php + +namespace Drupal\Tests\automatic_updates_extensions\Build; + +use Drupal\Tests\package_manager\Build\TemplateProjectTestBase; + +/** + * Tests updating modules in a staging area. + * + * @group automatic_updates_extensions + */ +class ModuleUpdateTest extends TemplateProjectTestBase { + + /** + * Tests updating a module in a staging area. + */ + public function testApi(): void { + $this->createTestProject('RecommendedProject'); + + $this->addRepository('alpha', __DIR__ . '/../../../../package_manager/tests/fixtures/alpha/1.0.0'); + $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require drupal/alpha --update-with-all-dependencies', 'project'); + + $this->installQuickStart('minimal'); + $this->formLogin($this->adminUsername, $this->adminPassword); + $this->installModules(['automatic_updates_extensions_test_api']); + + // Change both modules' upstream version. + $this->addRepository('alpha', __DIR__ . '/../../../../package_manager/tests/fixtures/alpha/1.1.0'); + + // Use the API endpoint to create a stage and update the 'alpha' module to + // 1.1.0. We ask the API to return the contents of the module's + // composer.json file, so we can assert that they were updated to the + // version we expect. + // @see \Drupal\automatic_updates_extensions_test_api\ApiController::run() + $query = http_build_query([ + 'projects' => [ + 'alpha' => '1.1.0', + ], + 'files_to_return' => [ + 'web/modules/contrib/alpha/composer.json', + ], + ]); + $this->visit("/automatic-updates-extensions-test-api?$query"); + $mink = $this->getMink(); + $mink->assertSession()->statusCodeEquals(200); + + $file_contents = $mink->getSession()->getPage()->getContent(); + $file_contents = json_decode($file_contents, TRUE); + + $module_composer_json = json_decode($file_contents['web/modules/contrib/alpha/composer.json']); + $this->assertSame('1.1.0', $module_composer_json->version); + } + +} diff --git a/automatic_updates_extensions/tests/src/Kernel/ExtensionUpdaterTest.php b/automatic_updates_extensions/tests/src/Kernel/ExtensionUpdaterTest.php new file mode 100644 index 0000000000000000000000000000000000000000..1305b87a31a2cb74e773a3970421227cf1f1d7c7 --- /dev/null +++ b/automatic_updates_extensions/tests/src/Kernel/ExtensionUpdaterTest.php @@ -0,0 +1,112 @@ +<?php + +namespace Drupal\Tests\automatic_updates_extensions\Kernel; + +use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase; +use Drupal\Tests\user\Traits\UserCreationTrait; + +/** + * @coversDefaultClass \Drupal\automatic_updates_extensions\ExtensionUpdater + * + * @group automatic_updates_extensions + */ +class ExtensionUpdaterTest extends AutomaticUpdatesKernelTestBase { + + use UserCreationTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'automatic_updates', + 'automatic_updates_test', + 'automatic_updates_extensions', + 'user', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installEntitySchema('user'); + } + + /** + * Tests that correct versions are staged after calling ::begin(). + */ + public function testCorrectVersionsStaged(): void { + + // Create a user who will own the stage even after the container is rebuilt. + $user = $this->createUser([], NULL, TRUE, ['uid' => 2]); + $this->setCurrentUser($user); + + $fixture_dir = __DIR__ . '/../../fixtures/fake-site'; + $locator = $this->mockPathLocator($fixture_dir, $fixture_dir); + + $id = $this->container->get('automatic_updates_extensions.updater')->begin([ + 'my_module' => '9.8.1', + 'my_dev_module' => '9.8.2', + ]); + // Rebuild the container to ensure the package versions are persisted. + /** @var \Drupal\Core\DrupalKernel $kernel */ + $kernel = $this->container->get('kernel'); + $kernel->rebuildContainer(); + $this->container = $kernel->getContainer(); + // Keep using the mocked path locator and current user. + $this->container->set('package_manager.path_locator', $locator); + $this->setCurrentUser($user); + + $extension_updater = $this->container->get('automatic_updates_extensions.updater'); + + // Ensure that the target package versions are what we expect. + $expected_versions = [ + 'production' => [ + 'drupal/my_module' => '9.8.1', + ], + 'dev' => [ + 'drupal/my_dev_module' => '9.8.2', + ], + ]; + $this->assertSame($expected_versions, $extension_updater->claim($id)->getPackageVersions()); + + // When we call ExtensionUpdater::stage(), the stored project versions + // should be read from state and passed to Composer Stager's Stager service, + // in the form of a Composer command. This is done using + // package_manager_bypass's 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. This is tested + // functionally in Package Manager. + // @see \Drupal\Tests\package_manager\Build\StagedUpdateTest + $expected_arguments = [ + [ + 'require', + '--no-update', + 'drupal/my_module:9.8.1', + ], + [ + 'require', + '--dev', + '--no-update', + 'drupal/my_dev_module:9.8.2', + ], + [ + 'update', + '--with-all-dependencies', + 'drupal/my_module:9.8.1', + 'drupal/my_dev_module:9.8.2', + ], + ]; + $extension_updater->stage(); + + $actual_arguments = $this->container->get('package_manager.stager') + ->getInvocationArguments(); + + $this->assertSame(count($expected_arguments), count($actual_arguments)); + foreach ($actual_arguments as $i => [$arguments]) { + $this->assertSame($expected_arguments[$i], $arguments); + } + } + +}