diff --git a/tests/src/Build/CoreUpdateTest.php b/tests/src/Build/CoreUpdateTest.php index 662090dc8b14b2f68a3c1b9d21fc08cc898be75d..5a3ca412c5f1db77fb5ce9363e61954bc8bc50dc 100644 --- a/tests/src/Build/CoreUpdateTest.php +++ b/tests/src/Build/CoreUpdateTest.php @@ -2,7 +2,7 @@ namespace Drupal\Tests\automatic_updates\Build; -use Drupal\Component\Serialization\Json; +use Drupal\Composer\Composer; /** * Tests an end-to-end update of Drupal core. @@ -14,153 +14,43 @@ class CoreUpdateTest extends UpdateTestBase { /** * {@inheritdoc} */ - protected function createTestSite(string $template): void { - $dir = $this->getWorkspaceDirectory(); - - // Build the test site and alter its copy of core so that it thinks it's - // running Drupal 9.8.0, which will never actually exist in the real world. - // Then, prepare a secondary copy of the core code base, masquerading as - // Drupal 9.8.1, which will be the version of core we update to. These two - // versions are referenced in the fake release metadata in our fake release - // metadata (see fixtures/release-history/drupal.0.0.xml). - parent::createTestSite($template); - $this->setCoreVersion($this->getWebRoot() . '/core', '9.8.0'); - $this->alterPackage($dir, $this->getConfigurationForUpdate('9.8.1')); - - // Install Drupal and ensure it's using the fake release metadata to fetch - // information about available updates. - $this->installQuickStart('minimal'); - $this->setReleaseMetadata(['drupal' => '9.8.1-security']); - $this->formLogin($this->adminUsername, $this->adminPassword); - $this->installModules([ - 'automatic_updates', - 'automatic_updates_test', - 'update_test', - ]); - - // If using the drupal/recommended-project template, we don't expect there - // to be an .htaccess file at the project root. One would normally be - // generated by Composer when Package Manager or other code creates a - // ComposerUtility object in the active directory, except that Package - // Manager takes specific steps to prevent that. So, here we're just - // confirming that, in fact, Composer's .htaccess protection was disabled. - // We don't do this for the drupal/legacy-project template because its - // project root, which is also the document root, SHOULD contain a .htaccess - // generated by Drupal core. - // We do this check because this test uses PHP's built-in web server, which - // ignores .htaccess files and everything in them, so a Composer-generated - // .htaccess file won't cause this test to fail. - if ($template === 'drupal/recommended-project') { - $this->assertFileDoesNotExist($dir . '/.htaccess'); - } + public function copyCodebase(\Iterator $iterator = NULL, $working_dir = NULL) { + parent::copyCodebase($iterator, $working_dir); - // Ensure that Drupal thinks we are running 9.8.0, then refresh information - // about available updates. - $this->assertCoreVersion('9.8.0'); - $this->checkForUpdates(); - // Ensure that an update to 9.8.1 is available. - $this->visit('/admin/modules/automatic-update'); - $this->getMink()->assertSession()->pageTextContains('9.8.1'); + // Ensure that we will install Drupal 9.8.0 (a fake version that should + // never exist in real life) initially. + $this->setUpstreamCoreVersion('9.8.0'); } /** * {@inheritdoc} */ - protected function tearDown(): void { - if ($this->destroyBuild) { - $this->deleteCopiedPackages(); - } - parent::tearDown(); - } - - /** - * Modifies a Drupal core code base to set its version. - * - * @param string $dir - * The directory of the Drupal core code base. - * @param string $version - * The version number to set. - */ - private function setCoreVersion(string $dir, string $version): void { - $this->alterPackage($dir, ['version' => $version]); - - $drupal_php = "$dir/lib/Drupal.php"; - $this->assertIsWritable($drupal_php); - $code = file_get_contents($drupal_php); - $code = preg_replace("/const VERSION = '([0-9]+\.?){3}(-dev)?';/", "const VERSION = '$version';", $code); - file_put_contents($drupal_php, $code); + public function getCodebaseFinder() { + // Don't copy .git directories and such, since that just slows things down. + // We can use ::setUpstreamCoreVersion() to explicitly set the versions of + // core packages required by the test site. + return parent::getCodebaseFinder()->ignoreVCS(TRUE); } /** - * Returns composer.json changes that are needed to update core. - * - * This will clone the following packages into temporary directories: - * - drupal/core - * - drupal/core-recommended - * - drupal/core-project-message - * - drupal/core-composer-scaffold - * The cloned packages will be assigned the given version number, and the test - * site's composer.json will use the clones as path repositories. - * - * @param string $version - * The version of core we will be updating to. - * - * @return array - * The changes to merge into the test site's composer.json. + * {@inheritdoc} */ - protected function getConfigurationForUpdate(string $version): array { - $repositories = []; - - // Create a fake version of core with the given version number, and change - // its README so that we can actually be certain that we update to this - // fake version. - $dir = $this->copyPackage($this->getWebRoot() . '/core'); - $this->setCoreVersion($dir, $version); - file_put_contents("$dir/README.txt", "Placeholder for Drupal core $version."); - $repositories['drupal/core'] = $this->createPathRepository($dir); - - $drupal_root = $this->getDrupalRoot(); - - // Create a fake version of drupal/core-recommended which itself requires - // the fake version of core we just created. - $dir = $this->copyPackage("$drupal_root/composer/Metapackage/CoreRecommended"); - $this->alterPackage($dir, [ - 'require' => [ - 'drupal/core' => $version, - ], - 'version' => $version, - ]); - $repositories['drupal/core-recommended'] = $this->createPathRepository($dir); - - // Create fake target versions of core plugins and metapackages. - $packages = [ - 'drupal/core-dev' => "$drupal_root/composer/Metapackage/DevDependencies", - 'drupal/core-project-message' => "$drupal_root/composer/Plugin/ProjectMessage", - 'drupal/core-composer-scaffold' => "$drupal_root/composer/Plugin/Scaffold", - 'drupal/core-vendor-hardening' => "$drupal_root/composer/Plugin/VendorHardening", - ]; - foreach ($packages as $name => $dir) { - $dir = $this->copyPackage($dir); - $this->alterPackage($dir, ['version' => $version]); - $repositories[$name] = $this->createPathRepository($dir); - } - - return [ - 'repositories' => $repositories, - ]; - } + protected function createTestProject(string $template): void { + parent::createTestProject($template); + + // Prepare an "upstream" version of core, 9.8.1, to which we will update. + // This version, along with 9.8.0 (which was installed initially), is + // referenced in our fake release metadata (see + // fixtures/release-history/drupal.0.0.xml). + $this->setUpstreamCoreVersion('9.8.1'); + $this->setReleaseMetadata(['drupal' => '9.8.1-security']); - /** - * Data provider for end-to-end update tests. - * - * @return array[] - * Sets of arguments to pass to the test method. - */ - public function providerTemplate(): array { - return [ - ['drupal/recommended-project'], - ['drupal/legacy-project'], - ]; + // Ensure that Drupal thinks we are running 9.8.0, then refresh information + // about available updates and ensure that an update to 9.8.1 is available. + $this->assertCoreVersion('9.8.0'); + $this->checkForUpdates(); + $this->visit('/admin/modules/automatic-update'); + $this->getMink()->assertSession()->pageTextContains('9.8.1'); } /** @@ -172,14 +62,14 @@ class CoreUpdateTest extends UpdateTestBase { * @dataProvider providerTemplate */ public function testApi(string $template): void { - $this->createTestSite($template); + $this->createTestProject($template); $mink = $this->getMink(); $assert_session = $mink->assertSession(); // Ensure that the update is prevented if the web root and/or vendor // directories are not writable. - $this->assertReadOnlyFileSystemError($template, '/automatic-update-test/update/9.8.1'); + $this->assertReadOnlyFileSystemError('/automatic-update-test/update/9.8.1'); $mink->getSession()->reload(); $assert_session->pageTextContains('9.8.1'); @@ -194,7 +84,7 @@ class CoreUpdateTest extends UpdateTestBase { * @dataProvider providerTemplate */ public function testUi(string $template): void { - $this->createTestSite($template); + $this->createTestProject($template); $mink = $this->getMink(); $session = $mink->getSession(); @@ -207,7 +97,7 @@ class CoreUpdateTest extends UpdateTestBase { // Ensure that the update is prevented if the web root and/or vendor // directories are not writable. - $this->assertReadOnlyFileSystemError($template, parse_url($session->getCurrentUrl(), PHP_URL_PATH)); + $this->assertReadOnlyFileSystemError(parse_url($session->getCurrentUrl(), PHP_URL_PATH)); $session->reload(); $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.'); @@ -218,7 +108,7 @@ class CoreUpdateTest extends UpdateTestBase { $this->waitForBatchJob(); $assert_session->pageTextContains('Update complete!'); $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.'); - $this->assertUpdateSuccessful(); + $this->assertUpdateSuccessful('9.8.1'); } /** @@ -230,43 +120,35 @@ class CoreUpdateTest extends UpdateTestBase { * @dataProvider providerTemplate */ public function testCron(string $template): void { - $this->createTestSite($template); + $this->createTestProject($template); $this->visit('/admin/reports/status'); $this->getMink()->getSession()->getPage()->clickLink('Run cron'); - $this->assertUpdateSuccessful(); + $this->assertUpdateSuccessful('9.8.1'); } /** * Asserts that the update is prevented if the filesystem isn't writable. * - * @param string $template - * The project template used to build the test site. See ::createTestSite() - * for the possible values. - * @param string $url + * @param string $error_url * A URL where we can see the error message which is raised when parts of * the file system are not writable. This URL will be visited twice: once * for the web root, and once for the vendor directory. */ - private function assertReadOnlyFileSystemError(string $template, string $url): void { + private function assertReadOnlyFileSystemError(string $error_url): void { $directories = [ 'Drupal' => rtrim($this->getWebRoot(), './'), ]; // The location of the vendor directory depends on which project template - // was used to build the test site. - if ($template === 'drupal/recommended-project') { - $directories['vendor'] = $this->getWorkspaceDirectory() . '/vendor'; - } - elseif ($template === 'drupal/legacy-project') { - $directories['vendor'] = $directories['Drupal'] . '/vendor'; - } + // was used to build the test site, so just ask Composer where it is. + $directories['vendor'] = $this->runComposer('composer config --absolute vendor-dir', 'project'); $assert_session = $this->getMink()->assertSession(); foreach ($directories as $type => $path) { chmod($path, 0555); $this->assertDirectoryIsNotWritable($path); - $this->visit($url); + $this->visit($error_url); $assert_session->pageTextContains("The $type directory \"$path\" is not writable."); chmod($path, 0755); $this->assertDirectoryIsWritable($path); @@ -274,43 +156,97 @@ class CoreUpdateTest extends UpdateTestBase { } /** - * Asserts that Drupal core was successfully updated. + * Sets the version of Drupal core to which the test site will be updated. + * + * @param string $version + * The Drupal core version to set. + */ + private function setUpstreamCoreVersion(string $version): void { + $workspace_dir = $this->getWorkspaceDirectory(); + + // Loop through core's metapackages and plugins, and alter them as needed. + $packages = str_replace("$workspace_dir/", NULL, $this->getCorePackages()); + foreach ($packages as $path) { + // Assign the new upstream version. + $this->runComposer("composer config version $version", $path); + + // If this package requires Drupal core (e.g., drupal/core-recommended), + // make it require the new upstream version. + $info = $this->runComposer('composer info --self --format json', $path, TRUE); + if (isset($info['requires']['drupal/core'])) { + $this->runComposer("composer require --no-update drupal/core:$version", $path); + } + } + + // Change the \Drupal::VERSION constant and put placeholder text in the + // README so we can ensure that we really updated to the correct version. + // @see ::assertUpdateSuccessful() + Composer::setDrupalVersion($workspace_dir, $version); + file_put_contents("$workspace_dir/core/README.txt", "Placeholder for Drupal core $version."); + } + + /** + * Asserts that a specific version of Drupal core is running. + * + * Assumes that a user with permission to view the status report is logged in. + * + * @param string $expected_version + * The version of core that should be running. + */ + protected function assertCoreVersion(string $expected_version): void { + $this->visit('/admin/reports/status'); + $item = $this->getMink() + ->assertSession() + ->elementExists('css', 'h3:contains("Drupal Version")') + ->getParent() + ->getText(); + $this->assertStringContainsString($expected_version, $item); + } + + /** + * Asserts that Drupal core was updated successfully. + * + * Assumes that a user with appropriate permissions is logged in. + * + * @param string $expected_version + * The expected active version of Drupal core. */ - private function assertUpdateSuccessful(): void { + private function assertUpdateSuccessful(string $expected_version): void { // The update form should not have any available updates. // @todo Figure out why this assertion fails when the batch processor // redirects directly to the update form, instead of update.status, when // updating via the UI. $this->visit('/admin/modules/automatic-update'); $this->getMink()->assertSession()->pageTextContains('No update available'); - // The status page should report that we're running Drupal 9.8.1. - $this->assertCoreVersion('9.8.1'); - // The fake placeholder text from ::getConfigurationForUpdate() should be - // present in the README. + + // The status page should report that we're running the expected version and + // the README should contain the placeholder text written by + // ::setUpstreamCoreVersion(). + $this->assertCoreVersion($expected_version); $placeholder = file_get_contents($this->getWebRoot() . '/core/README.txt'); - $this->assertSame('Placeholder for Drupal core 9.8.1.', $placeholder); + $this->assertSame("Placeholder for Drupal core $expected_version.", $placeholder); + + $info = $this->runComposer('composer info --self --format json', 'project', TRUE); - $composer = file_get_contents($this->getWorkspaceDirectory() . '/composer.json'); - $composer = Json::decode($composer); // The production dependencies should have been updated. - $this->assertSame('9.8.1', $composer['require']['drupal/core-recommended']); - $this->assertSame('9.8.1', $composer['require']['drupal/core-composer-scaffold']); - $this->assertSame('9.8.1', $composer['require']['drupal/core-project-message']); + $this->assertSame($expected_version, $info['requires']['drupal/core-recommended']); + $this->assertSame($expected_version, $info['requires']['drupal/core-composer-scaffold']); + $this->assertSame($expected_version, $info['requires']['drupal/core-project-message']); // The core-vendor-hardening plugin is only used by the legacy project // template. - if ($composer['name'] === 'drupal/legacy-project') { - $this->assertSame('9.8.1', $composer['require']['drupal/core-vendor-hardening']); + if ($info['name'] === 'drupal/legacy-project') { + $this->assertSame($expected_version, $info['requires']['drupal/core-vendor-hardening']); } // The production dependencies should not be listed as dev dependencies. - $this->assertArrayNotHasKey('drupal/core-recommended', $composer['require-dev']); - $this->assertArrayNotHasKey('drupal/core-composer-scaffold', $composer['require-dev']); - $this->assertArrayNotHasKey('drupal/core-project-message', $composer['require-dev']); - $this->assertArrayNotHasKey('drupal/core-vendor-hardening', $composer['require-dev']); + $this->assertArrayNotHasKey('drupal/core-recommended', $info['devRequires']); + $this->assertArrayNotHasKey('drupal/core-composer-scaffold', $info['devRequires']); + $this->assertArrayNotHasKey('drupal/core-project-message', $info['devRequires']); + $this->assertArrayNotHasKey('drupal/core-vendor-hardening', $info['devRequires']); // The drupal/core-dev metapackage should not be a production dependency... - $this->assertArrayNotHasKey('drupal/core-dev', $composer['require']); + $this->assertArrayNotHasKey('drupal/core-dev', $info['requires']); // ...but it should have been updated in the dev dependencies. - $this->assertSame('9.8.1', $composer['require-dev']['drupal/core-dev']); + $this->assertSame($expected_version, $info['devRequires']['drupal/core-dev']); } } diff --git a/tests/src/Build/TemplateProjectSiteTestBase.php b/tests/src/Build/TemplateProjectSiteTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..a05c5cd98de022ca892d4ce905fadeb861af6471 --- /dev/null +++ b/tests/src/Build/TemplateProjectSiteTestBase.php @@ -0,0 +1,285 @@ +<?php + +namespace Drupal\Tests\automatic_updates\Build; + +use Drupal\BuildTests\QuickStart\QuickStartTestBase; +use Drupal\Composer\Composer; + +/** + * Base class for tests which create a test site from a core project template. + */ +abstract class TemplateProjectSiteTestBase extends QuickStartTestBase { + + /** + * The web root of the test site, relative to the workspace directory. + * + * @var string + */ + private $webRoot; + + /** + * Data provider for tests which use all of the core project templates. + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerTemplate(): array { + return [ + ['RecommendedProject'], + ['LegacyProject'], + ]; + } + + /** + * {@inheritdoc} + */ + public function copyCodebase(\Iterator $iterator = NULL, $working_dir = NULL) { + parent::copyCodebase($iterator, $working_dir); + + // In certain situations, like Drupal CI, automatic_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/automatic_updates from + // drupal/core-recommended. + $this->runComposer('composer remove --no-update drupal/automatic_updates', 'composer/Metapackage/CoreRecommended'); + } + + /** + * Returns the full path to the test site's document root. + * + * @return string + * The full path of the test site's document root. + */ + protected function getWebRoot(): string { + return $this->getWorkspaceDirectory() . '/' . $this->webRoot; + } + + /** + * {@inheritdoc} + */ + protected function instantiateServer($port, $working_dir = NULL) { + return parent::instantiateServer($port, $working_dir ?: $this->webRoot); + } + + /** + * {@inheritdoc} + */ + public function installQuickStart($profile, $working_dir = NULL) { + parent::installQuickStart($profile, $working_dir ?: $this->webRoot); + } + + /** + * {@inheritdoc} + */ + public function visit($request_uri = '/', $working_dir = NULL) { + return parent::visit($request_uri, $working_dir ?: $this->webRoot); + } + + /** + * {@inheritdoc} + */ + public function formLogin($username, $password, $working_dir = NULL) { + parent::formLogin($username, $password, $working_dir ?: $this->webRoot); + } + + /** + * Returns the paths of all core Composer packages. + * + * @return string[] + * The paths of the core Composer packages, keyed by parent directory name. + */ + protected function getCorePackages(): array { + $workspace_dir = $this->getWorkspaceDirectory(); + + $packages = [ + 'core' => "$workspace_dir/core", + ]; + foreach (['Metapackage', 'Plugin'] as $type) { + foreach (Composer::composerSubprojectPaths($workspace_dir, $type) as $package) { + $path = $package->getPath(); + $name = basename($path); + $packages[$name] = $path; + } + } + return $packages; + } + + /** + * Creates a test project from a given template and installs Drupal. + * + * @param string $template + * The template to use. Can be 'RecommendedProject' or 'LegacyProject'. + */ + protected function createTestProject(string $template): void { + // Create a copy of core (including its Composer plugins, templates, and + // metapackages) which we can modify. + $this->copyCodebase(); + + $workspace_dir = $this->getWorkspaceDirectory(); + $template_dir = "composer/Template/$template"; + + // Remove the packages.drupal.org entry (and any other custom repository) + // from the template's repositories section. We have no reliable way of + // knowing the repositories' names in advance, so we get that information + // from `composer config`, and use `composer config --unset` to actually + // modify the template, to ensure it's done correctly. + $repositories = $this->runComposer('composer config repo', $template_dir, TRUE); + + foreach (array_keys($repositories) as $name) { + $this->runComposer("composer config --unset repo.$name", $template_dir); + } + + // Add all core plugins and metapackages as path repositories. To disable + // symlinking, we need to pass the JSON representations of the repositories + // to `composer config`. + foreach ($this->getCorePackages() as $name => $path) { + $repository = [ + 'type' => 'path', + 'url' => $path, + 'options' => [ + 'symlink' => FALSE, + ], + ]; + $repository = json_encode($repository, JSON_UNESCAPED_SLASHES); + $this->runComposer("composer config repo.$name '$repository'", $template_dir); + } + + // Add a local Composer repository with all third-party dependencies. + $vendor = "$workspace_dir/vendor.json"; + file_put_contents($vendor, json_encode($this->createVendorRepository(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + $this->runComposer("composer config repo.vendor composer file://$vendor", $template_dir); + + // Disable Packagist entirely so that we don't test the Internet. + $this->runComposer('composer config repo.packagist.org false', $template_dir); + + // Create the test project, defining its repository as part of the + // `composer create-project` command. + $repository = [ + 'type' => 'path', + 'url' => $template_dir, + ]; + $command = sprintf( + "COMPOSER_MIRROR_PATH_REPOS=1 composer create-project %s project --stability dev --repository '%s'", + $this->runComposer('composer config name', $template_dir), + json_encode($repository, JSON_UNESCAPED_SLASHES) + ); + // Because we set the COMPOSER_MIRROR_PATH_REPOS=1 environment variable when + // creating the project, none of the dependencies should be symlinked. + $this->assertStringNotContainsString('Symlinking', $this->runComposer($command)); + + // If using the drupal/recommended-project template, we don't expect there + // to be an .htaccess file at the project root. One would normally be + // generated by Composer when Package Manager or other code creates a + // ComposerUtility object in the active directory, except that Package + // Manager takes specific steps to prevent that. So, here we're just + // confirming that, in fact, Composer's .htaccess protection was disabled. + // We don't do this for the drupal/legacy-project template because its + // project root, which is also the document root, SHOULD contain a .htaccess + // generated by Drupal core. + // We do this check because this test uses PHP's built-in web server, which + // ignores .htaccess files and everything in them, so a Composer-generated + // .htaccess file won't cause this test to fail. + if ($template === 'RecommendedProject') { + $this->assertFileDoesNotExist("$workspace_dir/project/.htaccess"); + } + + // 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'); + } + + /** + * Creates a Composer repository for all installed third-party dependencies. + * + * @return array + * The data that should be written to the repository file. + */ + protected function createVendorRepository(): array { + $packages = []; + $drupal_root = $this->getDrupalRoot(); + + foreach ($this->getPackagesFromLockFile() as $package) { + $name = $package['name']; + $path = "$drupal_root/vendor/$name"; + + // 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 and will be represented as path repositories in the test + // project's composer.json. + if (is_dir($path) && !is_link($path)) { + unset( + // Force the package to be installed from our 'dist' information. + $package['source'], + // Don't notify anybody that we're installing this package. + $package['notification-url'], + // Since Drupal 9 requires PHP 7.3 or later, these polyfills won't be + // installed, so we should make sure that they're not required by + // anything. + $package['require']['symfony/polyfill-php72'], + $package['require']['symfony/polyfill-php73'] + ); + // Disabling symlinks in the transport options doesn't seem to have an + // effect, so we use the COMPOSER_MIRROR_PATH_REPOS environment variable + // to force mirroring in ::createTestProject(). + $package['dist'] = [ + 'type' => 'path', + 'url' => $path, + ]; + $version = $package['version']; + $packages[$name][$version] = $package; + } + } + return ['packages' => $packages]; + } + + /** + * Returns all package information from the lock file. + * + * @return array[] + * All package data from the lock file. + */ + private function getPackagesFromLockFile(): array { + $lock = $this->getDrupalRoot() . '/composer.lock'; + $this->assertFileExists($lock); + + $lock = file_get_contents($lock); + $lock = json_decode($lock, TRUE, JSON_THROW_ON_ERROR); + + $lock += [ + 'packages' => [], + 'packages-dev' => [], + ]; + return array_merge($lock['packages'], $lock['packages-dev']); + } + + /** + * Runs a Composer command and returns its output. + * + * Always asserts that the command was executed successfully. + * + * @param string $command + * The command to execute, including the `composer` invocation. + * @param string $working_dir + * (optional) A working directory relative to the workspace, within which to + * execute the command. Defaults to the workspace directory. + * @param bool $json + * (optional) Whether to parse the command's output as JSON before returning + * it. Defaults to FALSE. + * + * @return mixed|string|null + * The command's output, optionally parsed as JSON. + */ + protected function runComposer(string $command, string $working_dir = NULL, bool $json = FALSE) { + $output = $this->executeCommand($command, $working_dir)->getOutput(); + $this->assertCommandSuccessful(); + + $output = trim($output); + if ($json) { + $output = json_decode($output, TRUE, JSON_THROW_ON_ERROR); + } + return $output; + } + +} diff --git a/tests/src/Build/UpdateTestBase.php b/tests/src/Build/UpdateTestBase.php index 367e0f824153e08e0fa4362e7e7acd652c142771..907acead16dba817590e0163eedf31016f789994 100644 --- a/tests/src/Build/UpdateTestBase.php +++ b/tests/src/Build/UpdateTestBase.php @@ -2,22 +2,12 @@ namespace Drupal\Tests\automatic_updates\Build; -use Drupal\BuildTests\QuickStart\QuickStartTestBase; -use Drupal\Component\Serialization\Json; use Drupal\Component\Utility\Html; -use Drupal\Tests\automatic_updates\Traits\LocalPackagesTrait; -use Drupal\Tests\automatic_updates\Traits\SettingsTrait; /** * Base class for tests that perform in-place updates. */ -abstract class UpdateTestBase extends QuickStartTestBase { - - use LocalPackagesTrait { - getPackagePath as traitGetPackagePath; - copyPackage as traitCopyPackage; - } - use SettingsTrait; +abstract class UpdateTestBase extends TemplateProjectSiteTestBase { /** * A secondary server instance, to serve XML metadata about available updates. @@ -26,15 +16,6 @@ abstract class UpdateTestBase extends QuickStartTestBase { */ private $metadataServer; - /** - * The test site's document root, relative to the workspace directory. - * - * @var string - * - * @see ::createTestSite() - */ - private $webRoot; - /** * {@inheritdoc} */ @@ -48,36 +29,52 @@ abstract class UpdateTestBase extends QuickStartTestBase { /** * {@inheritdoc} */ - protected function copyPackage(string $source_dir, string $destination_dir = NULL): string { - return $this->traitCopyPackage($source_dir, $destination_dir ?: $this->getWorkspaceDirectory()); - } - - /** - * {@inheritdoc} - */ - protected function getPackagePath(array $package): string { - if ($package['name'] === 'drupal/core') { - return 'core'; - } + protected function createTestProject(string $template): void { + parent::createTestProject($template); - [$vendor, $name] = explode('/', $package['name']); + // Install Automatic Updates into the test project and ensure it wasn't + // symlinked. + $dir = 'project'; + $this->runComposer('composer config repo.automatic_updates path ' . __DIR__ . '/../../..', $dir); + $this->assertStringNotContainsString('Symlinking', $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require "drupal/automatic_updates:@dev"', $dir)); - // Assume any contributed module is in modules/contrib/$name. - if ($vendor === 'drupal' && $package['type'] === 'drupal-module') { - return implode(DIRECTORY_SEPARATOR, ['modules', 'contrib', $name]); - } - - return $this->traitGetPackagePath($package); + // Install Drupal. Always allow test modules to be installed in the UI and, + // for easier debugging, always display errors in their dubious glory. + $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([ + 'automatic_updates', + 'automatic_updates_test', + 'update_test', + ]); } /** - * Returns the full path to the test site's document root. + * Appends PHP code to the test site's settings.php. * - * @return string - * The full path of the test site's document root. - */ - protected function getWebRoot(): string { - return $this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $this->webRoot; + * @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)); } /** @@ -100,158 +97,12 @@ END; // about available updates. if (empty($this->metadataServer)) { $port = $this->findAvailablePort(); - $this->metadataServer = $this->instantiateServer($port, $this->webRoot); + $this->metadataServer = $this->instantiateServer($port); $code .= <<<END \$config['update.settings']['fetch']['url'] = 'http://localhost:$port/automatic-update-test'; END; } - $this->addSettings($code, $this->getWebRoot()); - } - - /** - * {@inheritdoc} - */ - public function visit($request_uri = '/', $working_dir = NULL) { - return parent::visit($request_uri, $working_dir ?: $this->webRoot); - } - - /** - * {@inheritdoc} - */ - public function formLogin($username, $password, $working_dir = NULL) { - parent::formLogin($username, $password, $working_dir ?: $this->webRoot); - } - - /** - * {@inheritdoc} - */ - 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->addSettings($php, $this->getWebRoot()); - } - - /** - * Uses our already-installed dependencies to build a test site to update. - * - * @param string $template - * The template project from which to build the test site. Can be - * 'drupal/recommended-project' or 'drupal/legacy-project'. - */ - protected function createTestSite(string $template): void { - // Create the test site using one of the core project templates, but don't - // install dependencies just yet. - $template_dir = implode(DIRECTORY_SEPARATOR, [ - $this->getDrupalRoot(), - 'composer', - 'Template', - ]); - $recommended_template = $this->createPathRepository($template_dir . DIRECTORY_SEPARATOR . 'RecommendedProject'); - $legacy_template = $this->createPathRepository($template_dir . DIRECTORY_SEPARATOR . 'LegacyProject'); - - $dir = $this->getWorkspaceDirectory(); - $command = sprintf( - "composer create-project %s %s --no-install --stability dev --repository '%s' --repository '%s'", - $template, - $dir, - Json::encode($recommended_template), - Json::encode($legacy_template) - ); - $this->executeCommand($command); - $this->assertCommandSuccessful(); - - $composer = $dir . DIRECTORY_SEPARATOR . 'composer.json'; - $data = $this->readJson($composer); - - // Allow the test to configure the test site as necessary. - $data = $this->getInitialConfiguration($data); - - // We need to know the path of the web root, relative to the project root, - // in order to install Drupal or visit the test site at all. Luckily, both - // template projects define this because the scaffold plugin needs to know - // it as well. - // @see ::visit() - // @see ::formLogin() - // @see ::installQuickStart() - $this->webRoot = $data['extra']['drupal-scaffold']['locations']['web-root']; - - // Update the test site's composer.json. - $this->writeJson($composer, $data); - // Install dependencies, including dev. - $this->executeCommand('composer install'); - $this->assertCommandSuccessful(); - } - - /** - * Returns the initial data to write to the test site's composer.json. - * - * This configuration will be used to build the pre-update test site. - * - * @param array $data - * The current contents of the test site's composer.json. - * - * @return array - * The data that should be written to the test site's composer.json. - */ - protected function getInitialConfiguration(array $data): array { - $drupal_root = $this->getDrupalRoot(); - $core_composer_dir = $drupal_root . DIRECTORY_SEPARATOR . 'composer'; - $repositories = []; - - // Add all the metapackages that are provided by Drupal core. - $metapackage_dir = $core_composer_dir . DIRECTORY_SEPARATOR . 'Metapackage'; - $repositories['drupal/core-recommended'] = $this->createPathRepository($metapackage_dir . DIRECTORY_SEPARATOR . 'CoreRecommended'); - $repositories['drupal/core-dev'] = $this->createPathRepository($metapackage_dir . DIRECTORY_SEPARATOR . 'DevDependencies'); - - // Add all the Composer plugins that are provided by Drupal core. - $plugin_dir = $core_composer_dir . DIRECTORY_SEPARATOR . 'Plugin'; - $repositories['drupal/core-project-message'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'ProjectMessage'); - $repositories['drupal/core-composer-scaffold'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'Scaffold'); - $repositories['drupal/core-vendor-hardening'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'VendorHardening'); - - $repositories = array_merge($repositories, $this->getLocalPackageRepositories($drupal_root)); - // To ensure the test runs entirely offline, don't allow Composer to contact - // Packagist. - $repositories['packagist.org'] = FALSE; - - $repositories['drupal/automatic_updates'] = $this->createPathRepository(__DIR__ . '/../../..'); - // Use whatever the current branch of automatic_updates is. - $data['require']['drupal/automatic_updates'] = '*'; - - $data['repositories'] = $repositories; - - // Since Drupal 9 requires PHP 7.3 or later, these packages are probably - // not installed, which can cause trouble during dependency resolution. - // The drupal/drupal package (defined with a composer.json that is part - // of core's repository) replaces these, so we need to emulate that here. - $data['replace']['symfony/polyfill-php72'] = '*'; - $data['replace']['symfony/polyfill-php73'] = '*'; - - return $data; - } - - /** - * Asserts that a specific version of Drupal core is running. - * - * Assumes that a user with permission to view the status report is logged in. - * - * @param string $expected_version - * The version of core that should be running. - */ - protected function assertCoreVersion(string $expected_version): void { - $this->visit('/admin/reports/status'); - $item = $this->getMink() - ->assertSession() - ->elementExists('css', 'h3:contains("Drupal Version")') - ->getParent() - ->getText(); - $this->assertStringContainsString($expected_version, $item); + $this->writeSettings($code); } /** diff --git a/tests/src/Traits/JsonTrait.php b/tests/src/Traits/JsonTrait.php deleted file mode 100644 index 2c216306b2cc6b3a65e047a4b2ac6aa472612436..0000000000000000000000000000000000000000 --- a/tests/src/Traits/JsonTrait.php +++ /dev/null @@ -1,41 +0,0 @@ -<?php - -namespace Drupal\Tests\automatic_updates\Traits; - -use Drupal\Component\Serialization\Json; -use PHPUnit\Framework\Assert; - -/** - * Provides assertive methods to read and write JSON data in files. - */ -trait JsonTrait { - - /** - * Reads JSON data from a file and returns it as an array. - * - * @param string $path - * The path of the file to read. - * - * @return mixed[] - * The parsed data in the file. - */ - protected function readJson(string $path): array { - Assert::assertIsReadable($path); - $data = file_get_contents($path); - return Json::decode($data); - } - - /** - * Writes an array of data to a file as JSON. - * - * @param string $path - * The path of the file to write. - * @param array $data - * The data to be written. - */ - protected function writeJson(string $path, array $data): void { - Assert::assertIsWritable(file_exists($path) ? $path : dirname($path)); - file_put_contents($path, Json::encode($data)); - } - -} diff --git a/tests/src/Traits/LocalPackagesTrait.php b/tests/src/Traits/LocalPackagesTrait.php deleted file mode 100644 index a0c178fb9db2eaa55c97b40ec3a27ab8e49fe8c5..0000000000000000000000000000000000000000 --- a/tests/src/Traits/LocalPackagesTrait.php +++ /dev/null @@ -1,163 +0,0 @@ -<?php - -namespace Drupal\Tests\automatic_updates\Traits; - -use Drupal\Component\FileSystem\FileSystem; -use Drupal\Component\Utility\NestedArray; -use PHPUnit\Framework\Assert; -use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem; - -/** - * Provides methods for interacting with installed Composer packages. - */ -trait LocalPackagesTrait { - - use JsonTrait; - - /** - * The paths of temporary copies of packages. - * - * @see ::copyPackage() - * @see ::deleteCopiedPackages() - * - * @var string[] - */ - private $copiedPackages = []; - - /** - * Returns the path of an installed package, relative to composer.json. - * - * @param array $package - * The package information, as read from the lock file. - * - * @return string - * The path of the installed package, relative to composer.json. - */ - protected function getPackagePath(array $package): string { - return 'vendor' . DIRECTORY_SEPARATOR . $package['name']; - } - - /** - * Deletes all copied packages. - * - * @see ::copyPackage() - */ - protected function deleteCopiedPackages(): void { - (new SymfonyFilesystem())->remove($this->copiedPackages); - } - - /** - * Copies a package's entire directory to another location. - * - * The copies' paths will be stored so that they can be easily deleted by - * ::deleteCopiedPackages(). - * - * @param string $source_dir - * The path of the package directory to copy. - * @param string|null $destination_dir - * (optional) The directory to which the package should be copied. Will be - * suffixed with a random string to ensure uniqueness. If not given, the - * system temporary directory will be used. - * - * @return string - * The path of the temporary copy. - * - * @see ::deleteCopiedPackages() - */ - protected function copyPackage(string $source_dir, string $destination_dir = NULL): string { - Assert::assertDirectoryExists($source_dir); - - if (empty($destination_dir)) { - $destination_dir = FileSystem::getOsTemporaryDirectory(); - Assert::assertNotEmpty($destination_dir); - $destination_dir .= DIRECTORY_SEPARATOR; - } - $destination_dir = uniqid($destination_dir); - Assert::assertDirectoryDoesNotExist($destination_dir); - - (new SymfonyFilesystem())->mirror($source_dir, $destination_dir); - array_push($this->copiedPackages, $destination_dir); - - return $destination_dir; - } - - /** - * Generates local path repositories for a set of installed packages. - * - * @param string $dir - * The directory which contains composer.lock. - * - * @return mixed[][] - * The local path repositories' configuration, for inclusion in a - * composer.json file. - */ - protected function getLocalPackageRepositories(string $dir): array { - $repositories = []; - - foreach ($this->getPackagesFromLockFile($dir) as $package) { - // Ensure the package directory is writable, since we'll need to make a - // few changes to it. - $path = $dir . DIRECTORY_SEPARATOR . $this->getPackagePath($package); - Assert::assertIsWritable($path); - $composer = $path . DIRECTORY_SEPARATOR . 'composer.json'; - - // Overwrite the composer.json with the fully resolved package information - // from the lock file. - // @todo Back up composer.json before overwriting it? - $this->writeJson($composer, $package); - - $name = $package['name']; - $repositories[$name] = $this->createPathRepository($path); - } - return $repositories; - } - - /** - * Defines a local path repository for a given path. - * - * @param string $path - * The path of the repository. - * - * @return array - * The local path repository definition. - */ - protected function createPathRepository(string $path): array { - return [ - 'type' => 'path', - 'url' => $path, - 'options' => [ - 'symlink' => FALSE, - ], - ]; - } - - /** - * Alters a package's composer.json file. - * - * @param string $package_dir - * The package directory. - * @param array $changes - * The changes to merge into composer.json. - */ - protected function alterPackage(string $package_dir, array $changes): void { - $composer = $package_dir . DIRECTORY_SEPARATOR . 'composer.json'; - $data = $this->readJson($composer); - $data = NestedArray::mergeDeep($data, $changes); - $this->writeJson($composer, $data); - } - - /** - * Reads all package information from a composer.lock file. - * - * @param string $dir - * The directory which contains the lock file. - * - * @return mixed[][] - * All package information (including dev packages) from the lock file. - */ - private function getPackagesFromLockFile(string $dir): array { - $lock = $this->readJson($dir . DIRECTORY_SEPARATOR . 'composer.lock'); - return array_merge($lock['packages'], $lock['packages-dev']); - } - -} diff --git a/tests/src/Traits/SettingsTrait.php b/tests/src/Traits/SettingsTrait.php deleted file mode 100644 index b9ae2948990b758c4fda838b72f7f3af4ff3b215..0000000000000000000000000000000000000000 --- a/tests/src/Traits/SettingsTrait.php +++ /dev/null @@ -1,57 +0,0 @@ -<?php - -namespace Drupal\Tests\automatic_updates\Traits; - -use PHPUnit\Framework\Assert; - -/** - * Provides methods for manipulating site settings. - */ -trait SettingsTrait { - - /** - * Appends some PHP code to settings.php. - * - * @param string $php - * The PHP code to append to settings.php. - * @param string $drupal_root - * The path of the Drupal root. - * @param string $site - * (optional) The name of the site whose settings.php should be amended. - * Defaults to 'default'. - */ - protected function addSettings(string $php, string $drupal_root, string $site = 'default'): void { - $settings = $this->makeSettingsWritable($drupal_root, $site); - $settings = fopen($settings, 'a'); - Assert::assertIsResource($settings); - Assert::assertIsInt(fwrite($settings, $php)); - Assert::assertTrue(fclose($settings)); - } - - /** - * Ensures that settings.php is writable. - * - * @param string $drupal_root - * The path of the Drupal root. - * @param string $site - * (optional) The name of the site whose settings should be made writable. - * Defaults to 'default'. - * - * @return string - * The path to settings.php for the specified site. - */ - private function makeSettingsWritable(string $drupal_root, string $site = 'default'): string { - $settings = implode(DIRECTORY_SEPARATOR, [ - $drupal_root, - 'sites', - $site, - 'settings.php', - ]); - chmod(dirname($settings), 0744); - chmod($settings, 0744); - Assert::assertIsWritable($settings); - - return $settings; - } - -}