Skip to content
Snippets Groups Projects

Contrib: Issue #3411241: Expand ConverterCommand documentation to make it...

1 file
+ 4
0
Compare changes
  • Side-by-side
  • Inline
<?php
declare(strict_types = 1);
namespace Drupal\Tests\package_manager\Build;
use Drupal\BuildTests\QuickStart\QuickStartTestBase;
use Drupal\Component\Serialization\Yaml;
use Drupal\Composer\Composer;
use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
use Drupal\package_manager_test_event_logger\EventSubscriber\EventLogSubscriber;
use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait;
use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait;
use Drupal\Tests\RandomGeneratorTrait;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
/**
* 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.
*
* @internal
*/
abstract class TemplateProjectTestBase extends QuickStartTestBase {
use AssertPreconditionsTrait;
use FixtureUtilityTrait;
use RandomGeneratorTrait;
/**
* The web root of the test site, relative to the workspace directory.
*
* @var string
*/
private $webRoot;
/**
* A secondary server instance, to serve XML metadata about available updates.
*
* @var \Symfony\Component\Process\Process
*/
private $metadataServer;
/**
* {@inheritdoc}
*/
protected function tearDown(): void {
if ($this->metadataServer) {
$this->metadataServer->stop();
}
parent::tearDown();
}
/**
* Data provider for tests which use all of the core project templates.
*
* @return string[][]
* The test cases.
*/
public function providerTemplate(): array {
return [
'RecommendedProject' => ['RecommendedProject'],
'LegacyProject' => ['LegacyProject'],
];
}
/**
* {@inheritdoc}
*/
public function getCodebaseFinder() {
// If core's npm dependencies are installed, we don't want them to be
// included in the upstream version of core that gets installed into the
// test site.
return parent::getCodebaseFinder()->notPath('#^core/node_modules#');
}
/**
* Sets the version of Drupal core to which the test site will be updated.
*
* @param string $version
* The Drupal core version to set.
*/
protected 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/", '', $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. We
// also change the default site configuration files so we can ensure that
// these are updated as well, despite `sites/default` being write-protected.
// @see ::assertUpdateSuccessful()
// @see ::createTestProject()
Composer::setDrupalVersion($workspace_dir, $version);
file_put_contents("$workspace_dir/core/README.txt", "Placeholder for Drupal core $version.");
foreach (['default.settings.php', 'default.services.yml'] as $file) {
$file = fopen("$workspace_dir/core/assets/scaffold/files/$file", 'a');
$this->assertIsResource($file);
fwrite($file, "# This is part of Drupal $version.\n");
fclose($file);
}
}
/**
* 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) {
$working_dir = $working_dir ?: $this->webRoot;
$finder = new PhpExecutableFinder();
$working_path = $this->getWorkingPath($working_dir);
$server = [
$finder->find(),
'-S',
'127.0.0.1:' . $port,
'-d max_execution_time=10',
'-d disable_functions=set_time_limit',
'-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()));
}
/**
* {@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->writeSettings($php);
}
/**
* {@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;
}
/**
* Adds a path repository to the test site.
*
* @param string $name
* An arbitrary name for the repository.
* @param string $path
* The path of the repository. Must exist in the file system.
* @param string $working_directory
* (optional) The Composer working directory. Defaults to 'project'.
*/
protected function addRepository(string $name, string $path, $working_directory = 'project'): void {
$this->assertDirectoryExists($path);
$repository = json_encode([
'type' => 'path',
'url' => $path,
'options' => [
'symlink' => FALSE,
],
], JSON_UNESCAPED_SLASHES);
$this->runComposer("composer config repo.$name '$repository'", $working_directory);
}
/**
* Prepares the test site to serve an XML feed of available release metadata.
*
* @param array $xml_map
* The update XML map, as used by update_test.settings.
*
* @see \Drupal\package_manager_test_release_history\TestController::metadata()
*/
protected function setReleaseMetadata(array $xml_map): void {
foreach ($xml_map as $metadata_file) {
$this->assertFileIsReadable($metadata_file);
}
$xml_map = var_export($xml_map, TRUE);
$this->writeSettings("\$config['update_test.settings']['xml_map'] = $xml_map;");
}
/**
* 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";
// Allow pre-release versions of dependencies.
$this->runComposer('composer config minimum-stability dev', $template_dir);
// Allow any version of Drupal core as in test using a git clone on Gitlab
// core will be checked out at a specific commit hash.
$this->runComposer("composer require --no-update drupal/core:'*'", "composer/Metapackage/CoreRecommended");
// 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) {
$this->addRepository($name, $path, $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);
// Allow any version of the Drupal core packages in the template project.
$this->runComposer('composer require --no-update drupal/core-recommended:* drupal/core-project-message:* drupal/core-composer-scaffold:*', $template_dir);
$this->runComposer('composer require --no-update --dev drupal/core-dev:*', $template_dir);
if ($template === 'LegacyProject') {
$this->runComposer('composer require --no-update drupal/core-vendor-hardening:*', $template_dir);
}
// Do not run development Composer plugin, since it tries to run an
// executable that might not exist while dependencies are being installed
// and it adds no value to this test.
$this->runComposer("composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer false", $template_dir);
// Create the test project, defining its repository as part of the
// `composer create-project` command.
$repository = [
'type' => 'path',
'url' => $template_dir,
];
// The COMPOSER_MIRROR_PATH_REPOS environment variable is necessary because
// the vendor packages are installed from a Composer-type repository, which
// will normally try to symlink packages which are installed from local
// directories. This breaks Package Manager, because it does not support
// symlinks pointing outside the main code base. The
// COMPOSER_MIRROR_PATH_REPOS environment variable forces Composer to
// mirror, rather than symlink, local directories during during package
// installation.
$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
// ComposerInspector 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');
// Install Drupal.
$this->installQuickStart('standard');
$this->formLogin($this->adminUsername, $this->adminPassword);
// When checking for updates, we need to be able to make sub-requests, but
// the built-in PHP server is single-threaded. Therefore, open a second
// server instance on another port, which will serve the metadata about
// available updates.
$port = $this->findAvailablePort();
$this->metadataServer = $this->instantiateServer($port);
$code = <<<END
\$config['update.settings']['fetch']['url'] = 'http://localhost:$port/test-release-history';
END;
$this->writeSettings($code);
// Install helpful modules.
$this->installModules([
'package_manager_test_api',
'package_manager_test_event_logger',
'package_manager_test_release_history',
]);
// Confirm the server time out settings.
// @see \Drupal\Tests\package_manager\Build\TemplateProjectTestBase::instantiateServer()
$this->visit('/package-manager-test-api/check-setup');
$this->getMink()
->assertSession()
->pageTextContains("max_execution_time=10:set_time_limit-exists=no");
}
/**
* Creates a Composer repository for all installed third-party dependencies.
*
* @return string[][]
* The data that should be written to the repository file.
*/
protected function createVendorRepository(): array {
$packages = [];
$drupal_root = $this->getDrupalRoot();
// @todo Add assertions that these packages never get added to vendor.json
// and determine if this logic should removed in the core merge request in
// https://drupal.org/i/3319679.
$core_packages = [
'drupal/core-vendor-hardening',
'drupal/core-project-message',
];
$output = $this->runComposer("composer show --format=json --working-dir=$drupal_root", NULL, TRUE);
foreach ($output['installed'] as $installed_package) {
$name = $installed_package['name'];
if (in_array($name, $core_packages, TRUE)) {
continue;
}
$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)) {
$package_info = $path . '/composer.json';
$this->assertFileIsReadable($package_info);
$package_info = file_get_contents($package_info);
$package_info = json_decode($package_info, TRUE, flags: JSON_THROW_ON_ERROR);
$version = $installed_package['version'];
// Create a pared-down package definition that has just enough
// information for Composer to install the package from the local copy:
// the name, version, package type, source path ("dist" in Composer
// terminology), and the autoload information, so that the classes
// provided by the package will actually be loadable in the test site
// we're building.
if (str_starts_with($version, 'dev-')) {
[$version, $reference] = explode(' ', $version, 2);
}
else {
$reference = $version;
}
$packages[$name][$version] = [
'name' => $name,
'version' => $version,
'type' => $package_info['type'] ?? 'library',
// 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().
'dist' => [
'type' => 'path',
'url' => $path,
],
'source' => [
'type' => 'path',
'url' => $path,
'reference' => $reference,
],
'autoload' => $package_info['autoload'] ?? [],
'provide' => $package_info['provide'] ?? [],
];
// These polyfills are dependencies of some packages, but for reasons we
// don't understand, they are not installed in code bases built on PHP
// versions that are newer than the ones being polyfilled, which means
// we won't be able to build our test project because these polyfills
// are not available in the local code base. Since we're guaranteed to
// be on PHP 8.1 or later, ensure no package requires polyfills of older
// versions of PHP.
if (isset($package_info['require'])) {
unset(
$package_info['require']['symfony/polyfill-php72'],
$package_info['require']['symfony/polyfill-php73'],
$package_info['require']['symfony/polyfill-php74'],
$package_info['require']['symfony/polyfill-php80'],
$package_info['require']['symfony/polyfill-php81'],
);
$packages[$name][$version]['require'] = $package_info['require'];
}
// Composer plugins are loaded and activated as early as possible, and
// they must have a `class` key defined in their `extra` section, along
// with a dependency on `composer-plugin-api` (plus any other real
// runtime dependencies).
if ($packages[$name][$version]['type'] === 'composer-plugin') {
$packages[$name][$version]['extra'] = $package_info['extra'] ?? [];
}
}
}
return ['packages' => $packages];
}
/**
* 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, flags: JSON_THROW_ON_ERROR);
}
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);
}
}
/**
* Copies a fixture directory to a temporary directory and returns its path.
*
* @param string $fixture_directory
* The fixture directory.
*
* @return string
* The temporary directory.
*/
protected function copyFixtureToTempDirectory(string $fixture_directory): string {
$temp_directory = $this->getWorkspaceDirectory() . '/fixtures_temp_' . $this->randomMachineName(20);
static::copyFixtureFilesTo($fixture_directory, $temp_directory);
return $temp_directory;
}
/**
* Asserts stage events were fired in a specific order.
*
* @param string $expected_stage_class
* The expected stage class for the events.
* @param array|null $expected_events
* (optional) The expected stage events that should have been fired in the
* order in which they should have been fired. Events can be specified more
* that once if they will be fired multiple times. If there are no events
* specified all life cycle events from PreCreateEvent to PostApplyEvent
* will be asserted.
* @param int $wait
* (optional) How many seconds to wait for the events to be fired. Defaults
* to 0.
* @param string $message
* (optional) A message to display with the assertion.
*
* @see \Drupal\package_manager_test_event_logger\EventSubscriber\EventLogSubscriber::logEventInfo
*/
protected function assertExpectedStageEventsFired(string $expected_stage_class, ?array $expected_events = NULL, int $wait = 0, string $message = ''): void {
if ($expected_events === NULL) {
$expected_events = EventLogSubscriber::getSubscribedEvents();
// The event subscriber uses this event to ensure the log file is excluded
// from Package Manager operations, but it's not relevant for our purposes
// because it's not part of the stage life cycle.
unset($expected_events[CollectPathsToExcludeEvent::class]);
$expected_events = array_keys($expected_events);
}
$this->assertNotEmpty($expected_events);
$log_file = $this->getWorkspaceDirectory() . '/project/' . EventLogSubscriber::LOG_FILE_NAME;
$max_wait = time() + $wait;
do {
$this->assertFileIsReadable($log_file);
$log_data = file_get_contents($log_file);
$log_data = json_decode($log_data, TRUE, flags: JSON_THROW_ON_ERROR);
// Filter out events logged by any other stage.
$log_data = array_filter($log_data, fn (array $event): bool => $event['stage'] === $expected_stage_class);
// If we've logged at least the expected number of events, stop waiting.
// Break out of the loop and assert the expected events were logged.
if (count($log_data) >= count($expected_events)) {
break;
}
// Wait a bit before checking again.
sleep(5);
} while ($max_wait > time());
$this->assertSame($expected_events, array_column($log_data, 'event'), $message);
}
/**
* Visits the 'admin/reports/dblog' and selects Package Manager's change log.
*/
private function visitPackageManagerChangeLog(): void {
$mink = $this->getMink();
$assert_session = $mink->assertSession();
$page = $mink->getSession()->getPage();
$this->visit('/admin/reports/dblog');
$assert_session->statusCodeEquals(200);
$page->selectFieldOption('Type', 'package_manager_change_log');
$page->pressButton('Filter');
$assert_session->statusCodeEquals(200);
}
/**
* Asserts changes requested during the stage life cycle were logged.
*
* This method specifically asserts changes that were *requested* (i.e.,
* during the require phase) rather than changes that were actually applied.
* The requested and applied changes may be exactly the same, or they may
* differ (for example, if a secondary dependency was added or updated in the
* stage directory).
*
* @param string[] $expected_requested_changes
* The expected requested changes.
*
* @see ::assertAppliedChangesWereLogged()
* @see \Drupal\package_manager\EventSubscriber\ChangeLogger
*/
protected function assertRequestedChangesWereLogged(array $expected_requested_changes): void {
$this->visitPackageManagerChangeLog();
$assert_session = $this->getMink()->assertSession();
$assert_session->elementExists('css', 'a[href*="/admin/reports/dblog/event/"]:contains("Requested changes:")')
->click();
array_walk($expected_requested_changes, $assert_session->pageTextContains(...));
}
/**
* Asserts that changes applied during the stage life cycle were logged.
*
* This method specifically asserts changes that were *applied*, rather than
* the changes that were merely requested. For example, if a package was
* required into the stage and it added a secondary dependency, that change
* will be considered one of the applied changes, not a requested change.
*
* @param string[] $expected_applied_changes
* The expected applied changes.
*
* @see ::assertRequestedChangesWereLogged()
* @see \Drupal\package_manager\EventSubscriber\ChangeLogger
*/
protected function assertAppliedChangesWereLogged(array $expected_applied_changes): void {
$this->visitPackageManagerChangeLog();
$assert_session = $this->getMink()->assertSession();
$assert_session->elementExists('css', 'a[href*="/admin/reports/dblog/event/"]:contains("Applied changes:")')
->click();
array_walk($expected_applied_changes, $assert_session->pageTextContains(...));
}
/**
* Gets a /package-manager-test-api response.
*
* @param string $url
* The package manager test API URL to fetch.
* @param array $query_data
* The query data.
*/
protected function makePackageManagerTestApiRequest(string $url, array $query_data): void {
$url .= '?' . http_build_query($query_data);
$this->visit($url);
$mink = $this->getMink();
$session = $mink->getSession();
// Ensure test failures provide helpful debug output when there's a fatal
// PHP error: don't use \Behat\Mink\WebAssert::statusCodeEquals().
if ($session->getStatusCode() == 500) {
$this->assertEquals(200, 500, 'Error response: ' . $session->getPage()->getContent());
}
else {
$mink->assertSession()->statusCodeEquals(200);
}
}
}
Loading