Unverified Commit 9ac4d544 authored by larowlan's avatar larowlan

Issue #2926633 by alexpott, dawehner, Mile23, mglaman, Wim Leers, jibran,...

Issue #2926633 by alexpott, dawehner, Mile23, mglaman, Wim Leers, jibran, larowlan, Lendude, Berdir: Provide a script to install a Drupal testsite
parent d6ae6b4d
......@@ -662,11 +662,11 @@ protected function prepareEnvironment() {
* An array of available database driver installer objects.
*/
protected function getDatabaseTypes() {
if ($this->originalContainer) {
if (isset($this->originalContainer) && $this->originalContainer) {
\Drupal::setContainer($this->originalContainer);
}
$database_types = drupal_get_database_types();
if ($this->originalContainer) {
if (isset($this->originalContainer) && $this->originalContainer) {
\Drupal::unsetContainer();
}
return $database_types;
......
......@@ -62,13 +62,17 @@ public static function getConnection() {
*
* @param string|null $db_prefix
* If not provided a new test lock is generated.
* @param bool $create_lock
* (optional) Whether or not to create a lock file. Defaults to FALSE. If
* the environment variable RUN_TESTS_CONCURRENCY is greater than 1 it will
* be overridden to TRUE regardless of its initial value.
*
* @throws \InvalidArgumentException
* Thrown when $db_prefix does not match the regular expression.
*/
public function __construct($db_prefix = NULL) {
public function __construct($db_prefix = NULL, $create_lock = FALSE) {
if ($db_prefix === NULL) {
$this->lockId = $this->getTestLock();
$this->lockId = $this->getTestLock($create_lock);
$this->databasePrefix = 'test' . $this->lockId;
}
else {
......@@ -107,31 +111,49 @@ public function getDatabasePrefix() {
/**
* Generates a unique lock ID for the test method.
*
* @param bool $create_lock
* (optional) Whether or not to create a lock file. Defaults to FALSE.
*
* @return int
* The unique lock ID for the test method.
*/
protected function getTestLock() {
// Ensure that the generated lock ID is not in use, which may happen when
// tests are run concurrently.
protected function getTestLock($create_lock = FALSE) {
// There is a risk that the generated random number is a duplicate. This
// would cause different tests to try to use the same database prefix.
// Therefore, if running with a concurrency of greater than 1, we need to
// create a lock.
if (getenv('RUN_TESTS_CONCURRENCY') > 1) {
$create_lock = TRUE;
}
do {
$lock_id = mt_rand(10000000, 99999999);
// If we're only running with a concurrency of 1 there's no need to create
// a test lock file as there is no chance of the random number generated
// clashing.
if (getenv('RUN_TESTS_CONCURRENCY') > 1 && @symlink(__FILE__, $this->getLockFile($lock_id)) === FALSE) {
if ($create_lock && @symlink(__FILE__, $this->getLockFile($lock_id)) === FALSE) {
// If we can't create a symlink, the lock ID is in use. Generate another
// one. Symlinks are used because they are atomic and reliable.
$lock_id = NULL;
}
} while ($lock_id === NULL);
return $lock_id;
}
/**
* Releases a lock.
*
* @return bool
* TRUE if successful, FALSE if not.
*/
public function releaseLock() {
return unlink($this->getLockFile($this->lockId));
}
/**
* Releases all test locks.
*
* This should only be called once all the test fixtures have been cleaned up.
*/
public static function releaseAllTestLocks() {
$tmp = file_directory_os_temp();
$tmp = FileSystem::getOsTemporaryDirectory();
$dir = dir($tmp);
while (($entry = $dir->read()) !== FALSE) {
if ($entry === '.' || $entry === '..') {
......
#!/usr/bin/env php
<?php
/**
* @file
* A command line application to install Drupal for tests.
*/
use Drupal\TestSite\TestSiteApplication;
if (PHP_SAPI !== 'cli') {
return;
}
// Use the PHPUnit bootstrap to prime an autoloader that works for test classes.
// Note we have to disable the SYMFONY_DEPRECATIONS_HELPER to ensure deprecation
// notices are not triggered.
putenv('SYMFONY_DEPRECATIONS_HELPER=disabled');
require_once __DIR__ . '/../tests/bootstrap.php';
// The application version is 0.1.0 to indicate that it is for internal use only
// and not currently API.
$app = new TestSiteApplication('test-site', '0.1.0');
$app->run();
<?php
namespace Drupal\TestSite\Commands;
use Drupal\Core\Database\Database;
use Drupal\Core\Test\FunctionalTestSetupTrait;
use Drupal\Core\Test\TestDatabase;
use Drupal\Core\Test\TestSetupTrait;
use Drupal\TestSite\TestSetupInterface;
use Drupal\Tests\RandomGeneratorTrait;
use Drupal\Tests\SessionTestTrait;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Command to create a test Drupal site.
*
* @internal
*/
class TestSiteInstallCommand extends Command {
use FunctionalTestSetupTrait {
installParameters as protected installParametersTrait;
}
use RandomGeneratorTrait;
use SessionTestTrait;
use TestSetupTrait {
changeDatabasePrefix as protected changeDatabasePrefixTrait;
}
/**
* The install profile to use.
*
* @var string
*/
protected $profile = 'testing';
/**
* Time limit in seconds for the test.
*
* Used by \Drupal\Core\Test\FunctionalTestSetupTrait::prepareEnvironment().
*
* @var int
*/
protected $timeLimit = 500;
/**
* The database prefix of this test run.
*
* @var string
*/
protected $databasePrefix;
/**
* The language to install the site in.
*
* @var string
*/
protected $langcode = 'en';
/**
* {@inheritdoc}
*/
protected function configure() {
$this->setName('install')
->setDescription('Creates a test Drupal site')
->setHelp('The details to connect to the test site created will be displayed upon success. It will contain the database prefix and the user agent.')
->addOption('setup-file', NULL, InputOption::VALUE_OPTIONAL, 'The path to a PHP file containing a class to setup configuration used by the test, for example, core/tests/Drupal/TestSite/TestSiteInstallTestScript.php.')
->addOption('db-url', NULL, InputOption::VALUE_OPTIONAL, 'URL for database. Defaults to the environment variable SIMPLETEST_DB.', getenv('SIMPLETEST_DB'))
->addOption('base-url', NULL, InputOption::VALUE_OPTIONAL, 'Base URL for site under test. Defaults to the environment variable SIMPLETEST_BASE_URL.', getenv('SIMPLETEST_BASE_URL'))
->addOption('install-profile', NULL, InputOption::VALUE_OPTIONAL, 'Install profile to install the site in. Defaults to testing.', 'testing')
->addOption('langcode', NULL, InputOption::VALUE_OPTIONAL, 'The language to install the site in. Defaults to en.', 'en')
->addOption('json', NULL, InputOption::VALUE_NONE, 'Output test site connection details in JSON.')
->addUsage('--setup-file core/tests/Drupal/TestSite/TestSiteInstallTestScript.php --json')
->addUsage('--install-profile demo_umami --langcode fr')
->addUsage('--base-url "http://example.com" --db-url "mysql://username:password@localhost/databasename#table_prefix"');
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output) {
// Determines and validates the setup class prior to installing a database
// to avoid creating unnecessary sites.
$root = dirname(dirname(dirname(dirname(dirname(__DIR__)))));
chdir($root);
$class_name = $this->getSetupClass($input->getOption('setup-file'));
// Ensure we can install a site in the sites/simpletest directory.
$this->ensureDirectory($root);
$db_url = $input->getOption('db-url');
$base_url = $input->getOption('base-url');
putenv("SIMPLETEST_DB=$db_url");
putenv("SIMPLETEST_BASE_URL=$base_url");
// Manage site fixture.
$this->setup($input->getOption('install-profile'), $class_name, $input->getOption('langcode'));
$user_agent = drupal_generate_test_ua($this->databasePrefix);
if ($input->getOption('json')) {
$output->writeln(json_encode([
'db_prefix' => $this->databasePrefix,
'user_agent' => $user_agent,
]));
}
else {
$output->writeln('<info>Successfully installed a test site</info>');
$io = new SymfonyStyle($input, $output);
$io->table([], [
['Database prefix', $this->databasePrefix],
['User agent', $user_agent],
]);
}
}
/**
* Gets the setup class.
*
* @param string|null $file
* The file to get the setup class from.
*
* @return string|null
* The setup class contained in the provided $file.
*
* @throws \InvalidArgumentException
* Thrown if the file does not exist, does not contain a class or the class
* does not implement \Drupal\TestSite\TestSetupInterface.
*/
protected function getSetupClass($file) {
if ($file === NULL) {
return;
}
if (!file_exists($file)) {
throw new \InvalidArgumentException("The file $file does not exist.");
}
$classes = get_declared_classes();
include_once $file;
$new_classes = array_values(array_diff(get_declared_classes(), $classes));
if (empty($new_classes)) {
throw new \InvalidArgumentException("The file $file does not contain a class.");
}
$class = array_pop($new_classes);
if (!is_subclass_of($class, TestSetupInterface::class)) {
throw new \InvalidArgumentException("The class $class contained in $file needs to implement \Drupal\TestSite\TestSetupInterface");
}
return $class;
}
/**
* Ensures that the sites/simpletest directory exists and is writable.
*
* @param string $root
* The Drupal root.
*/
protected function ensureDirectory($root) {
if (!is_writable($root . '/sites/simpletest')) {
if (!@mkdir($root . '/sites/simpletest')) {
throw new \RuntimeException($root . '/sites/simpletest must exist and be writable to install a test site');
}
}
}
/**
* Creates a test drupal installation.
*
* @param string $profile
* (optional) The installation profile to use.
* @param string $setup_class
* (optional) Setup class. A PHP class to setup configuration used by the
* test.
* @param string $langcode
* (optional) The language to install the site in.
*/
public function setup($profile = 'testing', $setup_class = NULL, $langcode = 'en') {
$this->profile = $profile;
$this->langcode = $langcode;
$this->setupBaseUrl();
$this->prepareEnvironment();
$this->installDrupal();
if ($setup_class) {
$this->executeSetupClass($setup_class);
}
}
/**
* Installs Drupal into the test site.
*/
protected function installDrupal() {
$this->initUserSession();
$this->prepareSettings();
$this->doInstall();
$this->initSettings();
$container = $this->initKernel(\Drupal::request());
$this->initConfig($container);
$this->installModulesFromClassProperty($container);
$this->rebuildAll();
}
/**
* Uses the setup file to configure Drupal.
*
* @param string $class
* The fully qualified class name, which should set up Drupal for tests. For
* example this class could create content types and fields or install
* modules. The class needs to implement TestSetupInterface.
*
* @see \Drupal\TestSite\TestSetupInterface
*/
protected function executeSetupClass($class) {
/** @var \Drupal\TestSite\TestSetupInterface $instance */
$instance = new $class();
$instance->setup();
}
/**
* {@inheritdoc}
*/
protected function installParameters() {
$parameters = $this->installParametersTrait();
$parameters['parameters']['langcode'] = $this->langcode;
return $parameters;
}
/**
* {@inheritdoc}
*/
protected function changeDatabasePrefix() {
// Ensure that we use the database from SIMPLETEST_DB environment variable.
Database::removeConnection('default');
$this->changeDatabasePrefixTrait();
}
/**
* {@inheritdoc}
*/
protected function prepareDatabasePrefix() {
// Override this method so that we can force a lock to be created.
$test_db = new TestDatabase(NULL, TRUE);
$this->siteDirectory = $test_db->getTestSitePath();
$this->databasePrefix = $test_db->getDatabasePrefix();
}
}
<?php
namespace Drupal\TestSite\Commands;
use Drupal\Core\Test\TestDatabase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Command to release all test site database prefix locks.
*
* Note that this command can't be safely tested by DrupalCI without potentially
* causing random failures.
*
* @internal
*/
class TestSiteReleaseLocksCommand extends Command {
/**
* {@inheritdoc}
*/
protected function configure() {
$this->setName('release-locks')
->setDescription('Releases all test site locks')
->setHelp('The locks ensure test site database prefixes are not reused.');
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output) {
TestDatabase::releaseAllTestLocks();
$output->writeln('<info>Successfully released all the test database locks</info>');
}
}
<?php
namespace Drupal\TestSite\Commands;
use Drupal\Core\Database\Database;
use Drupal\Core\Test\TestDatabase;
use Drupal\Tests\BrowserTestBase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Command to tear down a test Drupal site.
*
* @internal
*/
class TestSiteTearDownCommand extends Command {
/**
* {@inheritdoc}
*/
protected function configure() {
$this->setName('tear-down')
->setDescription('Removes a test site added by the install command')
->setHelp('All the database tables and files will be removed.')
->addArgument('db-prefix', InputArgument::REQUIRED, 'The database prefix for the test site.')
->addOption('db-url', NULL, InputOption::VALUE_OPTIONAL, 'URL for database. Defaults to the environment variable SIMPLETEST_DB.', getenv('SIMPLETEST_DB'))
->addOption('keep-lock', NULL, InputOption::VALUE_NONE, 'Keeps the database prefix lock. Useful for ensuring test isolation when running concurrent tests.')
->addUsage('test12345678')
->addUsage('test12345678 --db-url "mysql://username:password@localhost/databasename#table_prefix"')
->addUsage('test12345678 --keep-lock');
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output) {
$db_prefix = $input->getArgument('db-prefix');
// Validate the db_prefix argument.
try {
$test_database = new TestDatabase($db_prefix);
}
catch (\InvalidArgumentException $e) {
$io = new SymfonyStyle($input, $output);
$io->getErrorStyle()->error("Invalid database prefix: $db_prefix\n\nValid database prefixes match the regular expression '/test(\d+)$/'. For example, 'test12345678'.");
// Display the synopsis of the command like Composer does.
$output->writeln(sprintf('<info>%s</info>', sprintf($this->getSynopsis(), $this->getName())), OutputInterface::VERBOSITY_QUIET);
return 1;
}
$db_url = $input->getOption('db-url');
putenv("SIMPLETEST_DB=$db_url");
// Handle the cleanup of the test site.
$this->tearDown($test_database, $db_url);
// Release the test database prefix lock.
if (!$input->getOption('keep-lock')) {
$test_database->releaseLock();
}
$output->writeln("<info>Successfully uninstalled $db_prefix test site</info>");
}
/**
* Removes a given instance by deleting all the database tables and files.
*
* @param \Drupal\Core\Test\TestDatabase $test_database
* The test database object.
* @param string $db_url
* The database URL.
*
* @see \Drupal\Tests\BrowserTestBase::cleanupEnvironment()
*/
protected function tearDown(TestDatabase $test_database, $db_url) {
// Connect to the test database.
$root = dirname(dirname(dirname(dirname(dirname(__DIR__)))));
$database = Database::convertDbUrlToConnectionInfo($db_url, $root);
$database['prefix'] = ['default' => $test_database->getDatabasePrefix()];
Database::addConnectionInfo(__CLASS__, 'default', $database);
// Remove all the tables.
$schema = Database::getConnection('default', __CLASS__)->schema();
$tables = $schema->findTables('%');
array_walk($tables, [$schema, 'dropTable']);
// Delete test site directory.
$this->fileUnmanagedDeleteRecursive($root . DIRECTORY_SEPARATOR . $test_database->getTestSitePath(), [BrowserTestBase::class, 'filePreDeleteCallback']);
}
/**
* Deletes all files and directories in the specified path recursively.
*
* Note this method has no dependencies on Drupal core to ensure that the
* test site can be torn down even if something in the test site is broken.
*
* @param string $path
* A string containing either an URI or a file or directory path.
* @param callable $callback
* (optional) Callback function to run on each file prior to deleting it and
* on each directory prior to traversing it. For example, can be used to
* modify permissions.
*
* @return bool
* TRUE for success or if path does not exist, FALSE in the event of an
* error.
*
* @see file_unmanaged_delete_recursive()
*/
protected function fileUnmanagedDeleteRecursive($path, $callback = NULL) {
if (isset($callback)) {
call_user_func($callback, $path);
}
if (is_dir($path)) {
$dir = dir($path);
while (($entry = $dir->read()) !== FALSE) {
if ($entry == '.' || $entry == '..') {
continue;
}
$entry_path = $path . '/' . $entry;
$this->fileUnmanagedDeleteRecursive($entry_path, $callback);
}
$dir->close();
return rmdir($path);
}
return unlink($path);
}
}
<?php
namespace Drupal\TestSite;
/**
* Allows setting up an environment as part of a test site install.
*
* @see \Drupal\TestSite\Commands\TestSiteInstallCommand
*/
interface TestSetupInterface {
/**
* Run the code to setup the test environment.
*
* You have access to any API provided by any installed module. For example,
* to install modules use:
* @code
* \Drupal::service('module_installer')->install(['my_module'])
* @endcode
*
* Check out TestSiteInstallTestScript for an example.
*
* @see \Drupal\TestSite\TestSiteInstallTestScript
*/
public function setup();
}
<?php
namespace Drupal\TestSite;
use Drupal\TestSite\Commands\TestSiteInstallCommand;
use Drupal\TestSite\Commands\TestSiteReleaseLocksCommand;
use Drupal\TestSite\Commands\TestSiteTearDownCommand;
use Symfony\Component\Console\Application;
/**
* Application wrapper for test site commands.
*
* In order to see what commands are available and how to use them run
* "php core/scripts/test-site.php" from command line and use the help system.
*
* @internal
*/
class TestSiteApplication extends Application {
/**
* {@inheritdoc}
*/
protected function getDefaultCommands() {
$default_commands = parent::getDefaultCommands();
$default_commands[] = new TestSiteInstallCommand();
$default_commands[] = new TestSiteTearDownCommand();
$default_commands[] = new TestSiteReleaseLocksCommand();
return $default_commands;
}
}
<?php
namespace Drupal\TestSite;
/**
* Setup file used by TestSiteApplicationTest.
*
* @see \Drupal\Tests\Scripts\TestSiteApplicationTest
*/
class TestSiteInstallTestScript implements TestSetupInterface {
/**
* {@inheritdoc}
*/
public function setup() {
\Drupal::service('module_installer')->install(['test_page_test']);
}
}
This diff is collapsed.
......@@ -129,6 +129,7 @@ function drupal_phpunit_populate_class_loader() {
// Start with classes in known locations.
$loader->add('Drupal\\Tests', __DIR__);
$loader->add('Drupal\\TestSite', __DIR__);
$loader->add('Drupal\\KernelTests', __DIR__);
$loader->add('Drupal\\FunctionalTests', __DIR__);
$loader->add('Drupal\\FunctionalJavascriptTests', __DIR__);
......@@ -195,5 +196,5 @@ class_alias('\PHPUnit\Framework\MockObject\Matcher\InvokedRecorder', '\PHPUnit_F
class_alias('\PHPUnit\Framework\SkippedTestError', '\PHPUnit_Framework_SkippedTestError');
class_alias('\PHPUnit\Framework\TestCase', '\PHPUnit_Framework_TestCase');
class_alias('\PHPUnit\Util\Test', '\PHPUnit_Util_Test');
class_alias('\PHPUnit\Util\XML', '\PHPUnit_Util_XML');
class_alias('\PHPUnit\Util\Xml', '\PHPUnit_Util_XML');
}
<?php
// @codingStandardsIgnoreFile
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment