class TemplateProjectTestBase

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

Hierarchy

Expanded class hierarchy of TemplateProjectTestBase

File

core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php, line 31

Namespace

Drupal\Tests\package_manager\Build
View source
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 string $webRoot;
    
    /**
     * A secondary server instance, to serve XML metadata about available updates.
     *
     * @var \Symfony\Component\Process\Process
     */
    private Process $metadataServer;
    
    /**
     * All output that the PHP web server logs to the error buffer.
     *
     * @var string
     */
    private string $serverErrorLog = '';
    
    /**
     * The PHP web server's max_execution_time value.
     *
     * @var int
     */
    protected const MAX_EXECUTION_TIME = 20;
    
    /**
     * {@inheritdoc}
     */
    protected function setUp() : void {
        // Build tests cannot be run if SQLite minimum version is not met.
        $minimum_version = Tasks::SQLITE_MINIMUM_VERSION;
        $actual_version = (new \PDO('sqlite::memory:'))->query('select sqlite_version()')
            ->fetch()[0];
        if (version_compare($actual_version, $minimum_version, '<')) {
            $this->markTestSkipped("SQLite version {$minimum_version} or later is required, but {$actual_version} was detected.");
        }
        parent::setUp();
    }
    
    /**
     * {@inheritdoc}
     */
    protected function tearDown() : void {
        $this->metadataServer?->stop();
        parent::tearDown();
    }
    
    /**
     * Data provider for tests which use all the core project templates.
     *
     * @return string[][]
     *   The test cases.
     */
    public static function providerTemplate() : array {
        return [
            'RecommendedProject' => [
                'RecommendedProject',
            ],
            'LegacyProject' => [
                'LegacyProject',
            ],
        ];
    }
    
    /**
     * 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 {
        $this->createVendorRepository([
            'drupal/core' => $version,
            'drupal/core-dev' => $version,
            'drupal/core-dev-pinned' => $version,
            'drupal/core-recommended' => $version,
            'drupal/core-composer-scaffold' => $version,
            'drupal/core-project-message' => $version,
            'drupal/core-vendor-hardening' => $version,
        ]);
        // 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()
        $core_dir = $this->getWorkspaceDrupalRoot() . '/core';
        Composer::setDrupalVersion($this->getWorkspaceDrupalRoot(), $version);
        file_put_contents("{$core_dir}/README.txt", "Placeholder for Drupal core {$version}.");
        foreach ([
            'default.settings.php',
            'default.services.yml',
        ] as $file) {
            file_put_contents("{$core_dir}/assets/scaffold/files/{$file}", "# This is part of Drupal {$version}.\n", FILE_APPEND);
        }
    }
    
    /**
     * 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=' . static::MAX_EXECUTION_TIME,
            '-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(function ($output_type, $output) : void {
            if ($output_type === Process::ERR) {
                $this->serverErrorLog .= $output;
            }
        });
        // 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) : void {
        parent::installQuickStart("{$profile} --no-ansi", $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) : void {
        parent::formLogin($username, $password, $working_dir ?: $this->webRoot);
    }
    
    /**
     * 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();
        $project_dir = $workspace_dir . '/project';
        mkdir($project_dir);
        $data = file_get_contents("{$workspace_dir}/composer/Template/{$template}/composer.json");
        $data = json_decode($data, TRUE, flags: JSON_THROW_ON_ERROR);
        // Allow pre-release versions of dependencies.
        $data['minimum-stability'] = 'dev';
        // Remove all repositories and replace them with a single local one that
        // provides all dependencies.
        $data['repositories'] = [
            'vendor' => [
                'type' => 'composer',
                'url' => 'file://' . $workspace_dir . '/vendor.json',
            ],
            // Disable Packagist entirely so that we don't test the Internet.
'packagist.org' => FALSE,
        ];
        // Allow any version of the Drupal core packages in the template project.
        self::unboundCoreConstraints($data['require']);
        self::unboundCoreConstraints($data['require-dev']);
        // Do not run development Composer plugin, since it tries to run an
        // executable that might not exist while dependencies are being installed.
        // It adds no value to this test.
        $data['config']['allow-plugins']['dealerdirect/phpcodesniffer-composer-installer'] = FALSE;
        // Always force Composer to mirror path repositories. This is necessary
        // because dependencies 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.
        $script = '@putenv COMPOSER_MIRROR_PATH_REPOS=1';
        $data['scripts']['pre-install-cmd'] = $script;
        $data['scripts']['pre-update-cmd'] = $script;
        file_put_contents($project_dir . '/composer.json', json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
        // 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('composer install', 'project'));
        // 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/' . $data['extra']['drupal-scaffold']['locations']['web-root'];
        // 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:{<span class="php-variable">$port</span>}/test-release-history';
END;
        // Ensure Package Manager logs Composer Stager's process output to a file
        // named for the current test.
        $log = $this->getDrupalRoot() . '/sites/simpletest/browser_output';
        @mkdir($log, recursive: TRUE);
        $this->assertDirectoryIsWritable($log);
        $log .= '/' . str_replace('\\', '_', static::class) . '-' . $this->name();
        if ($this->usesDataProvider()) {
            $log .= '-' . preg_replace('/[^a-z0-9]+/i', '_', $this->dataName());
        }
        $code .= <<<END
\$config['package_manager.settings']['log'] = '{<span class="php-variable">$log</span>}-package_manager.log';
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=" . static::MAX_EXECUTION_TIME . ":set_time_limit-exists=no");
    }
    
    /**
     * Changes constraints for core packages to `*`.
     *
     * @param string[] $constraints
     *   A set of version constraints, like you'd find in the `require` or
     *   `require-dev` sections of `composer.json`. This array is modified by
     *   reference.
     */
    private static function unboundCoreConstraints(array &$constraints) : void {
        $names = preg_grep('/^drupal\\/core-?/', array_keys($constraints));
        foreach ($names as $name) {
            $constraints[$name] = '*';
        }
    }
    
    /**
     * Creates a Composer repository for all dependencies of the test project.
     *
     * We always reference third-party dependencies (i.e., any package that isn't
     * part of Drupal core) from the main project which is running this test.
     *
     * Packages that are part of Drupal core -- such as `drupal/core`,
     * `drupal/core-composer-scaffold`, and so on -- are expected to have been
     * copied into the workspace directory, so that we can modify them as needed.
     *
     * The file will be written to WORKSPACE_DIR/vendor.json.
     *
     * @param string[] $versions
     *   (optional) The versions of specific packages, keyed by package name.
     *   Versions of packages not in this array will be determined first by
     *   looking for a `version` key in the package's composer.json, then by
     *   calling \Composer\InstalledVersions::getPrettyVersion(). If none of that
     *   works, `dev-main` will be used as the package's version.
     */
    protected function createVendorRepository(array $versions = []) : void {
        $packages = [];
        $class_loaders = ClassLoader::getRegisteredLoaders();
        $workspace_dir = $this->getWorkspaceDirectory();
        $finder = Finder::create()->in([
            $this->getWorkspaceDrupalRoot() . '/core',
            "{$workspace_dir}/composer/Metapackage",
            "{$workspace_dir}/composer/Plugin",
            key($class_loaders),
        ])
            ->depth('< 3')
            ->files()
            ->name('composer.json');
        
        /** @var \Symfony\Component\Finder\SplFileInfo $file */
        foreach ($finder as $file) {
            $package_info = json_decode($file->getContents(), TRUE, flags: JSON_THROW_ON_ERROR);
            $name = $package_info['name'];
            $requirements = $package_info['require'] ?? [];
            // 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 aren't
            // available in the local code base. Since we're guaranteed to be on PHP
            // 8.3 or later, no package should need to polyfill older versions.
            unset($requirements['symfony/polyfill-php72'], $requirements['symfony/polyfill-php73'], $requirements['symfony/polyfill-php74'], $requirements['symfony/polyfill-php80'], $requirements['symfony/polyfill-php81'], $requirements['symfony/polyfill-php82'], $requirements['symfony/polyfill-php83']);
            // If this package requires any Drupal core packages, ensure it allows
            // any version.
            self::unboundCoreConstraints($requirements);
            // 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.
            if ($name === 'drupal/core-recommended') {
                unset($requirements['drupal/auto_updates']);
            }
            try {
                $version = $versions[$name] ?? $package_info['version'] ?? InstalledVersions::getPrettyVersion($name);
            } catch (\OutOfBoundsException) {
                $version = 'dev-main';
            }
            // 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.
            $path = $file->getPath();
            $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,
                ],
                'require' => $requirements,
                'autoload' => $package_info['autoload'] ?? [],
                'provide' => $package_info['provide'] ?? [],
                // 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). This is also necessary for packages that ship
                // scaffold files, like Drupal core.
'extra' => $package_info['extra'] ?? [],
            ];
        }
        $data = json_encode([
            'packages' => $packages,
        ], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
        file_put_contents($workspace_dir . '/vendor.json', $data);
    }
    
    /**
     * 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|null $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) {
        $process = $this->executeCommand($command, $working_dir);
        $this->assertCommandSuccessful();
        $output = trim($process->getOutput());
        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);
        $this->assertIsInt(file_put_contents($file, $php, FILE_APPEND));
    }
    
    /**
     * 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);
        $session = $this->getMink()
            ->getSession();
        // Ensure test failures provide helpful debug output when there's a fatal
        // PHP error: don't use \Behat\Mink\WebAssert::statusCodeEquals().
        $message = sprintf("Error response: %s\n\nHeaders: %s\n\nServer error log: %s", $session->getPage()
            ->getContent(), var_export($session->getResponseHeaders(), TRUE), $this->serverErrorLog);
        $this->assertSame(200, $session->getStatusCode(), $message);
    }
    
    /**
     * {@inheritdoc}
     */
    public function copyCodebase(?\Iterator $iterator = NULL, $working_dir = NULL) : void {
        parent::copyCodebase($iterator, $working_dir);
        // Create a local Composer repository for all third-party dependencies and
        // core packages.
        $this->createVendorRepository();
    }

}

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.