first commit
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\BuildTests\Composer;
|
||||
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use Drupal\Tests\Composer\ComposerIntegrationTrait;
|
||||
|
||||
/**
|
||||
* @group Composer
|
||||
* @requires externalCommand composer
|
||||
*/
|
||||
class ComposerValidateTest extends BuildTestBase {
|
||||
|
||||
use ComposerIntegrationTrait;
|
||||
|
||||
public function provideComposerJson() {
|
||||
$data = [];
|
||||
$composer_json_finder = $this->getComposerJsonFinder($this->getDrupalRoot());
|
||||
foreach ($composer_json_finder->getIterator() as $composer_json) {
|
||||
$data[] = [$composer_json->getPathname()];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideComposerJson
|
||||
*/
|
||||
public function testValidateComposer($path) {
|
||||
$this->executeCommand('composer validate --strict --no-check-all ' . $path);
|
||||
$this->assertCommandSuccessful();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\BuildTests\Composer\Template;
|
||||
|
||||
use Composer\Json\JsonFile;
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use Drupal\Composer\Composer;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* Demonstrate that Composer project templates are buildable as patched.
|
||||
*
|
||||
* We have to use the packages.json fixture so that Composer will use the
|
||||
* in-codebase version of the project template.
|
||||
*
|
||||
* We also have to add path repositories to the in-codebase project template or
|
||||
* else Composer will try to use packagist to resolve dependencies we'd prefer
|
||||
* it to find locally.
|
||||
*
|
||||
* This is because Composer only uses the packages.json file to resolve the
|
||||
* project template and not any other dependencies.
|
||||
*
|
||||
* @group #slow
|
||||
* @group Template
|
||||
*
|
||||
* @requires externalCommand composer
|
||||
*/
|
||||
class ComposerProjectTemplatesTest extends BuildTestBase {
|
||||
|
||||
/**
|
||||
* Get Composer items that we want to be path repos, from within a directory.
|
||||
*
|
||||
* @param string $workspace_directory
|
||||
* The full path to the workspace directory.
|
||||
* @param string $subdir
|
||||
* The subdirectory to search under composer/.
|
||||
*
|
||||
* @return string[]
|
||||
* Array of paths, indexed by package name.
|
||||
*/
|
||||
public function getPathReposForType($workspace_directory, $subdir) {
|
||||
// Find the Composer items that we want to be path repos.
|
||||
$path_repos = Finder::create()
|
||||
->files()
|
||||
->name('composer.json')
|
||||
->in($workspace_directory . '/composer/' . $subdir);
|
||||
|
||||
$data = [];
|
||||
/* @var $path_repo \SplFileInfo */
|
||||
foreach ($path_repos as $path_repo) {
|
||||
$json_file = new JsonFile($path_repo->getPathname());
|
||||
$json = $json_file->read();
|
||||
$data[$json['name']] = $path_repo->getPath();
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function provideTemplateCreateProject() {
|
||||
return [
|
||||
'recommended-project' => [
|
||||
'drupal/recommended-project',
|
||||
'composer/Template/RecommendedProject',
|
||||
'/web',
|
||||
],
|
||||
'legacy-project' => [
|
||||
'drupal/legacy-project',
|
||||
'composer/Template/LegacyProject',
|
||||
'',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure we've accounted for all the templates.
|
||||
*/
|
||||
public function testVerifyTemplateTestProviderIsAccurate() {
|
||||
$root = $this->getDrupalRoot();
|
||||
$data = $this->provideTemplateCreateProject($root);
|
||||
|
||||
// Find all the templates.
|
||||
$template_files = Finder::create()
|
||||
->files()
|
||||
->name('composer.json')
|
||||
->in($root . '/composer/Template');
|
||||
|
||||
$this->assertEquals(count($template_files), count($data));
|
||||
|
||||
// We could have the same number of templates but different names.
|
||||
$template_data = [];
|
||||
foreach ($data as $data_name => $data_value) {
|
||||
$template_data[$data_value[0]] = $data_name;
|
||||
}
|
||||
/* @var $file \SplFileInfo */
|
||||
foreach ($template_files as $file) {
|
||||
$json_file = new JsonFile($file->getPathname());
|
||||
$json = $json_file->read();
|
||||
$this->assertArrayHasKey('name', $json);
|
||||
// Does provideTemplateCreateProject() give us this template name?
|
||||
$this->assertArrayHasKey($json['name'], $template_data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideTemplateCreateProject
|
||||
*/
|
||||
public function testTemplateCreateProject($project, $package_dir, $docroot_dir) {
|
||||
$composer_version_line = exec('composer --version');
|
||||
if (strpos($composer_version_line, 'Composer version 2') !== FALSE) {
|
||||
// @todo Remove in https://www.drupal.org/project/drupal/issues/3128631
|
||||
$this->markTestSkipped("Composer 2 not supported for this test yet.");
|
||||
}
|
||||
|
||||
// Make a working COMPOSER_HOME directory for setting global composer config
|
||||
$composer_home = $this->getWorkspaceDirectory() . '/composer-home';
|
||||
mkdir($composer_home);
|
||||
|
||||
// Disable packagist globally (but only in our own custom COMPOSER_HOME).
|
||||
// It is necessary to do this globally rather than in our SUT composer.json
|
||||
// in order to ensure that Packagist is disabled during the
|
||||
// `composer create-project` command.
|
||||
$this->executeCommand("COMPOSER_HOME=$composer_home composer config --no-interaction --global repo.packagist false");
|
||||
$this->assertCommandSuccessful();
|
||||
|
||||
// Get the Drupal core version branch. For instance, this should be
|
||||
// 8.9.x-dev for the 8.9.x branch.
|
||||
$core_version = Composer::drupalVersionBranch();
|
||||
|
||||
// Create a "Composer"-type repository containing one entry for every
|
||||
// package in the vendor directory.
|
||||
$vendor_packages_path = $this->getWorkspaceDirectory() . '/vendor_packages/packages.json';
|
||||
$this->makeVendorPackage($vendor_packages_path);
|
||||
|
||||
// Make a copy of the code to alter.
|
||||
$this->copyCodebase();
|
||||
|
||||
// Remove the packages.drupal.org entry (and any other custom repository)
|
||||
// from the SUT's repositories section. There is no way to do this via
|
||||
// `composer config --unset`, so we read and rewrite composer.json.
|
||||
$composer_json_path = $this->getWorkspaceDirectory() . "/$package_dir/composer.json";
|
||||
$composer_json = json_decode(file_get_contents($composer_json_path), TRUE);
|
||||
unset($composer_json['repositories']);
|
||||
$json = json_encode($composer_json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
file_put_contents($composer_json_path, $json);
|
||||
|
||||
// Set up the template to use our path repos. Inclusion of metapackages is
|
||||
// reported differently, so we load up a separate set for them.
|
||||
$metapackage_path_repos = $this->getPathReposForType($this->getWorkspaceDirectory(), 'Metapackage');
|
||||
$path_repos = array_merge($metapackage_path_repos, $this->getPathReposForType($this->getWorkspaceDirectory(), 'Plugin'));
|
||||
// Always add drupal/core as a path repo.
|
||||
$path_repos['drupal/core'] = $this->getWorkspaceDirectory() . '/core';
|
||||
foreach ($path_repos as $name => $path) {
|
||||
$this->executeCommand("composer config --no-interaction repositories.$name path $path", $package_dir);
|
||||
$this->assertCommandSuccessful();
|
||||
}
|
||||
|
||||
$this->executeCommand("composer config --no-interaction repositories.local composer file://" . $vendor_packages_path, $package_dir);
|
||||
$this->assertCommandSuccessful();
|
||||
|
||||
$repository_path = $this->getWorkspaceDirectory() . '/test_repository/packages.json';
|
||||
$this->makeTestPackage($repository_path, $core_version);
|
||||
|
||||
$autoloader = $this->getWorkspaceDirectory() . '/testproject' . $docroot_dir . '/autoload.php';
|
||||
$this->assertFileNotExists($autoloader);
|
||||
|
||||
$this->executeCommand("COMPOSER_HOME=$composer_home COMPOSER_ROOT_VERSION=$core_version composer create-project --no-ansi $project testproject $core_version -s dev -vv --repository $repository_path");
|
||||
$this->assertCommandSuccessful();
|
||||
|
||||
// Ensure we used the project from our codebase.
|
||||
$this->assertErrorOutputContains("Installing $project ($core_version): Symlinking from $package_dir");
|
||||
// Ensure that we used drupal/core from our codebase. This probably means
|
||||
// that drupal/core-recommended was added successfully by the project.
|
||||
$this->assertErrorOutputContains("Installing drupal/core ($core_version): Symlinking from");
|
||||
// Verify that there is an autoloader. This is written by the scaffold
|
||||
// plugin, so its existence assures us that scaffolding happened.
|
||||
$this->assertFileExists($autoloader);
|
||||
|
||||
// In order to verify that Composer used the path repos for our project, we
|
||||
// have to get the requirements from the project composer.json so we can
|
||||
// reconcile our expectations.
|
||||
$template_json_file = $this->getWorkspaceDirectory() . '/' . $package_dir . '/composer.json';
|
||||
$this->assertFileExists($template_json_file);
|
||||
$json_file = new JsonFile($template_json_file);
|
||||
$template_json = $json_file->read();
|
||||
// Get the require and require-dev information, and ensure that our
|
||||
// requirements are not erroneously empty.
|
||||
$this->assertNotEmpty(
|
||||
$require = array_merge($template_json['require'] ?? [], $template_json['require-dev'] ?? [])
|
||||
);
|
||||
// Verify that path repo packages were installed.
|
||||
$path_repos = array_keys($path_repos);
|
||||
foreach (array_keys($require) as $package_name) {
|
||||
if (in_array($package_name, $path_repos)) {
|
||||
// Metapackages do not report that they were installed as symlinks, but
|
||||
// we still must check that their installed version matches
|
||||
// COMPOSER_CORE_VERSION.
|
||||
if (array_key_exists($package_name, $metapackage_path_repos)) {
|
||||
$this->assertErrorOutputContains("Installing $package_name ($core_version)");
|
||||
}
|
||||
else {
|
||||
$this->assertErrorOutputContains("Installing $package_name ($core_version): Symlinking from");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test package that points to the templates.
|
||||
*
|
||||
* @param string $repository_path
|
||||
* The path where to create the test package.
|
||||
* @param string $version
|
||||
* The version under test.
|
||||
*/
|
||||
protected function makeTestPackage($repository_path, $version) {
|
||||
$json = <<<JSON
|
||||
{
|
||||
"packages": {
|
||||
"drupal/recommended-project": {
|
||||
"$version": {
|
||||
"name": "drupal/recommended-project",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "composer/Template/RecommendedProject"
|
||||
},
|
||||
"type": "project",
|
||||
"version": "$version"
|
||||
}
|
||||
},
|
||||
"drupal/legacy-project": {
|
||||
"$version": {
|
||||
"name": "drupal/legacy-project",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "composer/Template/LegacyProject"
|
||||
},
|
||||
"type": "project",
|
||||
"version": "$version"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON;
|
||||
mkdir(dirname($repository_path));
|
||||
file_put_contents($repository_path, $json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test package that points to all the projects in vendor.
|
||||
*
|
||||
* @param string $repository_path
|
||||
* The path where to create the test package.
|
||||
*/
|
||||
protected function makeVendorPackage($repository_path) {
|
||||
$root = $this->getDrupalRoot();
|
||||
$process = $this->executeCommand("composer --working-dir=$root info --format=json");
|
||||
$this->assertCommandSuccessful();
|
||||
$installed = json_decode($process->getOutput(), TRUE);
|
||||
|
||||
// Build out package definitions for everything installed in
|
||||
// the vendor directory.
|
||||
$packages = [];
|
||||
foreach ($installed['installed'] as $project) {
|
||||
$name = $project['name'];
|
||||
$version = $project['version'];
|
||||
$path = "vendor/$name";
|
||||
$full_path = "$root/$path";
|
||||
// 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.
|
||||
if (is_dir($full_path)) {
|
||||
$packages['packages'][$name] = [
|
||||
$version => [
|
||||
"name" => $name,
|
||||
"dist" => [
|
||||
"type" => "path",
|
||||
"url" => $path,
|
||||
],
|
||||
"version" => $version,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$json = json_encode($packages, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
mkdir(dirname($repository_path));
|
||||
file_put_contents($repository_path, $json);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\BuildTests\Framework;
|
||||
|
||||
use Behat\Mink\Driver\Goutte\Client;
|
||||
use Behat\Mink\Driver\GoutteDriver;
|
||||
use Behat\Mink\Mink;
|
||||
use Behat\Mink\Session;
|
||||
use Drupal\Component\FileSystem\FileSystem as DrupalFilesystem;
|
||||
use Drupal\Tests\PhpunitCompatibilityTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\BrowserKit\Client as SymfonyClient;
|
||||
use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use Symfony\Component\Lock\Factory;
|
||||
use Symfony\Component\Lock\Store\FlockStore;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Provides a workspace to test build processes.
|
||||
*
|
||||
* If you need to build a file system and then run a command from the command
|
||||
* line then this is the test framework for you.
|
||||
*
|
||||
* Tests using this interface run in separate processes.
|
||||
*
|
||||
* Tests can perform HTTP requests against the assembled codebase.
|
||||
*
|
||||
* The results of these HTTP requests can be asserted using Mink.
|
||||
*
|
||||
* This framework does not use the same Mink extensions as BrowserTestBase.
|
||||
*
|
||||
* Features:
|
||||
* - Provide complete isolation between the test runner and the site under test.
|
||||
* - Provide a workspace where filesystem build processes can be performed.
|
||||
* - Allow for the use of PHP's build-in HTTP server to send requests to the
|
||||
* site built using the filesystem.
|
||||
* - Allow for commands and HTTP requests to be made to different subdirectories
|
||||
* of the workspace filesystem, to facilitate comparison between different
|
||||
* build results, and to support Composer builds which have an alternate
|
||||
* docroot.
|
||||
* - Provide as little framework as possible. Convenience methods should be
|
||||
* built into the test, or abstract base classes.
|
||||
* - Allow parallel testing, using random/unique port numbers for different HTTP
|
||||
* servers.
|
||||
* - Allow the use of PHPUnit-style (at)require annotations for external shell
|
||||
* commands.
|
||||
*
|
||||
* We don't use UiHelperInterface because it is too tightly integrated to
|
||||
* Drupal.
|
||||
*/
|
||||
abstract class BuildTestBase extends TestCase {
|
||||
|
||||
use ExternalCommandRequirementsTrait;
|
||||
use PhpunitCompatibilityTrait;
|
||||
|
||||
/**
|
||||
* The working directory where this test will manipulate files.
|
||||
*
|
||||
* Use getWorkspaceDirectory() to access this information.
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @see self::getWorkspaceDirectory()
|
||||
*/
|
||||
private $workspaceDir;
|
||||
|
||||
/**
|
||||
* The process that's running the HTTP server.
|
||||
*
|
||||
* @var \Symfony\Component\Process\Process
|
||||
*
|
||||
* @see self::standUpServer()
|
||||
* @see self::stopServer()
|
||||
*/
|
||||
private $serverProcess = NULL;
|
||||
|
||||
/**
|
||||
* Default to destroying build artifacts after a test finishes.
|
||||
*
|
||||
* Mainly useful for debugging.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $destroyBuild = TRUE;
|
||||
|
||||
/**
|
||||
* The docroot for the server process.
|
||||
*
|
||||
* This stores the last docroot directory used to start the server process. We
|
||||
* keep this information so we can restart the server if the desired docroot
|
||||
* changes.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $serverDocroot = NULL;
|
||||
|
||||
/**
|
||||
* Our native host name, used by PHP when it starts up the server.
|
||||
*
|
||||
* Requests should always be made to 'localhost', and not this IP address.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private static $hostName = '127.0.0.1';
|
||||
|
||||
/**
|
||||
* Port that will be tested.
|
||||
*
|
||||
* Generated internally. Use getPortNumber().
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $hostPort;
|
||||
|
||||
/**
|
||||
* A list of ports used by the test.
|
||||
*
|
||||
* Prevent the same process finding the same port by storing a list of ports
|
||||
* already discovered. This also stores locks so they are not released until
|
||||
* the test class is torn down.
|
||||
*
|
||||
* @var \Symfony\Component\Lock\LockInterface[]
|
||||
*/
|
||||
private $portLocks = [];
|
||||
|
||||
/**
|
||||
* The Mink session manager.
|
||||
*
|
||||
* @var \Behat\Mink\Mink
|
||||
*/
|
||||
private $mink;
|
||||
|
||||
/**
|
||||
* The most recent command process.
|
||||
*
|
||||
* @var \Symfony\Component\Process\Process
|
||||
*
|
||||
* @see ::executeCommand()
|
||||
*/
|
||||
private $commandProcess;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function setUpBeforeClass() {
|
||||
parent::setUpBeforeClass();
|
||||
static::checkClassCommandRequirements();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp() {
|
||||
parent::setUp();
|
||||
static::checkMethodCommandRequirements($this->getName());
|
||||
$this->phpFinder = new PhpExecutableFinder();
|
||||
// Set up the workspace directory.
|
||||
// @todo Glean working directory from env vars, etc.
|
||||
$fs = new SymfonyFilesystem();
|
||||
$this->workspaceDir = $fs->tempnam(DrupalFilesystem::getOsTemporaryDirectory(), '/build_workspace_' . md5($this->getName() . microtime(TRUE)));
|
||||
$fs->remove($this->workspaceDir);
|
||||
$fs->mkdir($this->workspaceDir);
|
||||
$this->initMink();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function tearDown() {
|
||||
parent::tearDown();
|
||||
|
||||
$this->stopServer();
|
||||
foreach ($this->portLocks as $lock) {
|
||||
$lock->release();
|
||||
}
|
||||
$ws = $this->getWorkspaceDirectory();
|
||||
$fs = new SymfonyFilesystem();
|
||||
if ($this->destroyBuild && $fs->exists($ws)) {
|
||||
// Filter out symlinks as chmod cannot alter them.
|
||||
$finder = new Finder();
|
||||
$finder->in($ws)
|
||||
->directories()
|
||||
->ignoreVCS(FALSE)
|
||||
->ignoreDotFiles(FALSE)
|
||||
// composer script is a symlink and fails chmod. Ignore it.
|
||||
->notPath('/^vendor\/bin\/composer$/');
|
||||
$fs->chmod($finder->getIterator(), 0775, 0000);
|
||||
$fs->remove($ws);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the working directory within the workspace, creating if necessary.
|
||||
*
|
||||
* @param string $working_dir
|
||||
* The path within the workspace directory.
|
||||
*
|
||||
* @return string
|
||||
* The full path to the working directory within the workspace directory.
|
||||
*/
|
||||
protected function getWorkingPath($working_dir = NULL) {
|
||||
$full_path = $this->getWorkspaceDirectory();
|
||||
if ($working_dir) {
|
||||
$full_path .= '/' . $working_dir;
|
||||
}
|
||||
if (!file_exists($full_path)) {
|
||||
$fs = new SymfonyFilesystem();
|
||||
$fs->mkdir($full_path);
|
||||
}
|
||||
return $full_path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the Mink session manager.
|
||||
*
|
||||
* @return \Behat\Mink\Session
|
||||
*/
|
||||
protected function initMink() {
|
||||
// If the Symfony BrowserKit client can followMetaRefresh(), we should use
|
||||
// the Goutte descendent instead of ours.
|
||||
if (method_exists(SymfonyClient::class, 'followMetaRefresh')) {
|
||||
$client = new Client();
|
||||
}
|
||||
else {
|
||||
$client = new DrupalMinkClient();
|
||||
}
|
||||
$client->followMetaRefresh(TRUE);
|
||||
$driver = new GoutteDriver($client);
|
||||
$session = new Session($driver);
|
||||
$this->mink = new Mink();
|
||||
$this->mink->registerSession('default', $session);
|
||||
$this->mink->setDefaultSessionName('default');
|
||||
$session->start();
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Mink instance.
|
||||
*
|
||||
* Use the Mink object to perform assertions against the content returned by a
|
||||
* request.
|
||||
*
|
||||
* @return \Behat\Mink\Mink
|
||||
* The Mink object.
|
||||
*/
|
||||
public function getMink() {
|
||||
return $this->mink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full path to the workspace where this test can build.
|
||||
*
|
||||
* This is often a directory within the system's temporary directory.
|
||||
*
|
||||
* @return string
|
||||
* Full path to the workspace where this test can build.
|
||||
*/
|
||||
public function getWorkspaceDirectory() {
|
||||
return $this->workspaceDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that text is present in the error output of the most recent command.
|
||||
*
|
||||
* @param string $expected
|
||||
* Text we expect to find in the error output of the command.
|
||||
*/
|
||||
public function assertErrorOutputContains($expected) {
|
||||
$this->assertStringContainsString($expected, $this->commandProcess->getErrorOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that text is present in the output of the most recent command.
|
||||
*
|
||||
* @param string $expected
|
||||
* Text we expect to find in the output of the command.
|
||||
*/
|
||||
public function assertCommandOutputContains($expected) {
|
||||
$this->assertStringContainsString($expected, $this->commandProcess->getOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the last command ran without error.
|
||||
*
|
||||
* This assertion checks whether the last command returned an exit code of 0.
|
||||
*
|
||||
* If you need to assert a different exit code, then you can use
|
||||
* executeCommand() and perform a different assertion on the process object.
|
||||
*/
|
||||
public function assertCommandSuccessful() {
|
||||
return $this->assertCommandExitCode(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the last command returned the specified exit code.
|
||||
*
|
||||
* @param int $expected_code
|
||||
* The expected process exit code.
|
||||
*/
|
||||
public function assertCommandExitCode($expected_code) {
|
||||
$this->assertEquals($expected_code, $this->commandProcess->getExitCode(),
|
||||
'COMMAND: ' . $this->commandProcess->getCommandLine() . "\n" .
|
||||
'OUTPUT: ' . $this->commandProcess->getOutput() . "\n" .
|
||||
'ERROR: ' . $this->commandProcess->getErrorOutput() . "\n"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command.
|
||||
*
|
||||
* @param string $command_line
|
||||
* A command line to run in an isolated process.
|
||||
* @param string $working_dir
|
||||
* (optional) A working directory relative to the workspace, within which to
|
||||
* execute the command. Defaults to the workspace directory.
|
||||
*
|
||||
* @return \Symfony\Component\Process\Process
|
||||
*/
|
||||
public function executeCommand($command_line, $working_dir = NULL) {
|
||||
$this->commandProcess = new Process($command_line);
|
||||
$this->commandProcess->setWorkingDirectory($this->getWorkingPath($working_dir))
|
||||
->setTimeout(300)
|
||||
->setIdleTimeout(300);
|
||||
$this->commandProcess->run();
|
||||
return $this->commandProcess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to assert that the last visit was a Drupal site.
|
||||
*
|
||||
* This method asserts that the X-Generator header shows that the site is a
|
||||
* Drupal site.
|
||||
*/
|
||||
public function assertDrupalVisit() {
|
||||
$this->getMink()->assertSession()->responseHeaderMatches('X-Generator', '/Drupal \d+ \(https:\/\/www.drupal.org\)/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit a URI on the HTTP server.
|
||||
*
|
||||
* The concept here is that there could be multiple potential docroots in the
|
||||
* workspace, so you can use whichever ones you want.
|
||||
*
|
||||
* @param string $request_uri
|
||||
* (optional) The non-host part of the URL. Example: /some/path?foo=bar.
|
||||
* Defaults to visiting the homepage.
|
||||
* @param string $working_dir
|
||||
* (optional) Relative path within the test workspace file system that will
|
||||
* be the docroot for the request. Defaults to the workspace directory.
|
||||
*
|
||||
* @return \Behat\Mink\Mink
|
||||
* The Mink object. Perform assertions against this.
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
* Thrown when $request_uri does not start with a slash.
|
||||
*/
|
||||
public function visit($request_uri = '/', $working_dir = NULL) {
|
||||
if ($request_uri[0] !== '/') {
|
||||
throw new \InvalidArgumentException('URI: ' . $request_uri . ' must be relative. Example: /some/path?foo=bar');
|
||||
}
|
||||
// Try to make a server.
|
||||
$this->standUpServer($working_dir);
|
||||
|
||||
$request = 'http://localhost:' . $this->getPortNumber() . $request_uri;
|
||||
$this->mink->getSession()->visit($request);
|
||||
return $this->mink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a local test server using PHP's internal HTTP server.
|
||||
*
|
||||
* Test authors should call visit() or assertVisit() instead.
|
||||
*
|
||||
* @param string|null $working_dir
|
||||
* (optional) Server docroot relative to the workspace file system. Defaults
|
||||
* to the workspace directory.
|
||||
*/
|
||||
protected function standUpServer($working_dir = NULL) {
|
||||
// If the user wants to test a new docroot, we have to shut down the old
|
||||
// server process and generate a new port number.
|
||||
if ($working_dir !== $this->serverDocroot && !empty($this->serverProcess)) {
|
||||
$this->stopServer();
|
||||
}
|
||||
// If there's not a server at this point, make one.
|
||||
if (!$this->serverProcess || $this->serverProcess->isTerminated()) {
|
||||
$this->serverProcess = $this->instantiateServer($this->getPortNumber(), $working_dir);
|
||||
if ($this->serverProcess) {
|
||||
$this->serverDocroot = $working_dir;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Do the work of making a server process.
|
||||
*
|
||||
* Test authors should call visit() or assertVisit() instead.
|
||||
*
|
||||
* When initializing the server, if '.ht.router.php' exists in the root, it is
|
||||
* leveraged. If testing with a version of Drupal before 8.5.x., this file
|
||||
* does not exist.
|
||||
*
|
||||
* @param int $port
|
||||
* The port number for the server.
|
||||
* @param string|null $working_dir
|
||||
* (optional) Server docroot relative to the workspace filesystem. Defaults
|
||||
* to the workspace directory.
|
||||
*
|
||||
* @return \Symfony\Component\Process\Process
|
||||
* The server process.
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
* Thrown if we were unable to start a web server.
|
||||
*/
|
||||
protected function instantiateServer($port, $working_dir = NULL) {
|
||||
$finder = new PhpExecutableFinder();
|
||||
$working_path = $this->getWorkingPath($working_dir);
|
||||
$server = [
|
||||
$finder->find(),
|
||||
'-S',
|
||||
self::$hostName . ':' . $port,
|
||||
'-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 < 1000; $i++) {
|
||||
if (!$this->checkPortIsAvailable($port)) {
|
||||
return $ps;
|
||||
}
|
||||
usleep(1000);
|
||||
}
|
||||
throw new \RuntimeException(sprintf("Unable to start the web server.\nERROR OUTPUT:\n%s", $ps->getErrorOutput()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the HTTP server, zero out all necessary variables.
|
||||
*/
|
||||
protected function stopServer() {
|
||||
if (!empty($this->serverProcess)) {
|
||||
$this->serverProcess->stop();
|
||||
}
|
||||
$this->serverProcess = NULL;
|
||||
$this->serverDocroot = NULL;
|
||||
$this->hostPort = NULL;
|
||||
$this->initMink();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover an available port number.
|
||||
*
|
||||
* @return int
|
||||
* The available port number that we discovered.
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
* Thrown when there are no available ports within the range.
|
||||
*/
|
||||
protected function findAvailablePort() {
|
||||
$store = new FlockStore(DrupalFilesystem::getOsTemporaryDirectory());
|
||||
$lock_factory = new Factory($store);
|
||||
|
||||
$counter = 100;
|
||||
while ($counter--) {
|
||||
// Limit to 9999 as higher ports cause random fails on DrupalCI.
|
||||
$port = random_int(1024, 9999);
|
||||
|
||||
if (isset($this->portLocks[$port])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Take a lock so that no other process can use the same port number even
|
||||
// if the server is yet to start.
|
||||
$lock = $lock_factory->createLock('drupal-build-test-port-' . $port);
|
||||
if ($lock->acquire()) {
|
||||
if ($this->checkPortIsAvailable($port)) {
|
||||
$this->portLocks[$port] = $lock;
|
||||
return $port;
|
||||
}
|
||||
else {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException('Unable to find a port available to run the web server.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a port is available.
|
||||
*
|
||||
* @param $port
|
||||
* A number between 1024 and 65536.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function checkPortIsAvailable($port) {
|
||||
$fp = @fsockopen(self::$hostName, $port, $errno, $errstr, 1);
|
||||
// If fsockopen() fails to connect, probably nothing is listening.
|
||||
// It could be a firewall but that's impossible to detect, so as a
|
||||
// best guess let's return it as available.
|
||||
if ($fp === FALSE) {
|
||||
return TRUE;
|
||||
}
|
||||
else {
|
||||
fclose($fp);
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the port number for requests.
|
||||
*
|
||||
* Test should never call this. Used by standUpServer().
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
protected function getPortNumber() {
|
||||
if (empty($this->hostPort)) {
|
||||
$this->hostPort = $this->findAvailablePort();
|
||||
}
|
||||
return $this->hostPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the current working codebase into a workspace.
|
||||
*
|
||||
* Use this method to copy the current codebase, including any patched
|
||||
* changes, into the workspace.
|
||||
*
|
||||
* By default, the copy will exclude sites/default/settings.php,
|
||||
* sites/default/files, and vendor/. Use the $iterator parameter to override
|
||||
* this behavior.
|
||||
*
|
||||
* @param \Iterator|null $iterator
|
||||
* (optional) An iterator of all the files to copy. Default behavior is to
|
||||
* exclude site-specific directories and files.
|
||||
* @param string|null $working_dir
|
||||
* (optional) Relative path within the test workspace file system that will
|
||||
* contain the copy of the codebase. Defaults to the workspace directory.
|
||||
*/
|
||||
public function copyCodebase(\Iterator $iterator = NULL, $working_dir = NULL) {
|
||||
$working_path = $this->getWorkingPath($working_dir);
|
||||
|
||||
if ($iterator === NULL) {
|
||||
$iterator = $this->getCodebaseFinder()->getIterator();
|
||||
}
|
||||
|
||||
$fs = new SymfonyFilesystem();
|
||||
$options = ['override' => TRUE, 'delete' => FALSE];
|
||||
$fs->mirror($this->getDrupalRoot(), $working_path, $iterator, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a default Finder object for a Drupal codebase.
|
||||
*
|
||||
* This method can be used two ways:
|
||||
* - Override this method and provide your own default Finder object for
|
||||
* copyCodebase().
|
||||
* - Call the method to get a default Finder object which can then be
|
||||
* modified for other purposes.
|
||||
*
|
||||
* @return \Symfony\Component\Finder\Finder
|
||||
* A Finder object ready to iterate over core codebase.
|
||||
*/
|
||||
public function getCodebaseFinder() {
|
||||
$finder = new Finder();
|
||||
$finder->files()
|
||||
->ignoreUnreadableDirs()
|
||||
->in($this->getDrupalRoot())
|
||||
->notPath('#^sites/default/files#')
|
||||
->notPath('#^sites/simpletest#')
|
||||
->notPath('#^vendor#')
|
||||
->notPath('#^sites/default/settings\..*php#')
|
||||
->ignoreDotFiles(FALSE)
|
||||
->ignoreVCS(FALSE);
|
||||
return $finder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the root path of this Drupal codebase.
|
||||
*
|
||||
* @return string
|
||||
* The full path to the root of this Drupal codebase.
|
||||
*/
|
||||
protected function getDrupalRoot() {
|
||||
return realpath(dirname(__DIR__, 5));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\BuildTests\Framework;
|
||||
|
||||
use Behat\Mink\Driver\Goutte\Client;
|
||||
use Symfony\Component\BrowserKit\Client as SymfonyClient;
|
||||
|
||||
/**
|
||||
* Extend the Mink client for Drupal use-cases.
|
||||
*
|
||||
* This is adapted from https://github.com/symfony/symfony/pull/27118.
|
||||
*
|
||||
* @todo Update this client when Drupal starts using Symfony 4.2.0+.
|
||||
* https://www.drupal.org/project/drupal/issues/3077785
|
||||
*/
|
||||
class DrupalMinkClient extends Client {
|
||||
|
||||
/**
|
||||
* Whether to follow meta redirects or not.
|
||||
*
|
||||
* @var bool
|
||||
*
|
||||
* @see \Drupal\BuildTests\Framework\DrupalMinkClient::followMetaRefresh()
|
||||
*/
|
||||
protected $followMetaRefresh;
|
||||
|
||||
/**
|
||||
* Sets whether to automatically follow meta refresh redirects or not.
|
||||
*
|
||||
* @param bool $followMetaRefresh
|
||||
* (optional) Whether to follow meta redirects. Defaults to TRUE.
|
||||
*/
|
||||
public function followMetaRefresh(bool $followMetaRefresh = TRUE) {
|
||||
$this->followMetaRefresh = $followMetaRefresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Glean the meta refresh URL from the current page content.
|
||||
*
|
||||
* @return string|null
|
||||
* Either the redirect URL that was found, or NULL if none was found.
|
||||
*/
|
||||
private function getMetaRefreshUrl() {
|
||||
$metaRefresh = $this->getCrawler()->filter('meta[http-equiv="Refresh"], meta[http-equiv="refresh"]');
|
||||
foreach ($metaRefresh->extract(['content']) as $content) {
|
||||
if (preg_match('/^\s*0\s*;\s*URL\s*=\s*(?|\'([^\']++)|"([^"]++)|([^\'"].*))/i', $content, $m)) {
|
||||
return str_replace("\t\r\n", '', rtrim($m[1]));
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function request($method, $uri, array $parameters = [], array $files = [], array $server = [], $content = NULL, $changeHistory = TRUE) {
|
||||
$this->crawler = parent::request($method, $uri, $parameters, $files, $server, $content, $changeHistory);
|
||||
// Check for meta refresh redirect and follow it.
|
||||
if ($this->followMetaRefresh && NULL !== $redirect = $this->getMetaRefreshUrl()) {
|
||||
$this->redirect = $redirect;
|
||||
// $this->redirects is private on the BrowserKit client, so we have to use
|
||||
// reflection to manage the redirects stack.
|
||||
$ref_redirects = new \ReflectionProperty(SymfonyClient::class, 'redirects');
|
||||
$ref_redirects->setAccessible(TRUE);
|
||||
$redirects = $ref_redirects->getValue($this);
|
||||
$redirects[serialize($this->history->current())] = TRUE;
|
||||
$ref_redirects->setValue($this, $redirects);
|
||||
|
||||
$this->crawler = $this->followRedirect();
|
||||
}
|
||||
return $this->crawler;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\BuildTests\Framework;
|
||||
|
||||
use PHPUnit\Framework\SkippedTestError;
|
||||
use PHPUnit\Util\Test;
|
||||
use Symfony\Component\Process\ExecutableFinder;
|
||||
|
||||
/**
|
||||
* Allows test classes to require external command line applications.
|
||||
*
|
||||
* Use annotation such as '(at)requires externalCommand git'.
|
||||
*/
|
||||
trait ExternalCommandRequirementsTrait {
|
||||
|
||||
/**
|
||||
* A list of existing external commands we've already discovered.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private static $existingCommands = [];
|
||||
|
||||
/**
|
||||
* Checks whether required external commands are available per test class.
|
||||
*
|
||||
* @throws \PHPUnit\Framework\SkippedTestError
|
||||
* Thrown when the requirements are not met, and this test should be
|
||||
* skipped. Callers should not catch this exception.
|
||||
*/
|
||||
private static function checkClassCommandRequirements() {
|
||||
$annotations = Test::parseTestMethodAnnotations(static::class);
|
||||
if (!empty($annotations['class']['requires'])) {
|
||||
static::checkExternalCommandRequirements($annotations['class']['requires']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether required external commands are available per method.
|
||||
*
|
||||
* @throws \PHPUnit\Framework\SkippedTestError
|
||||
* Thrown when the requirements are not met, and this test should be
|
||||
* skipped. Callers should not catch this exception.
|
||||
*/
|
||||
private static function checkMethodCommandRequirements($name) {
|
||||
$annotations = Test::parseTestMethodAnnotations(static::class, $name);
|
||||
if (!empty($annotations['method']['requires'])) {
|
||||
static::checkExternalCommandRequirements($annotations['method']['requires']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks missing external command requirements.
|
||||
*
|
||||
* @param string[] $annotations
|
||||
* A list of requires annotations from either a method or class annotation.
|
||||
*
|
||||
* @throws \PHPUnit\Framework\SkippedTestError
|
||||
* Thrown when the requirements are not met, and this test should be
|
||||
* skipped. Callers should not catch this exception.
|
||||
*/
|
||||
private static function checkExternalCommandRequirements(array $annotations) {
|
||||
// Make a list of required commands.
|
||||
$required_commands = [];
|
||||
foreach ($annotations as $requirement) {
|
||||
if (strpos($requirement, 'externalCommand ') === 0) {
|
||||
$command = trim(str_replace('externalCommand ', '', $requirement));
|
||||
// Use named keys to avoid duplicates.
|
||||
$required_commands[$command] = $command;
|
||||
}
|
||||
}
|
||||
|
||||
// Figure out which commands are not available.
|
||||
$unavailable = [];
|
||||
foreach ($required_commands as $required_command) {
|
||||
if (!in_array($required_command, self::$existingCommands)) {
|
||||
if (static::externalCommandIsAvailable($required_command)) {
|
||||
// Cache existing commands so we don't have to ask again.
|
||||
self::$existingCommands[] = $required_command;
|
||||
}
|
||||
else {
|
||||
$unavailable[] = $required_command;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip the test if there were some we couldn't find.
|
||||
if (!empty($unavailable)) {
|
||||
throw new SkippedTestError('Required external commands: ' . implode(', ', $unavailable));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an external command is available.
|
||||
*
|
||||
* @param $command
|
||||
* The external command.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if external command is available, else FALSE.
|
||||
*/
|
||||
private static function externalCommandIsAvailable($command) {
|
||||
$finder = new ExecutableFinder();
|
||||
return (bool) $finder->find($command);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\BuildTests\Framework\Tests;
|
||||
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use org\bovigo\vfs\vfsStream;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \Drupal\BuildTests\Framework\BuildTestBase
|
||||
* @group Build
|
||||
*/
|
||||
class BuildTestTest extends BuildTestBase {
|
||||
|
||||
/**
|
||||
* Ensure that workspaces work.
|
||||
*/
|
||||
public function testWorkspace() {
|
||||
$test_directory = 'test_directory';
|
||||
|
||||
// Execute an empty command through the shell to build out a working
|
||||
// directory.
|
||||
$process = $this->executeCommand('', $test_directory);
|
||||
$this->assertCommandSuccessful();
|
||||
|
||||
// Assert that our working directory exists and is in use by the process.
|
||||
$workspace = $this->getWorkspaceDirectory();
|
||||
$working_path = $workspace . '/' . $test_directory;
|
||||
$this->assertDirectoryExists($working_path);
|
||||
$this->assertEquals($working_path, $process->getWorkingDirectory());
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers ::copyCodebase
|
||||
*/
|
||||
public function testCopyCodebase() {
|
||||
$test_directory = 'copied_codebase';
|
||||
$this->copyCodebase(NULL, $test_directory);
|
||||
$full_path = $this->getWorkspaceDirectory() . '/' . $test_directory;
|
||||
$files = [
|
||||
'autoload.php',
|
||||
'composer.json',
|
||||
'index.php',
|
||||
'README.txt',
|
||||
'.git',
|
||||
'.ht.router.php',
|
||||
];
|
||||
foreach ($files as $file) {
|
||||
$this->assertFileExists($full_path . '/' . $file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we're not copying directories we wish to exclude.
|
||||
*
|
||||
* @covers ::copyCodebase
|
||||
*/
|
||||
public function testCopyCodebaseExclude() {
|
||||
// Create a virtual file system containing items that should be
|
||||
// excluded. Exception being modules directory.
|
||||
vfsStream::setup('drupal', NULL, [
|
||||
'sites' => [
|
||||
'default' => [
|
||||
'files' => [
|
||||
'a_file.txt' => 'some file.',
|
||||
],
|
||||
'settings.php' => '<?php $settings = stuff;',
|
||||
'settings.local.php' => '<?php $settings = override;',
|
||||
],
|
||||
'simpletest' => [
|
||||
'simpletest_hash' => [
|
||||
'some_results.xml' => '<xml/>',
|
||||
],
|
||||
],
|
||||
],
|
||||
'vendor' => [
|
||||
'composer' => [
|
||||
'composer' => [
|
||||
'installed.json' => '"items": {"things"}',
|
||||
],
|
||||
],
|
||||
],
|
||||
'modules' => [
|
||||
'my_module' => [
|
||||
'vendor' => [
|
||||
'my_vendor' => [
|
||||
'composer.json' => "{\n}",
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Mock BuildTestBase so that it thinks our VFS is the Drupal root.
|
||||
/** @var \PHPUnit\Framework\MockObject\MockBuilder|\Drupal\BuildTests\Framework\BuildTestBase $base */
|
||||
$base = $this->getMockBuilder(BuildTestBase::class)
|
||||
->setMethods(['getDrupalRoot'])
|
||||
->getMockForAbstractClass();
|
||||
$base->expects($this->exactly(2))
|
||||
->method('getDrupalRoot')
|
||||
->willReturn(vfsStream::url('drupal'));
|
||||
|
||||
$base->setUp();
|
||||
|
||||
// Perform the copy.
|
||||
$test_directory = 'copied_codebase';
|
||||
$base->copyCodebase(NULL, $test_directory);
|
||||
$full_path = $base->getWorkspaceDirectory() . '/' . $test_directory;
|
||||
|
||||
$this->assertDirectoryExists($full_path);
|
||||
|
||||
// Verify nested vendor directory was not excluded. Then remove it for next
|
||||
// validation.
|
||||
$this->assertFileExists($full_path . DIRECTORY_SEPARATOR . 'modules/my_module/vendor/my_vendor/composer.json');
|
||||
$file_system = new Filesystem();
|
||||
$file_system->remove($full_path . DIRECTORY_SEPARATOR . 'modules');
|
||||
|
||||
// Use scandir() to determine if our target directory is empty. It should
|
||||
// only contain the system dot directories.
|
||||
$this->assertTrue(
|
||||
($files = @scandir($full_path)) && count($files) <= 2,
|
||||
'Directory is not empty: ' . implode(', ', $files)
|
||||
);
|
||||
|
||||
$base->tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers ::findAvailablePort
|
||||
*/
|
||||
public function testPortMany() {
|
||||
$iterator = (new Finder())->in($this->getDrupalRoot())
|
||||
->ignoreDotFiles(FALSE)
|
||||
->exclude(['sites/simpletest'])
|
||||
->path('/^.ht.router.php$/')
|
||||
->getIterator();
|
||||
$this->copyCodebase($iterator);
|
||||
/** @var \Symfony\Component\Process\Process[] $processes */
|
||||
$processes = [];
|
||||
$count = 15;
|
||||
for ($i = 0; $i <= $count; $i++) {
|
||||
$port = $this->findAvailablePort();
|
||||
$this->assertArrayNotHasKey($port, $processes, 'Port ' . $port . ' was already in use by a process.');
|
||||
$processes[$port] = $this->instantiateServer($port);
|
||||
$this->assertNotEmpty($processes[$port]);
|
||||
$this->assertTrue($processes[$port]->isRunning(), 'Process on port ' . $port . ' is not still running.');
|
||||
$this->assertFalse($this->checkPortIsAvailable($port));
|
||||
}
|
||||
|
||||
// Clean up after ourselves.
|
||||
foreach ($processes as $process) {
|
||||
$process->stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers ::standUpServer
|
||||
*/
|
||||
public function testStandUpServer() {
|
||||
// Stand up a server with working directory 'first'.
|
||||
$this->standUpServer('first');
|
||||
|
||||
// Get the process object for the server.
|
||||
$ref_process = new \ReflectionProperty(parent::class, 'serverProcess');
|
||||
$ref_process->setAccessible(TRUE);
|
||||
$first_process = $ref_process->getValue($this);
|
||||
|
||||
// Standing up the server again should not change the server process.
|
||||
$this->standUpServer('first');
|
||||
$this->assertSame($first_process, $ref_process->getValue($this));
|
||||
|
||||
// Standing up the server with working directory 'second' should give us a
|
||||
// new server process.
|
||||
$this->standUpServer('second');
|
||||
$this->assertNotSame(
|
||||
$first_process,
|
||||
$second_process = $ref_process->getValue($this)
|
||||
);
|
||||
|
||||
// And even with the original working directory name, we should get a new
|
||||
// server process.
|
||||
$this->standUpServer('first');
|
||||
$this->assertNotSame($first_process, $ref_process->getValue($this));
|
||||
$this->assertNotSame($second_process, $ref_process->getValue($this));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\BuildTests\Framework\Tests;
|
||||
|
||||
use Drupal\BuildTests\Framework\DrupalMinkClient;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\BrowserKit\Response;
|
||||
|
||||
/**
|
||||
* Test \Drupal\BuildTests\Framework\DrupalMinkClient.
|
||||
*
|
||||
* This test is adapted from \Symfony\Component\BrowserKit\Tests\ClientTest.
|
||||
*
|
||||
* @coversDefaultClass \Drupal\BuildTests\Framework\DrupalMinkClient
|
||||
*
|
||||
* @group Build
|
||||
*/
|
||||
class DrupalMinkClientTest extends TestCase {
|
||||
|
||||
/**
|
||||
* @dataProvider getTestsForMetaRefresh
|
||||
* @covers ::getMetaRefreshUrl
|
||||
*/
|
||||
public function testFollowMetaRefresh(string $content, string $expectedEndingUrl, bool $followMetaRefresh = TRUE) {
|
||||
$client = new TestClient();
|
||||
$client->followMetaRefresh($followMetaRefresh);
|
||||
$client->setNextResponse(new Response($content));
|
||||
$client->request('GET', 'http://www.example.com/foo/foobar');
|
||||
$this->assertEquals($expectedEndingUrl, $client->getRequest()->getUri());
|
||||
}
|
||||
|
||||
public function getTestsForMetaRefresh() {
|
||||
return [
|
||||
['<html><head><meta http-equiv="Refresh" content="4" /><meta http-equiv="refresh" content="0; URL=http://www.example.com/redirected"/></head></html>', 'http://www.example.com/redirected'],
|
||||
['<html><head><meta http-equiv="refresh" content="0;URL=http://www.example.com/redirected"/></head></html>', 'http://www.example.com/redirected'],
|
||||
['<html><head><meta http-equiv="refresh" content="0;URL=\'http://www.example.com/redirected\'"/></head></html>', 'http://www.example.com/redirected'],
|
||||
['<html><head><meta http-equiv="refresh" content=\'0;URL="http://www.example.com/redirected"\'/></head></html>', 'http://www.example.com/redirected'],
|
||||
['<html><head><meta http-equiv="refresh" content="0; URL = http://www.example.com/redirected"/></head></html>', 'http://www.example.com/redirected'],
|
||||
['<html><head><meta http-equiv="refresh" content="0;URL= http://www.example.com/redirected "/></head></html>', 'http://www.example.com/redirected'],
|
||||
['<html><head><meta http-equiv="refresh" content="0;url=http://www.example.com/redirected "/></head></html>', 'http://www.example.com/redirected'],
|
||||
['<html><head><noscript><meta http-equiv="refresh" content="0;URL=http://www.example.com/redirected"/></noscript></head></head></html>', 'http://www.example.com/redirected'],
|
||||
// Non-zero timeout should not result in a redirect.
|
||||
['<html><head><meta http-equiv="refresh" content="4; URL=http://www.example.com/redirected"/></head></html>', 'http://www.example.com/foo/foobar'],
|
||||
['<html><body></body></html>', 'http://www.example.com/foo/foobar'],
|
||||
// HTML 5 allows the meta tag to be placed in head or body.
|
||||
['<html><body><meta http-equiv="refresh" content="0;url=http://www.example.com/redirected"/></body></html>', 'http://www.example.com/redirected'],
|
||||
// Valid meta refresh should not be followed if disabled.
|
||||
['<html><head><meta http-equiv="refresh" content="0;URL=http://www.example.com/redirected"/></head></html>', 'http://www.example.com/foo/foobar', FALSE],
|
||||
'drupal-1' => ['<html><head><meta http-equiv="Refresh" content="0; URL=/update.php/start?id=2&op=do_nojs" /></body></html>', 'http://www.example.com/update.php/start?id=2&op=do_nojs'],
|
||||
'drupal-2' => ['<html><head><noscript><meta http-equiv="Refresh" content="0; URL=/update.php/start?id=2&op=do_nojs" /></noscript></body></html>', 'http://www.example.com/update.php/start?id=2&op=do_nojs'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers ::request
|
||||
*/
|
||||
public function testBackForwardMetaRefresh() {
|
||||
$client = new TestClient();
|
||||
$client->followMetaRefresh();
|
||||
|
||||
// First request.
|
||||
$client->request('GET', 'http://www.example.com/first-page');
|
||||
|
||||
$content = '<html><head><meta http-equiv="Refresh" content="0; URL=/refreshed" /></body></html>';
|
||||
$client->setNextResponse(new Response($content, 200));
|
||||
$client->request('GET', 'http://www.example.com/refresh-from-here');
|
||||
|
||||
$this->assertEquals('http://www.example.com/refreshed', $client->getRequest()->getUri());
|
||||
|
||||
$client->back();
|
||||
$this->assertEquals('http://www.example.com/first-page', $client->getRequest()->getUri());
|
||||
|
||||
$client->forward();
|
||||
$this->assertEquals('http://www.example.com/refreshed', $client->getRequest()->getUri());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Special client that can return a given response on the first doRequest().
|
||||
*/
|
||||
class TestClient extends DrupalMinkClient {
|
||||
|
||||
protected $nextResponse = NULL;
|
||||
|
||||
public function setNextResponse(Response $response) {
|
||||
$this->nextResponse = $response;
|
||||
}
|
||||
|
||||
protected function doRequest($request) {
|
||||
if (NULL === $this->nextResponse) {
|
||||
return new Response();
|
||||
}
|
||||
|
||||
$response = $this->nextResponse;
|
||||
$this->nextResponse = NULL;
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\BuildTests\Framework\Tests;
|
||||
|
||||
use Drupal\BuildTests\Framework\ExternalCommandRequirementsTrait;
|
||||
use PHPUnit\Framework\SkippedTestError;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \Drupal\BuildTests\Framework\ExternalCommandRequirementsTrait
|
||||
* @group Build
|
||||
*/
|
||||
class ExternalCommandRequirementTest extends TestCase {
|
||||
|
||||
/**
|
||||
* @covers ::checkExternalCommandRequirements
|
||||
*/
|
||||
public function testCheckExternalCommandRequirementsNotAvailable() {
|
||||
$requires = new UsesCommandRequirements();
|
||||
$ref_check_requirements = new \ReflectionMethod($requires, 'checkExternalCommandRequirements');
|
||||
$ref_check_requirements->setAccessible(TRUE);
|
||||
|
||||
// Use a try/catch block because otherwise PHPUnit might think this test is
|
||||
// legitimately skipped.
|
||||
try {
|
||||
$ref_check_requirements->invokeArgs($requires, [
|
||||
['externalCommand not_available', 'externalCommand available_command'],
|
||||
]);
|
||||
$this->fail('Unavailable external command requirement should throw a skipped test error exception.');
|
||||
}
|
||||
catch (SkippedTestError $exception) {
|
||||
$this->assertEquals('Required external commands: not_available', $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers ::checkExternalCommandRequirements
|
||||
*/
|
||||
public function testCheckExternalCommandRequirementsAvailable() {
|
||||
$requires = new UsesCommandRequirements();
|
||||
$ref_check_requirements = new \ReflectionMethod($requires, 'checkExternalCommandRequirements');
|
||||
$ref_check_requirements->setAccessible(TRUE);
|
||||
|
||||
// Use a try/catch block because otherwise PHPUnit might think this test is
|
||||
// legitimately skipped.
|
||||
try {
|
||||
$this->assertNull(
|
||||
$ref_check_requirements->invokeArgs($requires, [['externalCommand available_command']])
|
||||
);
|
||||
}
|
||||
catch (SkippedTestError $exception) {
|
||||
$this->fail(sprintf('The external command should be available: %s', $exception->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers ::checkClassCommandRequirements
|
||||
*/
|
||||
public function testClassRequiresAvailable() {
|
||||
$requires = new ClassRequiresAvailable();
|
||||
$ref_check = new \ReflectionMethod($requires, 'checkClassCommandRequirements');
|
||||
$ref_check->setAccessible(TRUE);
|
||||
// Use a try/catch block because otherwise PHPUnit might think this test is
|
||||
// legitimately skipped.
|
||||
try {
|
||||
$this->assertNull($ref_check->invoke($requires));
|
||||
}
|
||||
catch (SkippedTestError $exception) {
|
||||
$this->fail(sprintf('The external command should be available: %s', $exception->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers ::checkClassCommandRequirements
|
||||
*/
|
||||
public function testClassRequiresUnavailable() {
|
||||
$requires = new ClassRequiresUnavailable();
|
||||
$ref_check = new \ReflectionMethod($requires, 'checkClassCommandRequirements');
|
||||
$ref_check->setAccessible(TRUE);
|
||||
// Use a try/catch block because otherwise PHPUnit might think this test is
|
||||
// legitimately skipped.
|
||||
try {
|
||||
$this->assertNull($ref_check->invoke($requires));
|
||||
$this->fail('Unavailable external command requirement should throw a skipped test error exception.');
|
||||
}
|
||||
catch (SkippedTestError $exception) {
|
||||
$this->assertEquals('Required external commands: unavailable_command', $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers ::checkMethodCommandRequirements
|
||||
*/
|
||||
public function testMethodRequiresAvailable() {
|
||||
$requires = new MethodRequires();
|
||||
$ref_check = new \ReflectionMethod($requires, 'checkMethodCommandRequirements');
|
||||
$ref_check->setAccessible(TRUE);
|
||||
// Use a try/catch block because otherwise PHPUnit might think this test is
|
||||
// legitimately skipped.
|
||||
try {
|
||||
$this->assertNull($ref_check->invoke($requires, 'testRequiresAvailable'));
|
||||
}
|
||||
catch (SkippedTestError $exception) {
|
||||
$this->fail(sprintf('The external command should be available: %s', $exception->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers ::checkMethodCommandRequirements
|
||||
*/
|
||||
public function testMethodRequiresUnavailable() {
|
||||
$requires = new MethodRequires();
|
||||
$ref_check = new \ReflectionMethod($requires, 'checkMethodCommandRequirements');
|
||||
$ref_check->setAccessible(TRUE);
|
||||
// Use a try/catch block because otherwise PHPUnit might think this test is
|
||||
// legitimately skipped.
|
||||
try {
|
||||
$this->assertNull($ref_check->invoke($requires, 'testRequiresUnavailable'));
|
||||
$this->fail('Unavailable external command requirement should throw a skipped test error exception.');
|
||||
}
|
||||
catch (SkippedTestError $exception) {
|
||||
$this->assertEquals('Required external commands: unavailable_command', $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class UsesCommandRequirements {
|
||||
|
||||
use ExternalCommandRequirementsTrait;
|
||||
|
||||
protected static function externalCommandIsAvailable($command) {
|
||||
return in_array($command, ['available_command']);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @requires externalCommand available_command
|
||||
*/
|
||||
class ClassRequiresAvailable {
|
||||
|
||||
use ExternalCommandRequirementsTrait;
|
||||
|
||||
protected static function externalCommandIsAvailable($command) {
|
||||
return in_array($command, ['available_command']);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @requires externalCommand unavailable_command
|
||||
*/
|
||||
class ClassRequiresUnavailable {
|
||||
|
||||
use ExternalCommandRequirementsTrait;
|
||||
|
||||
}
|
||||
|
||||
class MethodRequires {
|
||||
|
||||
use ExternalCommandRequirementsTrait;
|
||||
|
||||
/**
|
||||
* @requires externalCommand available_command
|
||||
*/
|
||||
public function testRequiresAvailable() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @requires externalCommand unavailable_command
|
||||
*/
|
||||
public function testRequiresUnavailable() {
|
||||
|
||||
}
|
||||
|
||||
protected static function externalCommandIsAvailable($command) {
|
||||
return in_array($command, ['available_command']);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\BuildTests\Framework\Tests;
|
||||
|
||||
use Drupal\BuildTests\QuickStart\QuickStartTestBase;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \Drupal\BuildTests\Framework\BuildTestBase
|
||||
* @group Build
|
||||
*/
|
||||
class HtRouterTest extends QuickStartTestBase {
|
||||
|
||||
/**
|
||||
* @covers ::instantiateServer
|
||||
*/
|
||||
public function testHtRouter() {
|
||||
$this->copyCodebase();
|
||||
$this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer install --no-dev --no-interaction');
|
||||
$this->assertErrorOutputContains('Generating autoload files');
|
||||
$this->installQuickStart('minimal');
|
||||
$this->formLogin($this->adminUsername, $this->adminPassword);
|
||||
$this->visit('/.well-known/change-password');
|
||||
$this->assertDrupalVisit();
|
||||
$url = $this->getMink()->getSession()->getCurrentUrl();
|
||||
$this->assertEquals('http://localhost:' . $this->getPortNumber() . '/user/1/edit', $url);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\BuildTests\QuickStart;
|
||||
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
/**
|
||||
* Helper methods for using the quickstart feature of Drupal.
|
||||
*/
|
||||
abstract class QuickStartTestBase extends BuildTestBase {
|
||||
|
||||
/**
|
||||
* User name of the admin account generated during install.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $adminUsername;
|
||||
|
||||
/**
|
||||
* Password of the admin account generated during install.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $adminPassword;
|
||||
|
||||
/**
|
||||
* Install a Drupal site using the quick start feature.
|
||||
*
|
||||
* @param string $profile
|
||||
* Drupal profile to install.
|
||||
* @param string $working_dir
|
||||
* (optional) A working directory relative to the workspace, within which to
|
||||
* execute the command. Defaults to the workspace directory.
|
||||
*/
|
||||
public function installQuickStart($profile, $working_dir = NULL) {
|
||||
$php_finder = new PhpExecutableFinder();
|
||||
$install_process = $this->executeCommand($php_finder->find() . ' ./core/scripts/drupal install ' . $profile, $working_dir);
|
||||
$this->assertCommandOutputContains('Username:');
|
||||
preg_match('/Username: (.+)\vPassword: (.+)/', $install_process->getOutput(), $matches);
|
||||
$this->assertNotEmpty($this->adminUsername = $matches[1]);
|
||||
$this->assertNotEmpty($this->adminPassword = $matches[2]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that uses Drupal's user/login form to log in.
|
||||
*
|
||||
* @param string $username
|
||||
* Username.
|
||||
* @param string $password
|
||||
* Password.
|
||||
* @param string $working_dir
|
||||
* (optional) A working directory within which to login. Defaults to the
|
||||
* workspace directory.
|
||||
*/
|
||||
public function formLogin($username, $password, $working_dir = NULL) {
|
||||
$this->visit('/user/login', $working_dir);
|
||||
$assert = $this->getMink()->assertSession();
|
||||
$assert->statusCodeEquals(200);
|
||||
$assert->fieldExists('edit-name')->setValue($username);
|
||||
$assert->fieldExists('edit-pass')->setValue($password);
|
||||
$session = $this->getMink()->getSession();
|
||||
$session->getPage()->findButton('Log in')->submit();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user