'', 'help' => FALSE, 'list' => FALSE, 'clean' => FALSE, 'url' => '', 'sqlite' => NULL, 'dburl' => NULL, 'php' => '', 'concurrency' => 1, 'all' => FALSE, 'module' => NULL, 'class' => FALSE, 'file' => FALSE, 'directory' => NULL, 'color' => FALSE, 'verbose' => FALSE, 'keep-results' => FALSE, 'test_names' => array(), 'repeat' => 1, 'die-on-fail' => FALSE, 'browser' => FALSE, // Used internally. 'test-id' => 0, 'execute-test' => '', 'xml' => '', 'non-html' => FALSE, ]); // Add in our input options. $options = array_keys((array) $config); foreach ($options as $option) { if ($input->hasOption($option)) { $config[$option] = $input->getOption($option); } } // Gather our test_names. $config['test_names'] = []; if ($input->hasArgument('test_names')) { $test_names = $input->getArgument('test_names'); if (!empty($test_names)) { $config['test_names'] = explode(',', $test_names); } } // Get the script name. $config['script'] = basename(__FILE__); return $config; } } /** * Test runner console command. */ class RunTestsCommand extends Command { protected $config; protected $autoloader; protected $output; protected $php = ''; protected $container; protected $test_ids = []; protected $test_list; protected $results_map = [ 'pass' => 'Pass', 'fail' => 'Fail', 'exception' => 'Exception' ]; /** * Contructs a new object. * * @param \Composer\Autoload\ClassLoader $auto_loader * The class loader. * @param string|null $name * (optional) The name of the command. */ public function __construct(ClassLoader $auto_loader, $name = NULL) { $this->autoloader = $auto_loader; parent::__construct($name); } /** * {@inheritdoc} */ protected function configure() { $this->setName('run-tests') ->setDescription('Run tests.') ->addArgument( 'test_names', InputArgument::OPTIONAL, 'Comma-delimited list of tests to run. Can be test classes, files, or groups based on other options.' ) ->addOption('list', NULL, InputOption::VALUE_NONE, 'Display all available test groups.') ->addOption('clean', NULL, InputOption::VALUE_NONE, 'Cleans up database tables or directories from previous, failed, tests and then exits (no tests are run).') ->addOption('url', NULL, InputOption::VALUE_OPTIONAL, 'The base URL of the root directory of this Drupal checkout; e.g.: http://drupal.test/ Required unless the Drupal root directory maps exactly to: http://localhost:80/ Use a https:// URL to force all tests to be run under SSL.') ->addOption('all', NULL, InputOption::VALUE_NONE, 'Run all available tests.') ->addOption('module', NULL, InputOption::VALUE_OPTIONAL, "Run all tests belonging to the specified module name. (e.g., 'node')") ->addOption('class', NULL, InputOption::VALUE_NONE, "Run tests identified by specific class names, instead of group names. A specific test method can be added, for example, 'Drupal\book\Tests\BookTest::testBookExport'.") ->addOption('file', NULL, InputOption::VALUE_NONE, "Run tests identified by specific file names, instead of group names. Specify the path and the extension (i.e. 'core/modules/user/user.test').") ->addOption('dburl', NULL, InputOption::VALUE_REQUIRED, ' A URI denoting the database driver, credentials, server hostname, and database name to use in tests. Required when running tests without a Drupal installation that contains default database connection info in settings.php. Examples: mysql://username:password@localhost/databasename#table_prefix sqlite://localhost/relative/path/db.sqlite sqlite://localhost//absolute/path/db.sqlite', NULL) ->addOption('sqlite', NULL, InputOption::VALUE_OPTIONAL, "A pathname to use for the SQLite database of the test runner. Required unless this script is executed with a working Drupalinstallation that has Simpletest module installed. A relative pathname is interpreted relative to the Drupal root directory. Note that ':memory:' cannot be used, because this script spawns sub-processes. However, you may use e.g. '/tmpfs/test.sqlite'", '') ->addOption('browser', NULL, InputOption::VALUE_NONE, 'Opens the results in the browser. This enforces --keep-results and if you want to also view any pages rendered in the simpletest browser you need to add --verbose to the command line.') ->addOption('keep-results', NULL, InputOption::VALUE_NONE, 'Keeps detailed assertion results (in the database) after tests have completed. By default, assertion results are cleared.') ->addOption('concurrency', NULL, InputOption::VALUE_REQUIRED, 'Run tests in parallel, up to [num] tests at a time.', 1) ->addOption('repeat', NULL, InputOption::VALUE_REQUIRED, 'Number of times to repeat the test.', 1) ->addOption('color', NULL, InputOption::VALUE_NONE, 'Output text format results with color highlighting.') ->addOption('die-on-fail', NULL, InputOption::VALUE_REQUIRED, 'Exit test execution immediately upon any failed assertion. This allows to access the test site by changing settings.php to use the test database and configuration directories. Use in combination with --repeat for debugging random test failures.', FALSE) ->addOption('xml', NULL, InputOption::VALUE_REQUIRED, 'If provided, test results will be written as xml files to this path. Specify a directory.', '') ->addOption('non-html', NULL, InputOption::VALUE_REQUIRED, 'Removes escaping from output. Useful for reading results on the CLI.', FALSE) ->addOption('execute-test', NULL, InputOption::VALUE_OPTIONAL, 'Execute a test. Used internally to add a new process.', FALSE) ->addOption('test-id', NULL, InputOption::VALUE_OPTIONAL, 'Execute a test. Used internally to add a new process.') ->addOption('php', NULL, InputOption::VALUE_REQUIRED, 'The absolute path to the PHP executable. Usually not needed.', '') ; } /** * Set the styles we can output. * * Styles map to status strings. The currently available strings are: pass, * fail, error, and exception. You can use these styles as follows: * @code * Your test passed! * @endcode * * @param OutputInterface $output * An output interface object. The styles will be set for this output. */ protected function setStyles(OutputInterface $output) { $formatter = $output->getFormatter(); $formatter->setStyle('fail', new OutputFormatterStyle('red')); $formatter->setStyle('pass', new OutputFormatterStyle('green')); $formatter->setStyle('error', new OutputFormatterStyle('yellow')); $formatter->setStyle('exception', new OutputFormatterStyle('cyan')); } /** * {@inheritdoc} */ protected function execute(InputInterface $input, OutputInterface $output) { $this->setStyles($output); $this->output = $output; try { // Gather our configuration from the CLI inputs. $this->config = Config::createFromInput($input); $this->validateConfig(); $this->simpletest_script_init(); try { $request = Request::createFromGlobals(); $kernel = TestRunnerKernel::createFromRequest($request, $this->autoloader); $kernel->prepareLegacyRequest($request); $this->container = $kernel->getContainer(); } catch (\Exception $e) { $this->simpletest_script_print((string) $e, 'exception'); return SIMPLETEST_SCRIPT_EXIT_EXCEPTION; } if (!empty($this->config['execute-test'])) { $test_id = $this->config['test-id']; $this->simpletest_script_setup_database(); return $this->simpletest_script_run_one_test($test_id, $this->config['execute-test']); } if ($this->config['list']) { return $this->listTests($this->config['module']); } $this->simpletest_script_setup_database($input, TRUE); if ($this->config['clean']) { return $this->clean(); } $this->test_list = $this->simpletest_script_get_test_list(); // Try to allocate unlimited time to run the tests. \drupal_set_time_limit(0); $this->simpletest_script_reporter_init(); $tests_to_run = array(); $input_repeat = $this->config['repeat']; for ($i = 0; $i < $input_repeat; $i++) { $tests_to_run = array_merge($tests_to_run, $this->test_list); } // Execute tests. $status = $this->simpletest_script_execute_batch($tests_to_run); // Stop the timer. $this->simpletest_script_reporter_timer_stop(); // Display results before database is cleared. if ($this->config['browser']) { $this->simpletest_script_open_browser(); } else { $this->simpletest_script_reporter_display_results(); } if ($this->config['xml']) { $this->simpletest_script_reporter_write_xml_results(); } // Clean up all test results. if (!$this->config['keep-results']) { try { \simpletest_clean_results_table(); } catch (Exception $e) { $this->output->writeln((string) $e); return SIMPLETEST_SCRIPT_EXIT_EXCEPTION; } } } catch (DrupalRunTestsException $e) { $this->simpletest_script_print($e->getMessage(), 'exception'); $status = $e->getCode(); } // Test complete, exit. $output->writeln('status code: ' . $status); return $status; } /** * Validate the configuration. * * @throws DrupalRunTestsException */ protected function validateConfig() { // Validate the --concurrency argument. $concurrency = $this->config['concurrency']; if (!is_numeric($concurrency) || $concurrency <= 0) { throw new DrupalRunTestsException('--concurrency must be a strictly positive integer.', SIMPLETEST_SCRIPT_EXIT_FAILURE); } // Force --keep-results if --browser is set. if ($this->config['browser']) { $this->config['keep-results'] = TRUE; } } /** * Supports the --list option. * * @param string $module * * @return int * * @throws DrupalRunTestsException */ protected function listTests($module = NULL) { // Display all available tests. $this->output->write( [ 'Available test groups & classes', '-------------------------------', '', ], TRUE ); try { $groups = \simpletest_test_get_all(); } catch (\Exception $e) { } foreach ($groups as $group => $tests) { $this->output->writeln($group); foreach ($tests as $class => $info) { $this->output->writeln(" - $class"); } } return SIMPLETEST_SCRIPT_EXIT_SUCCESS; } /** * Support the --clean option. * * @return int * Exit code. */ protected function clean() { // Clean up left-over tables and directories. try { \simpletest_clean_environment(); } catch (Exception $e) { $this->output->writeln((string) $e); return SIMPLETEST_SCRIPT_EXIT_EXCEPTION; } $this->output->writeln(''); $this->output->writeln('Environment cleaned.'); // Get the status messages and print them. $messages = drupal_get_messages('status'); foreach ($messages['status'] as $text) { $this->output->writeln(" - " . $text); } return SIMPLETEST_SCRIPT_EXIT_SUCCESS; } /** * Sets up database connection info for running tests. * * If this script is executed from within a real Drupal installation, then * this function essentially performs nothing (unless the --sqlite or --dburl * parameters were passed). * * Otherwise, there are three database connections of concern: * - --sqlite: The test runner connection, providing access to Simpletest * database tables for recording test IDs and assertion results. * - --dburl: A database connection that is used as base connection info for * all tests; i.e., every test will spawn from this connection. In case this * connection uses e.g. SQLite, then all tests will run against SQLite. This * is exposed as $databases['default']['default'] to Drupal. * - The actual database connection used within a test. This is the same as * --dburl, but uses an additional database table prefix. This is * $databases['default']['default'] within a test environment. The original * connection is retained in * $databases['simpletest_original_default']['default'] and restored after * each test. * * @param bool $new * TRUE if this process is a run-tests.sh master process, FALSE otherwise. * If TRUE, the SQLite database file specified by --sqlite (if any) is set * up. Otherwise, database connections are prepared only. */ protected function simpletest_script_setup_database($new = FALSE) { // If there is an existing Drupal installation that contains a database // connection info in settings.php, then $databases['default']['default'] // will hold the default database connection already. This connection is // assumed to be valid, and this connection will be used in tests, so that // they run against e.g. MySQL instead of SQLite. // However, in case no Drupal installation exists, this default database // connection can be set and/or overridden with the --dburl parameter. if (!empty($this->config['dburl'])) { // Remove a possibly existing default connection (from settings.php). Database::removeConnection('default'); try { $databases['default']['default'] = Database::convertDbUrlToConnectionInfo($this->config['dburl'], DRUPAL_ROOT); } catch (\InvalidArgumentException $e) { throw new DrupalRunTestsException('Invalid --dburl. Reason: ' . $e->getMessage(), SIMPLETEST_SCRIPT_EXIT_FAILURE); } } // Otherwise, try to use the default database connection from settings.php. else { $databases['default'] = Database::getConnectionInfo('default'); } // If there is no default database connection for tests, we cannot continue. if (!isset($databases['default']['default'])) { throw new DrupalRunTestsException('Missing default database connection for tests. Use --dburl to specify one.', SIMPLETEST_SCRIPT_EXIT_FAILURE); } Database::addConnectionInfo('default', 'default', $databases['default']['default']); // If no --sqlite parameter has been passed, then Simpletest module is // assumed to be installed, so the test runner database connection is the // default database connection. if (empty($this->config['sqlite'])) { $sqlite = FALSE; $databases['test-runner']['default'] = $databases['default']['default']; } // Otherwise, set up a SQLite connection for the test runner. else { if ($this->config['sqlite'][0] === '/') { $sqlite = $this->config['sqlite']; } else { $sqlite = DRUPAL_ROOT . '/' . $this->config['sqlite']; } $databases['test-runner']['default'] = array( 'driver' => 'sqlite', 'database' => $sqlite, 'prefix' => array( 'default' => '', ), ); // Create the test runner SQLite database, unless it exists already. if ($new && !file_exists($sqlite)) { if (!is_dir(dirname($sqlite))) { mkdir(dirname($sqlite)); } touch($sqlite); } } // Add the test runner database connection. Database::addConnectionInfo('test-runner', 'default', $databases['test-runner']['default']); // Create the Simpletest schema. try { $schema = Database::getConnection('default', 'test-runner')->schema(); } catch (\PDOException $e) { $this->simpletest_script_print_error('There was an error connecting to the databse. Try the --dburl and/or --sqlite options.'); throw new DrupalRunTestsException($databases['test-runner']['default']['driver'] . ': ' . $e->getMessage(), SIMPLETEST_SCRIPT_EXIT_FAILURE); } if ($new && $sqlite) { require_once DRUPAL_ROOT . '/' . \drupal_get_path('module', 'simpletest') . '/simpletest.install'; $simpletest_schema = \simpletest_schema(); foreach ($simpletest_schema as $name => $table_spec) { try { if ($schema->tableExists($name)) { $schema->dropTable($name); } $schema->createTable($name, $table_spec); } catch (Exception $e) { throw new DrupalRunTestsException((string) $e, SIMPLETEST_SCRIPT_EXIT_EXCEPTION); } } } // Verify that the Simpletest database schema exists by checking one table. try { if (!$schema->tableExists('simpletest')) { throw new DrupalRunTestsException('Missing Simpletest database schema. Either install Simpletest module or use the --sqlite parameter.', SIMPLETEST_SCRIPT_EXIT_FAILURE); } } catch (Exception $e) { throw new DrupalRunTestsException((string) $e, SIMPLETEST_SCRIPT_EXIT_EXCEPTION); } } /** * Print error messages in appropriately alarming colors. * * Print error message prefixed with " ERROR: " and displayed in fail color * if color output is enabled. * * @param string $message * The message to print. */ protected function simpletest_script_print_error($message) { $this->simpletest_script_print(" ERROR: $message", 'error'); } /** * Print a message to the console. * * If color is enabled then the specified color code will be used. * * @param string $message * The message to print. * @param string $status_code * The color code to use for coloring. */ protected function simpletest_script_print($message, $status_code = NULL) { if ($status_code !== NULL && $this->config['color']) { $message = "<$status_code>$message"; } $this->output->writeln($message); } /** * Run a single test against a bootstrapped Drupal. */ protected function simpletest_script_run_one_test($test_id, $test_class) { try { if (strpos($test_class, '::') > 0) { list($class_name, $method) = explode('::', $test_class, 2); $methods = [$method]; } else { $class_name = $test_class; // Use empty array to run all the test methods. $methods = array(); } $test = new $class_name($test_id); if (is_subclass_of($test_class, '\PHPUnit_Framework_TestCase')) { $status = $this->simpletest_script_run_phpunit($test_id, $test_class); } else { $test->dieOnFail = (bool) $this->config['die-on-fail']; $test->verbose = (bool) $this->config['verbose']; $test->run($methods); $this->simpletest_script_reporter_display_summary($test_class, $test->results); $status = SIMPLETEST_SCRIPT_EXIT_SUCCESS; // Finished, kill this runner. if ($test->results['#fail'] || $test->results['#exception']) { $status = SIMPLETEST_SCRIPT_EXIT_FAILURE; } } return $status; } // DrupalTestCase::run() catches exceptions already, so this is only reached // when an exception is thrown in the wrapping test runner environment. catch (\Exception $e) { throw new DrupalRunTestsException((string) $e, SIMPLETEST_SCRIPT_EXIT_EXCEPTION); } } /** * Gather all the tests for a list of class names. * * Supports the --class option. */ protected function getTestsForClassNames(array $test_names) { $test_list = []; foreach ($test_names as $test_class) { list($class_name, ) = explode('::', $test_class, 2); if (class_exists($class_name)) { $test_list[] = $test_class; } else { // Tell the user that their class doesn't exist. $this->simpletest_script_print_error('Test class not found: ' . $class_name); $this->simpletest_script_print_alternatives($class_name, $this->getAllTestClasses(), 6); throw new DrupalRunTestsException('', SIMPLETEST_SCRIPT_EXIT_FAILURE); } } return $test_list; } /** * Gather all the tests Simpletest can find. * * @return array * * @see \simpletest_test_get_all() */ protected function getAllTestClasses() { $groups = []; $all_classes = []; try { $groups = \simpletest_test_get_all(); } catch (\Exception $e) { } foreach ($groups as $group) { $all_classes = array_merge($all_classes, array_keys($group)); } return $all_classes; } /** * * @param type $test_names * @return type * @throws DrupalRunTestsException */ protected function getTestsForFiles($test_names) { // Extract test case class names from specified files. $test_list = []; foreach ($test_names as $file) { if (!file_exists($file)) { $this->simpletest_script_print_error('File not found: ' . $file); throw new DrupalRunTestsException('', SIMPLETEST_SCRIPT_EXIT_FAILURE); } $contents = file_get_contents($file); $test_list = array_merge($test_list, $this->getClassnamesForFileContents($contents)); } return $test_list; } /** * * @param string $directory * @return type */ protected function getTestsForDirectory($directory) { $test_list = []; // Extract test case class names from specified directory. // Find all tests in the PSR-X structure; Drupal\$extension\Tests\*.php // Since we do not want to hard-code too many structural file/directory // assumptions about PSR-0/4 files and directories, we check for the // minimal conditions only; i.e., a '*.php' file that has '/Tests/' in // its path. // Ignore anything from third party vendors. $ignore = array('.', '..', 'vendor'); $files = []; // Add DRUPAL_ROOT if beginning slash is missing. if ($directory[0] !== '/') { $directory = DRUPAL_ROOT . "/" . $input_directory; } foreach (file_scan_directory($directory, '/\.php$/', $ignore) as $file) { // '/Tests/' can be contained anywhere in the file's path (there can // be sub-directories below /Tests), but must be contained literally. // Case-insensitive to match all Simpletest and PHPUnit tests: // ./lib/Drupal/foo/Tests/Bar/Baz.php // ./foo/src/Tests/Bar/Baz.php // ./foo/tests/Drupal/foo/Tests/FooTest.php // ./foo/tests/src/FooTest.php // $file->filename doesn't give us a directory, so we use $file->uri // Strip the drupal root directory and trailing slash off the URI. $filename = substr($file->uri, strlen(DRUPAL_ROOT) + 1); if (stripos($filename, '/Tests/')) { $files[$filename] = $filename; } } foreach ($files as $file) { $contents = file_get_contents($file); $test_list = array_merge($test_list, $this->getClassnamesForFileContents($contents)); } return $test_list; } /** * * @param type $contents * @return string */ protected function getClassnamesForFileContents($contents) { $test_list = []; // Extract a potential namespace. $namespace = FALSE; if (preg_match('@^namespace ([^ ;]+)@m', $contents, $matches)) { $namespace = $matches[1]; } // Extract all class names. // Abstract classes are excluded on purpose. preg_match_all('@^class ([^ ]+)@m', $contents, $matches); if (!$namespace) { $test_list = array_merge($test_list, $matches[1]); } else { foreach ($matches[1] as $class_name) { $namespace_class = $namespace . '\\' . $class_name; if (is_subclass_of($namespace_class, '\Drupal\simpletest\TestBase') || is_subclass_of($namespace_class, '\PHPUnit_Framework_TestCase')) { $test_list[] = $namespace_class; } } } return $test_list; } /** * Get list of tests based on options. * * If --all is specified then return all available tests, otherwise read list * of tests. * * Will print error and exit if no valid tests were found. * * @return string[] * List of tests. */ protected function simpletest_script_get_test_list() { $this->test_list = array(); // Handle --all and --module. if ($this->config['all'] || !empty($this->config['module'])) { try { $groups = \simpletest_test_get_all($this->config['module']); } catch (Exception $e) { throw new DrupalRunTestsException((string) $e, SIMPLETEST_SCRIPT_EXIT_EXCEPTION); } $all_tests = array(); foreach ($groups as $group => $tests) { $all_tests = array_merge($all_tests, array_keys($tests)); } $this->test_list = $all_tests; } // Handle --class, --file and --directory. else { if (!empty($this->config['class'])) { $this->test_list = $this->getTestsForClassNames($this->config['test_names']); } elseif (!empty($this->config['file'])) { $this->test_list = $this->getTestsForFiles($this->config['test_names']); } elseif (!empty($this->config['directory'])) { $this->test_list = $this->getTestsForDirectory($this->config['directory']); } // Assume test_names contains group names. else { if (!empty($this->config['test_names'])) { try { $groups = \simpletest_test_get_all(); } catch (\Exception $e) { } foreach ($this->config['test_names'] as $group_name) { if (isset($groups[$group_name])) { $this->test_list = array_merge($this->test_list, array_keys($groups[$group_name])); } else { $this->simpletest_script_print_error('Test group not found: ' . $group_name); $this->simpletest_script_print_alternatives($group_name, array_keys($groups)); throw new DrupalRunTestsException('', SIMPLETEST_SCRIPT_EXIT_FAILURE); } } } } } if (empty($this->test_list)) { $this->simpletest_script_print_error('No valid tests were specified.'); throw new DrupalRunTestsException('', SIMPLETEST_SCRIPT_EXIT_FAILURE); } return $this->test_list; } /** * Initialize the reporter. */ protected function simpletest_script_reporter_init() { $this->output->writeln(''); $this->output->writeln("Drupal test run"); $this->output->writeln("---------------"); $this->output->writeln(''); // Tell the user about what tests are to be run. if ($this->config['all']) { $this->output->writeln("All tests will run."); $this->output->writeln(''); } else { $this->output->writeln("Tests to be run:"); foreach ($this->test_list as $class_name) { $this->output->writeln(" - $class_name"); } $this->output->writeln(''); } $this->output->writeln("Test run started:"); // @todo: $_SERVER? Really? :-) $this->output->writeln(" " . date('l, F j, Y - H:i', $_SERVER['REQUEST_TIME'])); Timer::start('run-tests'); $this->output->writeln(''); $this->output->writeln("Test summary"); $this->output->writeln("------------"); $this->output->writeln(''); } /** * Execute a batch of tests. */ protected function simpletest_script_execute_batch($test_classes) { $total_status = SIMPLETEST_SCRIPT_EXIT_SUCCESS; // Multi-process execution. $children = array(); while (!empty($test_classes) || !empty($children)) { while (count($children) < $this->config['concurrency']) { if (empty($test_classes)) { break; } try { $test_id = Database::getConnection('default', 'test-runner') ->insert('simpletest_test_id') ->useDefaults(array('test_id')) ->execute(); } catch (\Exception $e) { throw new DrupalRunTestsException((string) $e, SIMPLETEST_SCRIPT_EXIT_EXCEPTION); } $this->test_ids[] = $test_id; $test_class = array_shift($test_classes); // Fork a child process. $command = $this->simpletest_script_command($test_id, $test_class); $process = proc_open($command, array(), $pipes, NULL, NULL, array('bypass_shell' => TRUE)); if (!is_resource($process)) { // @todo: Should really be an error code. throw new DrupalRunTestsException('Unable to fork test process. Aborting.', SIMPLETEST_SCRIPT_EXIT_SUCCESS); } // Register our new child. $children[] = array( 'process' => $process, 'test_id' => $test_id, 'class' => $test_class, 'pipes' => $pipes, ); } // Wait for children every 200ms. usleep(200000); // Check if some children finished. foreach ($children as $cid => $child) { $status = proc_get_status($child['process']); if (empty($status['running'])) { // The child exited, unregister it. proc_close($child['process']); // If the test failed, only add the status code. if ($status['exitcode'] === SIMPLETEST_SCRIPT_EXIT_FAILURE) { $total_status = max($status['exitcode'], $total_status); } // If the test errored out, add the status code and log it. elseif ($status['exitcode']) { $total_status = max($status['exitcode'], $total_status); $message = 'FATAL ' . $child['class'] . ': test runner returned a non-zero error code (' . $status['exitcode'] . ').'; $this->simpletest_script_print($message, 'error'); // Insert a fail for xml results. TestBase::insertAssert($child['test_id'], $child['class'], FALSE, $message, 'run-tests.sh check'); // Ensure that an error line is displayed for the class. $this->simpletest_script_reporter_display_summary($child['class'], ['#pass' => 0, '#fail' => 1, '#exception' => 0, '#debug' => 0]); if ($this->config['die-on-fail']) { list($db_prefix, ) = \simpletest_last_test_get($child['test_id']); $test_directory = 'sites/simpletest/' . substr($db_prefix, 10); $this->output->writeln('Simpletest database and files kept and test exited immediately on fail so should be reproducible if you change settings.php to use the database prefix '. $db_prefix . ' and config directories in '. $test_directory . "\n"); $this->config['keep-results'] = TRUE; // Exit repeat loop immediately. $this->config['repeat'] = -1; } } // Free-up space by removing any potentially created resources. if (!$this->config['keep-results']) { $this->simpletest_script_cleanup($child['test_id'], $child['class'], $status['exitcode']); } // Remove this child. unset($children[$cid]); } } } return $total_status; } /** * Stop the test timer. */ protected function simpletest_script_reporter_timer_stop() { $this->output->writeln(''); $end = Timer::stop('run-tests'); $this->output->writeln("Test run duration: " . $this->container->get('date.formatter')->formatInterval($end['time'] / 1000)); $this->output->writeln(''); } /** * Display test results. */ protected function simpletest_script_reporter_display_results() { if ($this->config['verbose']) { // Report results. $this->output->write([ 'Detailed test results', '---------------------', ], TRUE); $results = $this->simpletest_script_load_messages_by_test_id($this->test_ids); $test_class = ''; foreach ($results as $result) { if (isset($this->results_map[$result->status])) { if ($result->test_class != $test_class) { // Display test class every time results are for new test class. $this->output->write([ '', '', "---- $result->test_class ----", '', '', ], TRUE); $test_class = $result->test_class; // Print table header. $this->output->write([ "Status Group Filename Line Function ", "--------------------------------------------------------------------------------", ], TRUE); } $this->simpletest_script_format_result($result); } } } } /** * Return a command used to run a test in a separate process. * * @param int $test_id * The current test ID. * @param string $test_class * The name of the test class to run. */ protected function simpletest_script_command($test_id, $test_class) { $command = escapeshellarg($this->php) . ' ' . escapeshellarg('./core/scripts/' . $this->config['script']); $command .= ' --url ' . escapeshellarg($this->config['url']); if (!empty($this->config['sqlite'])) { $command .= ' --sqlite ' . escapeshellarg($this->config['sqlite']); } if (!empty($this->config['dburl'])) { $command .= ' --dburl ' . escapeshellarg($this->config['dburl']); } $command .= ' --php ' . escapeshellarg($this->php); $command .= " --test-id $test_id"; foreach (array('verbose', 'keep-results', 'color', 'die-on-fail') as $option) { if ($this->config[$option]) { $command .= ' --' . $option; } } // --execute-test and class name needs to come last. $command .= ' --execute-test ' . escapeshellarg($test_class); return $command; } /** * Displays the assertion result summary for a single test class. * * @param string $class * The test class name that was run. * @param string[] $results * The assertion results using #pass, #fail, #exception, #debug array keys. */ protected function simpletest_script_reporter_display_summary($class, array $results) { // Output all test results vertically aligned. // Cut off the class name after 60 chars, and pad each group with 3 digits // by default (more than 999 assertions are rare). $output = vsprintf('%-60.60s %10s %9s %14s %12s', array( $class, $results['#pass'] . ' passes', !$results['#fail'] ? '' : $results['#fail'] . ' fails', !$results['#exception'] ? '' : $results['#exception'] . ' exceptions', !$results['#debug'] ? '' : $results['#debug'] . ' messages', )); $status = ($results['#fail'] || $results['#exception'] ? 'fail' : 'pass'); $this->simpletest_script_print($output, $status); } /** * Removes all remnants of a test runner. * * In case a (e.g., fatal) error occurs after the test site has been fully * setup and the error happens in many tests, the environment that executes * the tests can easily run out of memory or disk space. This function ensures * that all created resources are properly cleaned up after every executed * test. * * This clean-up only exists in this script, since SimpleTest module itself * does not use isolated sub-processes for each test being run, so a fatal * error halts not only the test, but also the test runner (i.e., the parent * site). * * @param int $test_id * The test ID of the test run. * @param string $test_class * The class name of the test run. * @param int $exitcode * The exit code of the test runner. * * @see simpletest_script_run_one_test() */ protected function simpletest_script_cleanup($test_id, $test_class, $exitcode) { if (is_subclass_of($test_class, '\PHPUnit_Framework_TestCase')) { // PHPUnit test, move on. return; } // Retrieve the last database prefix used for testing. try { list($db_prefix,) = \simpletest_last_test_get($test_id); } catch (Exception $e) { throw new DrupalRunTestsException((string) $e, SIMPLETEST_SCRIPT_EXIT_EXCEPTION); } // If no database prefix was found, then the test was not set up correctly. if (empty($db_prefix)) { throw new DrupalRunTestsException("FATAL $test_class: Found no database prefix for test ID $test_id. (Check whether setUp() is invoked correctly.)", SIMPLETEST_SCRIPT_EXIT_EXCEPTION); } // Do not output verbose cleanup messages in case of a positive exitcode. $output = !empty($exitcode); $messages = array(); $messages[] = "- Found database prefix '$db_prefix' for test ID $test_id."; // Read the log file in case any fatal errors caused the test to crash. try { \simpletest_log_read($test_id, $db_prefix, $test_class); } catch (Exception $e) { throw new DrupalRunTestsException((string) $e, SIMPLETEST_SCRIPT_EXIT_EXCEPTION); } // Check whether a test site directory was setup already. // @see \Drupal\simpletest\TestBase::prepareEnvironment() $test_directory = DRUPAL_ROOT . '/sites/simpletest/' . substr($db_prefix, 10); if (is_dir($test_directory)) { // Output the error_log. if (is_file($test_directory . '/error.log')) { if ($errors = file_get_contents($test_directory . '/error.log')) { $output = TRUE; $messages[] = $errors; } } // Delete the test site directory. // simpletest_clean_temporary_directories() cannot be used here, since it // would also delete file directories of other tests that are potentially // running concurrently. file_unmanaged_delete_recursive($test_directory, array('Drupal\simpletest\TestBase', 'filePreDeleteCallback')); $messages[] = "- Removed test site directory."; } // Clear out all database tables from the test. try { $schema = Database::getConnection('default', 'default')->schema(); $count = 0; foreach ($schema->findTables($db_prefix . '%') as $table) { $schema->dropTable($table); $count++; } } catch (Exception $e) { throw new DrupalRunTestsException((string) $e, SIMPLETEST_SCRIPT_EXIT_EXCEPTION); } if ($count) { $messages[] = "- Removed $count leftover tables."; } if ($output) { $this->output->write($messages, TRUE); $this->output->writeln(''); } } /** * Format the result to fit the default 80 character terminal size. * * @param $result * The result object to format. */ protected function simpletest_script_format_result($result) { $summary = sprintf("%-9.9s %-10.10s %-17.17s %4.4s %-35.35s\n", $this->results_map[$result->status], $result->message_group, basename($result->file), $result->line, $result->function); $this->simpletest_script_print($summary, $result->status); $message = trim(strip_tags($result->message)); if ($this->config['non-html']) { $message = Html::decodeEntities($message, ENT_QUOTES, 'UTF-8'); } $lines = explode("\n", wordwrap($message), 76); foreach ($lines as $line) { $this->output->writeln(" $line"); } } /** * Initialize script variables and perform general setup requirements. */ protected function simpletest_script_init() { $host = 'localhost'; $path = ''; $port = '80'; // Determine location of php command automatically, unless a command line // argument is supplied. if (!empty($this->config['php'])) { $this->php = $this->config['php']; } elseif ($php_env = getenv('_')) { // '_' is an environment variable set by the shell. It contains the // command that was executed. $this->php = $php_env; } elseif ($sudo = getenv('SUDO_COMMAND')) { // 'SUDO_COMMAND' is an environment variable set by the sudo program. // Extract only the PHP interpreter, not the rest of the command. list($this->php,) = explode(' ', $sudo, 2); } else { $this->simpletest_script_print_error('Unable to automatically determine the path to the PHP interpreter. Supply the --php command line argument.'); $this->simpletest_script_help(); throw new DrupalRunTestsException('', SIMPLETEST_SCRIPT_EXIT_FAILURE); } // Get URL from arguments. $input_url = $this->config['url']; if (!empty($input_url)) { $parsed_url = parse_url($input_url); $host = $parsed_url['host'] . (isset($parsed_url['port']) ? ':' . $parsed_url['port'] : ''); $path = isset($parsed_url['path']) ? rtrim(rtrim($parsed_url['path']), '/') : ''; $port = (isset($parsed_url['port']) ? $parsed_url['port'] : $port); if ($path == '/') { $path = ''; } // If the passed URL schema is 'https' then setup the $_SERVER variables // properly so that testing will run under HTTPS. if ($parsed_url['scheme'] == 'https') { $_SERVER['HTTPS'] = 'on'; } } if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') { $base_url = 'https://'; } else { $base_url = 'http://'; } $base_url .= $host; if ($path !== '') { $base_url .= $path; } putenv('SIMPLETEST_BASE_URL=' . $base_url); $_SERVER['HTTP_HOST'] = $host; $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; $_SERVER['SERVER_ADDR'] = '127.0.0.1'; $_SERVER['SERVER_PORT'] = $port; $_SERVER['SERVER_SOFTWARE'] = NULL; $_SERVER['SERVER_NAME'] = 'localhost'; $_SERVER['REQUEST_URI'] = $path . '/'; $_SERVER['REQUEST_METHOD'] = 'GET'; $_SERVER['SCRIPT_NAME'] = $path . '/index.php'; $_SERVER['SCRIPT_FILENAME'] = $path . '/index.php'; $_SERVER['PHP_SELF'] = $path . '/index.php'; $_SERVER['HTTP_USER_AGENT'] = 'Drupal command line'; if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') { // Ensure that any and all environment variables are changed to https://. foreach ($_SERVER as $key => $value) { $_SERVER[$key] = str_replace('http://', 'https://', $_SERVER[$key]); } } chdir(realpath(__DIR__ . '/../..')); } /** * Prints alternative test names. * * Searches the provided array of string values for close matches based on the * Levenshtein algorithm. * * @param string $string * A string to test. * @param string[] $array * A list of strings to search. * @param int $degree * The matching strictness. Higher values return fewer matches. A value of * 4 means that the function will return strings from $array if the * candidate string in $array would be identical to $string by changing 1/4 * or fewer of its characters. * * @see http://php.net/manual/en/function.levenshtein.php */ protected function simpletest_script_print_alternatives($string, array $array, $degree = 4) { $alternatives = array(); foreach ($array as $item) { $lev = levenshtein($string, $item); if ($lev <= strlen($item) / $degree || FALSE !== strpos($string, $item)) { $alternatives[] = $item; } } if (!empty($alternatives)) { $this->simpletest_script_print(" Did you mean?", 'error'); foreach ($alternatives as $alternative) { $this->simpletest_script_print(" - $alternative", 'error'); } } } /** * Loads the simpletest messages from the database. * * Messages are ordered by test class and message id. * * @param array $test_ids * Array of test IDs of the messages to be loaded. * * @return array * Array of simpletest messages from the database. */ protected function simpletest_script_load_messages_by_test_id($test_ids) { $results = array(); // Sqlite has a maximum number of variables per query. If required, the // database query is split into chunks. if (count($test_ids) > SIMPLETEST_SCRIPT_SQLITE_VARIABLE_LIMIT && !empty($this->config['sqlite'])) { $test_id_chunks = array_chunk($test_ids, SIMPLETEST_SCRIPT_SQLITE_VARIABLE_LIMIT); } else { $test_id_chunks = array($test_ids); } foreach ($test_id_chunks as $test_id_chunk) { try { $result_chunk = Database::getConnection('default', 'test-runner') ->query("SELECT * FROM {simpletest} WHERE test_id IN ( :test_ids[] ) ORDER BY test_class, message_id", array( ':test_ids[]' => $test_id_chunk, ))->fetchAll(); } catch (Exception $e) { $this->output->writeln((string) $e); throw new DrupalRunTestsException(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); } if ($result_chunk) { $results = array_merge($results, $result_chunk); } } return $results; } /** * Display test results. */ protected function simpletest_script_open_browser() { try { $connection = Database::getConnection('default', 'test-runner'); $results = $connection->select('simpletest') ->fields('simpletest') ->condition('test_id', $this->test_ids, 'IN') ->orderBy('test_class') ->orderBy('message_id') ->execute() ->fetchAll(); } catch (Exception $e) { $this->output->writeln((string) $e); throw new DrupalRunTestsException('', SIMPLETEST_SCRIPT_EXIT_EXCEPTION); } // Get the results form. $form = array(); SimpletestResultsForm::addResultForm($form, $results); // Get the assets to make the details element collapsible and theme the // result form. $assets = new \Drupal\Core\Asset\AttachedAssets(); $assets->setLibraries(['core/drupal.collapse', 'system/admin', 'simpletest/drupal.simpletest']); $resolver = $this->container->get('asset.resolver'); list($js_assets_header, $js_assets_footer) = $resolver->getJsAssets($assets, FALSE); $js_collection_renderer = $this->container->get('asset.js.collection_renderer'); $js_assets_header = $js_collection_renderer->render($js_assets_header); $js_assets_footer = $js_collection_renderer->render($js_assets_footer); $css_assets = $this->container->get('asset.css.collection_renderer')->render($resolver->getCssAssets($assets, FALSE)); // Make the html page to write to disk. $render_service = $this->container->get('renderer'); $html = '' . $render_service->renderPlain($js_assets_header) . $render_service->renderPlain($css_assets) . '' . $render_service->renderPlain($form) . $render_service->renderPlain($js_assets_footer) . ''; // Ensure we have assets verbose directory - tests with no verbose output // will not have created one. $directory = PublicStream::basePath() . '/simpletest/verbose'; file_prepare_directory($directory, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS); $uuid = new Php(); $filename = $directory . '/results-' . $uuid->generate() . '.html'; file_put_contents($filename, $html); // See if we can find an OS helper to open URLs in default browser. $browser = FALSE; if (shell_exec('which xdg-open')) { $browser = 'xdg-open'; } elseif (shell_exec('which open')) { $browser = 'open'; } elseif (substr(PHP_OS, 0, 3) == 'WIN') { $browser = 'start'; } if ($browser) { shell_exec($browser . ' ' . escapeshellarg($filename)); } else { // Can't find assets valid browser. print 'Open file://' . realpath($filename) . ' in your browser to see the verbose output.'; } } /** * Display jUnit XML test results. */ protected function simpletest_script_reporter_write_xml_results() { try { $results = $this->simpletest_script_load_messages_by_test_id($this->test_ids); } catch (Exception $e) { echo (string) $e; throw new DrupalRunTestsException((string) $e, SIMPLETEST_SCRIPT_EXIT_EXCEPTION); } $test_class = ''; $xml_files = array(); foreach ($results as $result) { if (isset($this->results_map[$result->status])) { if ($result->test_class != $test_class) { // We've moved onto a new class, so write the last classes results to a file: if (isset($xml_files[$test_class])) { file_put_contents($this->config['xml'] . '/' . str_replace('\\', '_', $test_class) . '.xml', $xml_files[$test_class]['doc']->saveXML()); unset($xml_files[$test_class]); } $test_class = $result->test_class; if (!isset($xml_files[$test_class])) { $doc = new DomDocument('1.0'); $root = $doc->createElement('testsuite'); $root = $doc->appendChild($root); $xml_files[$test_class] = array('doc' => $doc, 'suite' => $root); } } // For convenience: $dom_document = &$xml_files[$test_class]['doc']; // Create the XML element for this test case: $case = $dom_document->createElement('testcase'); $case->setAttribute('classname', $test_class); if (strpos($result->function, '->') !== FALSE) { list($class, $name) = explode('->', $result->function, 2); } else { $name = $result->function; } $case->setAttribute('name', $name); // Passes get no further attention, but failures and exceptions get to add more detail: if ($result->status == 'fail') { $fail = $dom_document->createElement('failure'); $fail->setAttribute('type', 'failure'); $fail->setAttribute('message', $result->message_group); $text = $dom_document->createTextNode($result->message); $fail->appendChild($text); $case->appendChild($fail); } elseif ($result->status == 'exception') { // In the case of an exception the $result->function may not be a class // method so we record the full function name: $case->setAttribute('name', $result->function); $fail = $dom_document->createElement('error'); $fail->setAttribute('type', 'exception'); $fail->setAttribute('message', $result->message_group); $full_message = $result->message . "\n\nline: " . $result->line . "\nfile: " . $result->file; $text = $dom_document->createTextNode($full_message); $fail->appendChild($text); $case->appendChild($fail); } // Append the test case XML to the test suite: $xml_files[$test_class]['suite']->appendChild($case); } } // The last test case hasn't been saved to a file yet, so do that now: if (isset($xml_files[$test_class])) { file_put_contents($this->config['xml'] . '/' . str_replace('\\', '_', $test_class) . '.xml', $xml_files[$test_class]['doc']->saveXML()); unset($xml_files[$test_class]); } } /** * Run a group of phpunit tests. */ protected function simpletest_script_run_phpunit($test_id, $class) { $reflection = new \ReflectionClass($class); if ($reflection->hasProperty('runLimit')) { set_time_limit($reflection->getStaticPropertyValue('runLimit')); } $results = \simpletest_run_phpunit_tests($test_id, array($class), $status); \simpletest_process_phpunit_results($results); // Map phpunit results to a data structure we can pass to // _simpletest_format_summary_line. $summaries = array(); foreach ($results as $result) { if (!isset($summaries[$result['test_class']])) { $summaries[$result['test_class']] = array( '#pass' => 0, '#fail' => 0, '#exception' => 0, '#debug' => 0, ); } switch ($result['status']) { case 'pass': $summaries[$result['test_class']]['#pass'] ++; break; case 'fail': $summaries[$result['test_class']]['#fail'] ++; break; case 'exception': $summaries[$result['test_class']]['#exception'] ++; break; case 'debug': $summaries[$result['test_class']]['#debug'] ++; break; } } foreach ($summaries as $class => $summary) { $this->simpletest_script_reporter_display_summary($class, $summary); } return $status; } } /** * A single-command application class for run-tests. * * This helps us shoehorn the run-tests.sh behavior into the Console idiom. */ class RunTestsApplication extends Application { protected $autoloader; public function __construct($auto_loader) { $this->autoloader = $auto_loader; parent::__construct('Drupal RunTests:TNG', '8.0.x'); } /** * {@inheritdoc} */ protected function getCommandName(InputInterface $input) { return 'run-tests'; } /** * {@inheritdoc} */ protected function getDefaultCommands() { // Keep the core default commands to have the HelpCommand // which is used when using the --help option $defaultCommands = parent::getDefaultCommands(); $defaultCommands[] = new RunTestsCommand($this->autoloader); return $defaultCommands; } /** * {@inheritdoc} */ public function getDefinition() { $inputDefinition = parent::getDefinition(); // clear out the normal first argument, which is the command name $inputDefinition->setArguments(); return $inputDefinition; } } $app = new RunTestsApplication($autoloader); $app->run();