Chris@0: getTestClasses($args['module']); Chris@0: } Chris@0: catch (Exception $e) { Chris@0: error_log((string) $e); Chris@0: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@18: Chris@18: // A given class can appear in multiple groups. For historical reasons, we Chris@18: // need to present each test only once. The test is shown in the group that is Chris@18: // printed first. Chris@18: $printed_tests = []; Chris@0: foreach ($groups as $group => $tests) { Chris@0: echo $group . "\n"; Chris@18: $tests = array_diff(array_keys($tests), $printed_tests); Chris@18: foreach ($tests as $test) { Chris@18: echo " - $test\n"; Chris@0: } Chris@18: $printed_tests = array_merge($printed_tests, $tests); Chris@0: } Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_SUCCESS); Chris@0: } Chris@0: Chris@0: // List-files and list-files-json provide a way for external tools such as the Chris@0: // testbot to prioritize running changed tests. Chris@0: // @see https://www.drupal.org/node/2569585 Chris@0: if ($args['list-files'] || $args['list-files-json']) { Chris@0: // List all files which could be run as tests. Chris@0: $test_discovery = NULL; Chris@0: try { Chris@0: $test_discovery = \Drupal::service('test_discovery'); Chris@14: } Chris@14: catch (Exception $e) { Chris@0: error_log((string) $e); Chris@14: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@0: // TestDiscovery::findAllClassFiles() gives us a classmap similar to a Chris@0: // Composer 'classmap' array. Chris@0: $test_classes = $test_discovery->findAllClassFiles(); Chris@0: // JSON output is the easiest. Chris@0: if ($args['list-files-json']) { Chris@0: echo json_encode($test_classes); Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_SUCCESS); Chris@0: } Chris@0: // Output the list of files. Chris@0: else { Chris@14: foreach (array_values($test_classes) as $test_class) { Chris@0: echo $test_class . "\n"; Chris@0: } Chris@0: } Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_SUCCESS); Chris@0: } Chris@0: Chris@0: simpletest_script_setup_database(TRUE); Chris@0: Chris@0: if ($args['clean']) { Chris@0: // Clean up left-over tables and directories. Chris@0: try { Chris@0: simpletest_clean_environment(); Chris@0: } Chris@0: catch (Exception $e) { Chris@0: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@0: echo "\nEnvironment cleaned.\n"; Chris@0: Chris@0: // Get the status messages and print them. Chris@17: $messages = \Drupal::messenger()->messagesByType('status'); Chris@17: foreach ($messages as $text) { Chris@0: echo " - " . $text . "\n"; Chris@0: } Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_SUCCESS); Chris@0: } Chris@0: Chris@14: // Ensure we have the correct PHPUnit version for the version of PHP. Chris@14: if (class_exists('\PHPUnit_Runner_Version')) { Chris@14: $phpunit_version = \PHPUnit_Runner_Version::id(); Chris@14: } Chris@14: else { Chris@14: $phpunit_version = Version::id(); Chris@14: } Chris@14: if (!Composer::upgradePHPUnitCheck($phpunit_version)) { Chris@17: simpletest_script_print_error("PHPUnit testing framework version 6 or greater is required when running on PHP 7.0 or greater. Run the command 'composer run-script drupal-phpunit-upgrade' in order to fix this."); Chris@14: exit(SIMPLETEST_SCRIPT_EXIT_FAILURE); Chris@14: } Chris@14: Chris@0: $test_list = simpletest_script_get_test_list(); Chris@0: Chris@0: // Try to allocate unlimited time to run the tests. Chris@0: drupal_set_time_limit(0); Chris@0: simpletest_script_reporter_init(); Chris@0: Chris@14: $tests_to_run = []; Chris@0: for ($i = 0; $i < $args['repeat']; $i++) { Chris@0: $tests_to_run = array_merge($tests_to_run, $test_list); Chris@0: } Chris@0: Chris@0: // Execute tests. Chris@0: $status = simpletest_script_execute_batch($tests_to_run); Chris@0: Chris@0: // Stop the timer. Chris@0: simpletest_script_reporter_timer_stop(); Chris@0: Chris@0: // Ensure all test locks are released once finished. If tests are run with a Chris@0: // concurrency of 1 the each test will clean up its own lock. Test locks are Chris@0: // not released if using a higher concurrency to ensure each test method has Chris@0: // unique fixtures. Chris@0: TestDatabase::releaseAllTestLocks(); Chris@0: Chris@0: // Display results before database is cleared. Chris@0: if ($args['browser']) { Chris@0: simpletest_script_open_browser(); Chris@0: } Chris@0: else { Chris@0: simpletest_script_reporter_display_results(); Chris@0: } Chris@0: Chris@0: if ($args['xml']) { Chris@0: simpletest_script_reporter_write_xml_results(); Chris@0: } Chris@0: Chris@0: // Clean up all test results. Chris@0: if (!$args['keep-results']) { Chris@0: try { Chris@0: simpletest_clean_results_table(); Chris@0: } Chris@0: catch (Exception $e) { Chris@0: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@0: } Chris@0: Chris@0: // Test complete, exit. Chris@0: exit($status); Chris@0: Chris@0: /** Chris@0: * Print help text. Chris@0: */ Chris@0: function simpletest_script_help() { Chris@0: global $args; Chris@0: Chris@0: echo << Chris@0: Example: {$args['script']} Profile Chris@0: Chris@0: All arguments are long options. Chris@0: Chris@0: --help Print this page. Chris@0: Chris@0: --list Display all available test groups. Chris@0: Chris@0: --list-files Chris@0: Display all discoverable test file paths. Chris@0: Chris@0: --list-files-json Chris@0: Display all discoverable test files as JSON. The array key will be Chris@0: the test class name, and the value will be the file path of the Chris@0: test. Chris@0: Chris@0: --clean Cleans up database tables or directories from previous, failed, Chris@0: tests and then exits (no tests are run). Chris@0: Chris@0: --url The base URL of the root directory of this Drupal checkout; e.g.: Chris@0: http://drupal.test/ Chris@0: Required unless the Drupal root directory maps exactly to: Chris@0: http://localhost:80/ Chris@0: Use a https:// URL to force all tests to be run under SSL. Chris@0: Chris@0: --sqlite A pathname to use for the SQLite database of the test runner. Chris@0: Required unless this script is executed with a working Drupal Chris@0: installation that has Simpletest module installed. Chris@0: A relative pathname is interpreted relative to the Drupal root Chris@0: directory. Chris@0: Note that ':memory:' cannot be used, because this script spawns Chris@0: sub-processes. However, you may use e.g. '/tmpfs/test.sqlite' Chris@0: Chris@0: --keep-results-table Chris@0: Chris@0: Boolean flag to indicate to not cleanup the simpletest result Chris@0: table. For testbots or repeated execution of a single test it can Chris@0: be helpful to not cleanup the simpletest result table. Chris@0: Chris@0: --dburl A URI denoting the database driver, credentials, server hostname, Chris@0: and database name to use in tests. Chris@0: Required when running tests without a Drupal installation that Chris@0: contains default database connection info in settings.php. Chris@0: Examples: Chris@0: mysql://username:password@localhost/databasename#table_prefix Chris@0: sqlite://localhost/relative/path/db.sqlite Chris@0: sqlite://localhost//absolute/path/db.sqlite Chris@0: Chris@0: --php The absolute path to the PHP executable. Usually not needed. Chris@0: Chris@0: --concurrency [num] Chris@0: Chris@0: Run tests in parallel, up to [num] tests at a time. Chris@0: Chris@0: --all Run all available tests. Chris@0: Chris@0: --module Run all tests belonging to the specified module name. Chris@0: (e.g., 'node') Chris@0: Chris@0: --class Run tests identified by specific class names, instead of group names. Chris@0: A specific test method can be added, for example, Chris@0: 'Drupal\book\Tests\BookTest::testBookExport'. Chris@0: Chris@0: --file Run tests identified by specific file names, instead of group names. Chris@0: Specify the path and the extension Chris@0: (i.e. 'core/modules/user/user.test'). Chris@0: Chris@0: --types Chris@0: Chris@0: Runs just tests from the specified test type, for example Chris@0: run-tests.sh Chris@0: (i.e. --types "Simpletest,PHPUnit-Functional") Chris@0: Chris@0: --directory Run all tests found within the specified file directory. Chris@0: Chris@0: --xml Chris@0: Chris@0: If provided, test results will be written as xml files to this path. Chris@0: Chris@0: --color Output text format results with color highlighting. Chris@0: Chris@0: --verbose Output detailed assertion messages in addition to summary. Chris@0: Chris@0: --keep-results Chris@0: Chris@0: Keeps detailed assertion results (in the database) after tests Chris@0: have completed. By default, assertion results are cleared. Chris@0: Chris@0: --repeat Number of times to repeat the test. Chris@0: Chris@0: --die-on-fail Chris@0: Chris@0: Exit test execution immediately upon any failed assertion. This Chris@0: allows to access the test site by changing settings.php to use the Chris@0: test database and configuration directories. Use in combination Chris@0: with --repeat for debugging random test failures. Chris@0: Chris@0: --browser Opens the results in the browser. This enforces --keep-results and Chris@0: if you want to also view any pages rendered in the simpletest Chris@0: browser you need to add --verbose to the command line. Chris@0: Chris@0: --non-html Removes escaping from output. Useful for reading results on the Chris@0: CLI. Chris@0: Chris@12: --suppress-deprecations Chris@12: Chris@17: Stops tests from failing if deprecation errors are triggered. If Chris@17: this is not set the value specified in the Chris@17: SYMFONY_DEPRECATIONS_HELPER environment variable, or the value Chris@17: specified in core/phpunit.xml (if it exists), or the default value Chris@17: will be used. The default is that any unexpected silenced Chris@17: deprecation error will fail tests. Chris@12: Chris@0: [,[, ...]] Chris@0: Chris@0: One or more tests to be run. By default, these are interpreted Chris@0: as the names of test groups as shown at Chris@0: admin/config/development/testing. Chris@0: These group names typically correspond to module names like "User" Chris@0: or "Profile" or "System", but there is also a group "Database". Chris@0: If --class is specified then these are interpreted as the names of Chris@0: specific test classes whose test methods will be run. Tests must Chris@0: be separated by commas. Ignored if --all is specified. Chris@0: Chris@0: To run this script you will normally invoke it from the root directory of your Chris@0: Drupal installation as the webserver user (differs per configuration), or root: Chris@0: Chris@0: sudo -u [wwwrun|www-data|etc] php ./core/scripts/{$args['script']} Chris@0: --url http://example.com/ --all Chris@0: sudo -u [wwwrun|www-data|etc] php ./core/scripts/{$args['script']} Chris@0: --url http://example.com/ --class "Drupal\block\Tests\BlockTest" Chris@0: Chris@0: Without a preinstalled Drupal site and enabled Simpletest module, specify a Chris@0: SQLite database pathname to create and the default database connection info to Chris@0: use in tests: Chris@0: Chris@0: sudo -u [wwwrun|www-data|etc] php ./core/scripts/{$args['script']} Chris@0: --sqlite /tmpfs/drupal/test.sqlite Chris@0: --dburl mysql://username:password@localhost/database Chris@0: --url http://example.com/ --all Chris@0: Chris@0: EOF; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Parse execution argument and ensure that all are valid. Chris@0: * Chris@0: * @return array Chris@0: * The list of arguments. Chris@0: */ Chris@0: function simpletest_script_parse_args() { Chris@0: // Set default values. Chris@14: $args = [ Chris@0: 'script' => '', Chris@0: 'help' => FALSE, Chris@0: 'list' => FALSE, Chris@0: 'list-files' => FALSE, Chris@0: 'list-files-json' => FALSE, Chris@0: 'clean' => FALSE, Chris@0: 'url' => '', Chris@0: 'sqlite' => NULL, Chris@0: 'dburl' => NULL, Chris@0: 'php' => '', Chris@0: 'concurrency' => 1, Chris@0: 'all' => FALSE, Chris@0: 'module' => NULL, Chris@0: 'class' => FALSE, Chris@0: 'file' => FALSE, Chris@0: 'types' => [], Chris@0: 'directory' => NULL, Chris@0: 'color' => FALSE, Chris@0: 'verbose' => FALSE, Chris@0: 'keep-results' => FALSE, Chris@0: 'keep-results-table' => FALSE, Chris@14: 'test_names' => [], Chris@0: 'repeat' => 1, Chris@0: 'die-on-fail' => FALSE, Chris@12: 'suppress-deprecations' => FALSE, Chris@0: 'browser' => FALSE, Chris@0: // Used internally. Chris@0: 'test-id' => 0, Chris@0: 'execute-test' => '', Chris@0: 'xml' => '', Chris@0: 'non-html' => FALSE, Chris@14: ]; Chris@0: Chris@0: // Override with set values. Chris@0: $args['script'] = basename(array_shift($_SERVER['argv'])); Chris@0: Chris@0: $count = 0; Chris@0: while ($arg = array_shift($_SERVER['argv'])) { Chris@0: if (preg_match('/--(\S+)/', $arg, $matches)) { Chris@0: // Argument found. Chris@0: if (array_key_exists($matches[1], $args)) { Chris@0: // Argument found in list. Chris@0: $previous_arg = $matches[1]; Chris@0: if (is_bool($args[$previous_arg])) { Chris@0: $args[$matches[1]] = TRUE; Chris@0: } Chris@0: elseif (is_array($args[$previous_arg])) { Chris@0: $value = array_shift($_SERVER['argv']); Chris@0: $args[$matches[1]] = array_map('trim', explode(',', $value)); Chris@0: } Chris@0: else { Chris@0: $args[$matches[1]] = array_shift($_SERVER['argv']); Chris@0: } Chris@0: // Clear extraneous values. Chris@14: $args['test_names'] = []; Chris@0: $count++; Chris@0: } Chris@0: else { Chris@0: // Argument not found in list. Chris@0: simpletest_script_print_error("Unknown argument '$arg'."); Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_FAILURE); Chris@0: } Chris@0: } Chris@0: else { Chris@0: // Values found without an argument should be test names. Chris@0: $args['test_names'] += explode(',', $arg); Chris@0: $count++; Chris@0: } Chris@0: } Chris@0: Chris@0: // Validate the concurrency argument. Chris@0: if (!is_numeric($args['concurrency']) || $args['concurrency'] <= 0) { Chris@0: simpletest_script_print_error("--concurrency must be a strictly positive integer."); Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_FAILURE); Chris@0: } Chris@0: Chris@0: if ($args['browser']) { Chris@0: $args['keep-results'] = TRUE; Chris@0: } Chris@14: return [$args, $count]; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Initialize script variables and perform general setup requirements. Chris@0: */ Chris@0: function simpletest_script_init() { Chris@0: global $args, $php; Chris@0: Chris@0: $host = 'localhost'; Chris@0: $path = ''; Chris@0: $port = '80'; Chris@0: Chris@0: // Determine location of php command automatically, unless a command line Chris@0: // argument is supplied. Chris@0: if (!empty($args['php'])) { Chris@0: $php = $args['php']; Chris@0: } Chris@0: elseif ($php_env = getenv('_')) { Chris@0: // '_' is an environment variable set by the shell. It contains the command Chris@0: // that was executed. Chris@0: $php = $php_env; Chris@0: } Chris@0: elseif ($sudo = getenv('SUDO_COMMAND')) { Chris@0: // 'SUDO_COMMAND' is an environment variable set by the sudo program. Chris@0: // Extract only the PHP interpreter, not the rest of the command. Chris@0: list($php) = explode(' ', $sudo, 2); Chris@0: } Chris@0: else { Chris@0: simpletest_script_print_error('Unable to automatically determine the path to the PHP interpreter. Supply the --php command line argument.'); Chris@0: simpletest_script_help(); Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_FAILURE); Chris@0: } Chris@0: Chris@14: // Detect if we're in the top-level process using the private 'execute-test' Chris@14: // argument. Determine if being run on drupal.org's testing infrastructure Chris@14: // using the presence of 'drupaltestbot' in the database url. Chris@14: // @todo https://www.drupal.org/project/drupalci_testbot/issues/2860941 Use Chris@14: // better environment variable to detect DrupalCI. Chris@14: // @todo https://www.drupal.org/project/drupal/issues/2942473 Remove when Chris@14: // dropping PHPUnit 4 and PHP 5 support. Chris@14: if (!$args['execute-test'] && preg_match('/drupalci/', $args['sqlite'])) { Chris@14: // Update PHPUnit if needed and possible. There is a later check once the Chris@14: // autoloader is in place to ensure we're on the correct version. We need to Chris@14: // do this before the autoloader is in place to ensure that it is correct. Chris@14: $composer = ($composer = rtrim('\\' === DIRECTORY_SEPARATOR ? preg_replace('/[\r\n].*/', '', `where.exe composer.phar`) : `which composer.phar`)) Chris@14: ? $php . ' ' . escapeshellarg($composer) Chris@14: : 'composer'; Chris@14: passthru("$composer run-script drupal-phpunit-upgrade-check"); Chris@14: } Chris@14: Chris@14: $autoloader = require_once __DIR__ . '/../../autoload.php'; Chris@14: Chris@0: // Get URL from arguments. Chris@0: if (!empty($args['url'])) { Chris@0: $parsed_url = parse_url($args['url']); Chris@0: $host = $parsed_url['host'] . (isset($parsed_url['port']) ? ':' . $parsed_url['port'] : ''); Chris@0: $path = isset($parsed_url['path']) ? rtrim(rtrim($parsed_url['path']), '/') : ''; Chris@0: $port = (isset($parsed_url['port']) ? $parsed_url['port'] : $port); Chris@0: if ($path == '/') { Chris@0: $path = ''; Chris@0: } Chris@0: // If the passed URL schema is 'https' then setup the $_SERVER variables Chris@0: // properly so that testing will run under HTTPS. Chris@0: if ($parsed_url['scheme'] == 'https') { Chris@0: $_SERVER['HTTPS'] = 'on'; Chris@0: } Chris@0: } Chris@0: Chris@0: if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') { Chris@0: $base_url = 'https://'; Chris@0: } Chris@0: else { Chris@0: $base_url = 'http://'; Chris@0: } Chris@0: $base_url .= $host; Chris@0: if ($path !== '') { Chris@0: $base_url .= $path; Chris@0: } Chris@0: putenv('SIMPLETEST_BASE_URL=' . $base_url); Chris@0: $_SERVER['HTTP_HOST'] = $host; Chris@0: $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; Chris@0: $_SERVER['SERVER_ADDR'] = '127.0.0.1'; Chris@0: $_SERVER['SERVER_PORT'] = $port; Chris@0: $_SERVER['SERVER_SOFTWARE'] = NULL; Chris@0: $_SERVER['SERVER_NAME'] = 'localhost'; Chris@0: $_SERVER['REQUEST_URI'] = $path . '/'; Chris@0: $_SERVER['REQUEST_METHOD'] = 'GET'; Chris@0: $_SERVER['SCRIPT_NAME'] = $path . '/index.php'; Chris@0: $_SERVER['SCRIPT_FILENAME'] = $path . '/index.php'; Chris@0: $_SERVER['PHP_SELF'] = $path . '/index.php'; Chris@0: $_SERVER['HTTP_USER_AGENT'] = 'Drupal command line'; Chris@0: Chris@0: if ($args['concurrency'] > 1) { Chris@0: $directory = FileSystem::getOsTemporaryDirectory(); Chris@0: $test_symlink = @symlink(__FILE__, $directory . '/test_symlink'); Chris@0: if (!$test_symlink) { Chris@0: throw new \RuntimeException('In order to use a concurrency higher than 1 the test system needs to be able to create symlinks in ' . $directory); Chris@0: } Chris@0: unlink($directory . '/test_symlink'); Chris@0: putenv('RUN_TESTS_CONCURRENCY=' . $args['concurrency']); Chris@0: } Chris@0: Chris@0: if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') { Chris@0: // Ensure that any and all environment variables are changed to https://. Chris@0: foreach ($_SERVER as $key => $value) { Chris@0: $_SERVER[$key] = str_replace('http://', 'https://', $_SERVER[$key]); Chris@0: } Chris@0: } Chris@0: Chris@0: chdir(realpath(__DIR__ . '/../..')); Chris@14: Chris@14: // Prepare the kernel. Chris@14: try { Chris@14: $request = Request::createFromGlobals(); Chris@14: $kernel = TestRunnerKernel::createFromRequest($request, $autoloader); Chris@14: $kernel->prepareLegacyRequest($request); Chris@14: } Chris@14: catch (Exception $e) { Chris@14: echo (string) $e; Chris@14: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@14: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Sets up database connection info for running tests. Chris@0: * Chris@0: * If this script is executed from within a real Drupal installation, then this Chris@0: * function essentially performs nothing (unless the --sqlite or --dburl Chris@0: * parameters were passed). Chris@0: * Chris@0: * Otherwise, there are three database connections of concern: Chris@0: * - --sqlite: The test runner connection, providing access to Simpletest Chris@0: * database tables for recording test IDs and assertion results. Chris@0: * - --dburl: A database connection that is used as base connection info for all Chris@0: * tests; i.e., every test will spawn from this connection. In case this Chris@0: * connection uses e.g. SQLite, then all tests will run against SQLite. This Chris@0: * is exposed as $databases['default']['default'] to Drupal. Chris@0: * - The actual database connection used within a test. This is the same as Chris@0: * --dburl, but uses an additional database table prefix. This is Chris@0: * $databases['default']['default'] within a test environment. The original Chris@0: * connection is retained in Chris@0: * $databases['simpletest_original_default']['default'] and restored after Chris@0: * each test. Chris@0: * Chris@0: * @param bool $new Chris@0: * Whether this process is a run-tests.sh master process. If TRUE, the SQLite Chris@0: * database file specified by --sqlite (if any) is set up. Otherwise, database Chris@0: * connections are prepared only. Chris@0: */ Chris@0: function simpletest_script_setup_database($new = FALSE) { Chris@0: global $args; Chris@0: Chris@0: // If there is an existing Drupal installation that contains a database Chris@0: // connection info in settings.php, then $databases['default']['default'] will Chris@0: // hold the default database connection already. This connection is assumed to Chris@0: // be valid, and this connection will be used in tests, so that they run Chris@0: // against e.g. MySQL instead of SQLite. Chris@0: // However, in case no Drupal installation exists, this default database Chris@0: // connection can be set and/or overridden with the --dburl parameter. Chris@0: if (!empty($args['dburl'])) { Chris@0: // Remove a possibly existing default connection (from settings.php). Chris@0: Database::removeConnection('default'); Chris@0: try { Chris@0: $databases['default']['default'] = Database::convertDbUrlToConnectionInfo($args['dburl'], DRUPAL_ROOT); Chris@0: } Chris@0: catch (\InvalidArgumentException $e) { Chris@0: simpletest_script_print_error('Invalid --dburl. Reason: ' . $e->getMessage()); Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_FAILURE); Chris@0: } Chris@0: } Chris@0: // Otherwise, use the default database connection from settings.php. Chris@0: else { Chris@0: $databases['default'] = Database::getConnectionInfo('default'); Chris@0: } Chris@0: Chris@0: // If there is no default database connection for tests, we cannot continue. Chris@0: if (!isset($databases['default']['default'])) { Chris@0: simpletest_script_print_error('Missing default database connection for tests. Use --dburl to specify one.'); Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_FAILURE); Chris@0: } Chris@0: Database::addConnectionInfo('default', 'default', $databases['default']['default']); Chris@0: Chris@0: // If no --sqlite parameter has been passed, then Simpletest module is assumed Chris@0: // to be installed, so the test runner database connection is the default Chris@0: // database connection. Chris@0: if (empty($args['sqlite'])) { Chris@0: $sqlite = FALSE; Chris@0: $databases['test-runner']['default'] = $databases['default']['default']; Chris@0: } Chris@0: // Otherwise, set up a SQLite connection for the test runner. Chris@0: else { Chris@0: if ($args['sqlite'][0] === '/') { Chris@0: $sqlite = $args['sqlite']; Chris@0: } Chris@0: else { Chris@0: $sqlite = DRUPAL_ROOT . '/' . $args['sqlite']; Chris@0: } Chris@14: $databases['test-runner']['default'] = [ Chris@0: 'driver' => 'sqlite', Chris@0: 'database' => $sqlite, Chris@14: 'prefix' => [ Chris@0: 'default' => '', Chris@14: ], Chris@14: ]; Chris@0: // Create the test runner SQLite database, unless it exists already. Chris@0: if ($new && !file_exists($sqlite)) { Chris@0: if (!is_dir(dirname($sqlite))) { Chris@0: mkdir(dirname($sqlite)); Chris@0: } Chris@0: touch($sqlite); Chris@0: } Chris@0: } Chris@0: Chris@0: // Add the test runner database connection. Chris@0: Database::addConnectionInfo('test-runner', 'default', $databases['test-runner']['default']); Chris@0: Chris@0: // Create the Simpletest schema. Chris@0: try { Chris@0: $connection = Database::getConnection('default', 'test-runner'); Chris@0: $schema = $connection->schema(); Chris@0: } Chris@0: catch (\PDOException $e) { Chris@0: simpletest_script_print_error($databases['test-runner']['default']['driver'] . ': ' . $e->getMessage()); Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_FAILURE); Chris@0: } Chris@0: if ($new && $sqlite) { Chris@0: require_once DRUPAL_ROOT . '/' . drupal_get_path('module', 'simpletest') . '/simpletest.install'; Chris@0: foreach (simpletest_schema() as $name => $table_spec) { Chris@0: try { Chris@0: $table_exists = $schema->tableExists($name); Chris@0: if (empty($args['keep-results-table']) && $table_exists) { Chris@0: $connection->truncate($name)->execute(); Chris@0: } Chris@0: if (!$table_exists) { Chris@0: $schema->createTable($name, $table_spec); Chris@0: } Chris@0: } Chris@0: catch (Exception $e) { Chris@0: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@0: } Chris@0: } Chris@0: // Verify that the Simpletest database schema exists by checking one table. Chris@0: try { Chris@0: if (!$schema->tableExists('simpletest')) { Chris@0: simpletest_script_print_error('Missing Simpletest database schema. Either install Simpletest module or use the --sqlite parameter.'); Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_FAILURE); Chris@0: } Chris@0: } Chris@0: catch (Exception $e) { Chris@0: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Execute a batch of tests. Chris@0: */ Chris@0: function simpletest_script_execute_batch($test_classes) { Chris@0: global $args, $test_ids; Chris@0: Chris@0: $total_status = SIMPLETEST_SCRIPT_EXIT_SUCCESS; Chris@0: Chris@0: // Multi-process execution. Chris@14: $children = []; Chris@0: while (!empty($test_classes) || !empty($children)) { Chris@0: while (count($children) < $args['concurrency']) { Chris@0: if (empty($test_classes)) { Chris@0: break; Chris@0: } Chris@0: Chris@0: try { Chris@0: $test_id = Database::getConnection('default', 'test-runner') Chris@0: ->insert('simpletest_test_id') Chris@14: ->useDefaults(['test_id']) Chris@0: ->execute(); Chris@0: } Chris@0: catch (Exception $e) { Chris@0: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@0: $test_ids[] = $test_id; Chris@0: Chris@0: $test_class = array_shift($test_classes); Chris@0: // Fork a child process. Chris@0: $command = simpletest_script_command($test_id, $test_class); Chris@14: $process = proc_open($command, [], $pipes, NULL, NULL, ['bypass_shell' => TRUE]); Chris@0: Chris@0: if (!is_resource($process)) { Chris@0: echo "Unable to fork test process. Aborting.\n"; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_SUCCESS); Chris@0: } Chris@0: Chris@0: // Register our new child. Chris@14: $children[] = [ Chris@0: 'process' => $process, Chris@0: 'test_id' => $test_id, Chris@0: 'class' => $test_class, Chris@0: 'pipes' => $pipes, Chris@14: ]; Chris@0: } Chris@0: Chris@0: // Wait for children every 200ms. Chris@0: usleep(200000); Chris@0: Chris@0: // Check if some children finished. Chris@0: foreach ($children as $cid => $child) { Chris@0: $status = proc_get_status($child['process']); Chris@0: if (empty($status['running'])) { Chris@0: // The child exited, unregister it. Chris@0: proc_close($child['process']); Chris@0: if ($status['exitcode'] === SIMPLETEST_SCRIPT_EXIT_FAILURE) { Chris@0: $total_status = max($status['exitcode'], $total_status); Chris@0: } Chris@0: elseif ($status['exitcode']) { Chris@0: $message = 'FATAL ' . $child['class'] . ': test runner returned a non-zero error code (' . $status['exitcode'] . ').'; Chris@0: echo $message . "\n"; Chris@0: // @todo Return SIMPLETEST_SCRIPT_EXIT_EXCEPTION instead, when Chris@0: // DrupalCI supports this. Chris@0: // @see https://www.drupal.org/node/2780087 Chris@0: $total_status = max(SIMPLETEST_SCRIPT_EXIT_FAILURE, $total_status); Chris@0: // Insert a fail for xml results. Chris@0: TestBase::insertAssert($child['test_id'], $child['class'], FALSE, $message, 'run-tests.sh check'); Chris@0: // Ensure that an error line is displayed for the class. Chris@0: simpletest_script_reporter_display_summary( Chris@0: $child['class'], Chris@0: ['#pass' => 0, '#fail' => 1, '#exception' => 0, '#debug' => 0] Chris@0: ); Chris@0: if ($args['die-on-fail']) { Chris@0: list($db_prefix) = simpletest_last_test_get($child['test_id']); Chris@0: $test_db = new TestDatabase($db_prefix); Chris@0: $test_directory = $test_db->getTestSitePath(); Chris@0: echo '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"; Chris@0: $args['keep-results'] = TRUE; Chris@0: // Exit repeat loop immediately. Chris@0: $args['repeat'] = -1; Chris@0: } Chris@0: } Chris@0: // Free-up space by removing any potentially created resources. Chris@0: if (!$args['keep-results']) { Chris@0: simpletest_script_cleanup($child['test_id'], $child['class'], $status['exitcode']); Chris@0: } Chris@0: Chris@0: // Remove this child. Chris@0: unset($children[$cid]); Chris@0: } Chris@0: } Chris@0: } Chris@0: return $total_status; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Run a PHPUnit-based test. Chris@0: */ Chris@0: function simpletest_script_run_phpunit($test_id, $class) { Chris@0: $reflection = new \ReflectionClass($class); Chris@0: if ($reflection->hasProperty('runLimit')) { Chris@0: set_time_limit($reflection->getStaticPropertyValue('runLimit')); Chris@0: } Chris@0: Chris@14: $results = simpletest_run_phpunit_tests($test_id, [$class], $status); Chris@0: simpletest_process_phpunit_results($results); Chris@0: Chris@0: // Map phpunit results to a data structure we can pass to Chris@0: // _simpletest_format_summary_line. Chris@0: $summaries = simpletest_summarize_phpunit_result($results); Chris@0: foreach ($summaries as $class => $summary) { Chris@0: simpletest_script_reporter_display_summary($class, $summary); Chris@0: } Chris@0: return $status; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Run a single test, bootstrapping Drupal if needed. Chris@0: */ Chris@0: function simpletest_script_run_one_test($test_id, $test_class) { Chris@0: global $args; Chris@0: Chris@0: try { Chris@0: if (strpos($test_class, '::') > 0) { Chris@0: list($class_name, $method) = explode('::', $test_class, 2); Chris@0: $methods = [$method]; Chris@0: } Chris@0: else { Chris@0: $class_name = $test_class; Chris@0: // Use empty array to run all the test methods. Chris@14: $methods = []; Chris@0: } Chris@0: $test = new $class_name($test_id); Chris@12: if ($args['suppress-deprecations']) { Chris@12: putenv('SYMFONY_DEPRECATIONS_HELPER=disabled'); Chris@12: } Chris@0: if (is_subclass_of($test_class, TestCase::class)) { Chris@0: $status = simpletest_script_run_phpunit($test_id, $test_class); Chris@0: } Chris@0: else { Chris@0: $test->dieOnFail = (bool) $args['die-on-fail']; Chris@0: $test->verbose = (bool) $args['verbose']; Chris@0: $test->run($methods); Chris@0: simpletest_script_reporter_display_summary($test_class, $test->results); Chris@0: Chris@0: $status = SIMPLETEST_SCRIPT_EXIT_SUCCESS; Chris@0: // Finished, kill this runner. Chris@0: if ($test->results['#fail'] || $test->results['#exception']) { Chris@0: $status = SIMPLETEST_SCRIPT_EXIT_FAILURE; Chris@0: } Chris@0: } Chris@0: Chris@0: exit($status); Chris@0: } Chris@0: // DrupalTestCase::run() catches exceptions already, so this is only reached Chris@0: // when an exception is thrown in the wrapping test runner environment. Chris@0: catch (Exception $e) { Chris@0: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Return a command used to run a test in a separate process. Chris@0: * Chris@0: * @param int $test_id Chris@0: * The current test ID. Chris@0: * @param string $test_class Chris@0: * The name of the test class to run. Chris@0: * Chris@0: * @return string Chris@0: * The assembled command string. Chris@0: */ Chris@0: function simpletest_script_command($test_id, $test_class) { Chris@0: global $args, $php; Chris@0: Chris@0: $command = escapeshellarg($php) . ' ' . escapeshellarg('./core/scripts/' . $args['script']); Chris@0: $command .= ' --url ' . escapeshellarg($args['url']); Chris@0: if (!empty($args['sqlite'])) { Chris@0: $command .= ' --sqlite ' . escapeshellarg($args['sqlite']); Chris@0: } Chris@0: if (!empty($args['dburl'])) { Chris@0: $command .= ' --dburl ' . escapeshellarg($args['dburl']); Chris@0: } Chris@0: $command .= ' --php ' . escapeshellarg($php); Chris@0: $command .= " --test-id $test_id"; Chris@14: foreach (['verbose', 'keep-results', 'color', 'die-on-fail', 'suppress-deprecations'] as $arg) { Chris@0: if ($args[$arg]) { Chris@0: $command .= ' --' . $arg; Chris@0: } Chris@0: } Chris@0: // --execute-test and class name needs to come last. Chris@0: $command .= ' --execute-test ' . escapeshellarg($test_class); Chris@0: return $command; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Removes all remnants of a test runner. Chris@0: * Chris@0: * In case a (e.g., fatal) error occurs after the test site has been fully setup Chris@0: * and the error happens in many tests, the environment that executes the tests Chris@0: * can easily run out of memory or disk space. This function ensures that all Chris@0: * created resources are properly cleaned up after every executed test. Chris@0: * Chris@0: * This clean-up only exists in this script, since SimpleTest module itself does Chris@0: * not use isolated sub-processes for each test being run, so a fatal error Chris@0: * halts not only the test, but also the test runner (i.e., the parent site). Chris@0: * Chris@0: * @param int $test_id Chris@0: * The test ID of the test run. Chris@0: * @param string $test_class Chris@0: * The class name of the test run. Chris@0: * @param int $exitcode Chris@0: * The exit code of the test runner. Chris@0: * Chris@0: * @see simpletest_script_run_one_test() Chris@0: */ Chris@0: function simpletest_script_cleanup($test_id, $test_class, $exitcode) { Chris@0: if (is_subclass_of($test_class, TestCase::class)) { Chris@0: // PHPUnit test, move on. Chris@0: return; Chris@0: } Chris@0: // Retrieve the last database prefix used for testing. Chris@0: try { Chris@0: list($db_prefix) = simpletest_last_test_get($test_id); Chris@0: } Chris@0: catch (Exception $e) { Chris@0: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@0: Chris@0: // If no database prefix was found, then the test was not set up correctly. Chris@0: if (empty($db_prefix)) { Chris@0: echo "\nFATAL $test_class: Found no database prefix for test ID $test_id. (Check whether setUp() is invoked correctly.)"; Chris@0: return; Chris@0: } Chris@0: Chris@0: // Do not output verbose cleanup messages in case of a positive exitcode. Chris@0: $output = !empty($exitcode); Chris@14: $messages = []; Chris@0: Chris@0: $messages[] = "- Found database prefix '$db_prefix' for test ID $test_id."; Chris@0: Chris@0: // Read the log file in case any fatal errors caused the test to crash. Chris@0: try { Chris@0: simpletest_log_read($test_id, $db_prefix, $test_class); Chris@0: } Chris@0: catch (Exception $e) { Chris@0: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@0: Chris@0: // Check whether a test site directory was setup already. Chris@0: // @see \Drupal\simpletest\TestBase::prepareEnvironment() Chris@0: $test_db = new TestDatabase($db_prefix); Chris@0: $test_directory = DRUPAL_ROOT . '/' . $test_db->getTestSitePath(); Chris@0: if (is_dir($test_directory)) { Chris@0: // Output the error_log. Chris@0: if (is_file($test_directory . '/error.log')) { Chris@0: if ($errors = file_get_contents($test_directory . '/error.log')) { Chris@0: $output = TRUE; Chris@0: $messages[] = $errors; Chris@0: } Chris@0: } Chris@0: // Delete the test site directory. Chris@0: // simpletest_clean_temporary_directories() cannot be used here, since it Chris@0: // would also delete file directories of other tests that are potentially Chris@0: // running concurrently. Chris@18: try { Chris@18: \Drupal::service('file_system')->deleteRecursive($test_directory, ['Drupal\simpletest\TestBase', 'filePreDeleteCallback']); Chris@18: $messages[] = "- Removed test site directory."; Chris@18: } Chris@18: catch (FileException $e) { Chris@18: // Ignore failed deletes. Chris@18: } Chris@0: } Chris@0: Chris@0: // Clear out all database tables from the test. Chris@0: try { Chris@0: $schema = Database::getConnection('default', 'default')->schema(); Chris@0: $count = 0; Chris@0: foreach ($schema->findTables($db_prefix . '%') as $table) { Chris@0: $schema->dropTable($table); Chris@0: $count++; Chris@0: } Chris@0: } Chris@0: catch (Exception $e) { Chris@0: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@0: Chris@0: if ($count) { Chris@0: $messages[] = "- Removed $count leftover tables."; Chris@0: } Chris@0: Chris@0: if ($output) { Chris@0: echo implode("\n", $messages); Chris@0: echo "\n"; Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Get list of tests based on arguments. Chris@0: * Chris@0: * If --all specified then return all available tests, otherwise reads list of Chris@0: * tests. Chris@0: * Chris@0: * @return array Chris@0: * List of tests. Chris@0: */ Chris@0: function simpletest_script_get_test_list() { Chris@0: global $args; Chris@0: Chris@17: /** $test_discovery \Drupal\simpletest\TestDiscovery */ Chris@17: $test_discovery = \Drupal::service('test_discovery'); Chris@0: $types_processed = empty($args['types']); Chris@14: $test_list = []; Chris@0: if ($args['all'] || $args['module']) { Chris@0: try { Chris@17: $groups = $test_discovery->getTestClasses($args['module'], $args['types']); Chris@0: $types_processed = TRUE; Chris@0: } Chris@0: catch (Exception $e) { Chris@0: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@14: $all_tests = []; Chris@0: foreach ($groups as $group => $tests) { Chris@0: $all_tests = array_merge($all_tests, array_keys($tests)); Chris@0: } Chris@18: $test_list = array_unique($all_tests); Chris@0: } Chris@0: else { Chris@0: if ($args['class']) { Chris@14: $test_list = []; Chris@0: foreach ($args['test_names'] as $test_class) { Chris@0: list($class_name) = explode('::', $test_class, 2); Chris@0: if (class_exists($class_name)) { Chris@0: $test_list[] = $test_class; Chris@0: } Chris@0: else { Chris@0: try { Chris@17: $groups = $test_discovery->getTestClasses(NULL, $args['types']); Chris@0: } Chris@0: catch (Exception $e) { Chris@0: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@14: $all_classes = []; Chris@0: foreach ($groups as $group) { Chris@0: $all_classes = array_merge($all_classes, array_keys($group)); Chris@0: } Chris@0: simpletest_script_print_error('Test class not found: ' . $class_name); Chris@0: simpletest_script_print_alternatives($class_name, $all_classes, 6); Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_FAILURE); Chris@0: } Chris@0: } Chris@0: } Chris@0: elseif ($args['file']) { Chris@0: // Extract test case class names from specified files. Chris@0: foreach ($args['test_names'] as $file) { Chris@0: if (!file_exists($file)) { Chris@0: simpletest_script_print_error('File not found: ' . $file); Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_FAILURE); Chris@0: } Chris@0: $content = file_get_contents($file); Chris@0: // Extract a potential namespace. Chris@0: $namespace = FALSE; Chris@0: if (preg_match('@^namespace ([^ ;]+)@m', $content, $matches)) { Chris@0: $namespace = $matches[1]; Chris@0: } Chris@0: // Extract all class names. Chris@0: // Abstract classes are excluded on purpose. Chris@0: preg_match_all('@^class ([^ ]+)@m', $content, $matches); Chris@0: if (!$namespace) { Chris@0: $test_list = array_merge($test_list, $matches[1]); Chris@0: } Chris@0: else { Chris@0: foreach ($matches[1] as $class_name) { Chris@0: $namespace_class = $namespace . '\\' . $class_name; Chris@0: if (is_subclass_of($namespace_class, '\Drupal\simpletest\TestBase') || is_subclass_of($namespace_class, TestCase::class)) { Chris@0: $test_list[] = $namespace_class; Chris@0: } Chris@0: } Chris@0: } Chris@0: } Chris@0: } Chris@0: elseif ($args['directory']) { Chris@0: // Extract test case class names from specified directory. Chris@0: // Find all tests in the PSR-X structure; Drupal\$extension\Tests\*.php Chris@0: // Since we do not want to hard-code too many structural file/directory Chris@0: // assumptions about PSR-0/4 files and directories, we check for the Chris@0: // minimal conditions only; i.e., a '*.php' file that has '/Tests/' in Chris@0: // its path. Chris@0: // Ignore anything from third party vendors. Chris@14: $ignore = ['.', '..', 'vendor']; Chris@0: $files = []; Chris@0: if ($args['directory'][0] === '/') { Chris@0: $directory = $args['directory']; Chris@0: } Chris@0: else { Chris@0: $directory = DRUPAL_ROOT . "/" . $args['directory']; Chris@0: } Chris@0: foreach (file_scan_directory($directory, '/\.php$/', $ignore) as $file) { Chris@0: // '/Tests/' can be contained anywhere in the file's path (there can be Chris@0: // sub-directories below /Tests), but must be contained literally. Chris@0: // Case-insensitive to match all Simpletest and PHPUnit tests: Chris@0: // ./lib/Drupal/foo/Tests/Bar/Baz.php Chris@0: // ./foo/src/Tests/Bar/Baz.php Chris@0: // ./foo/tests/Drupal/foo/Tests/FooTest.php Chris@0: // ./foo/tests/src/FooTest.php Chris@0: // $file->filename doesn't give us a directory, so we use $file->uri Chris@0: // Strip the drupal root directory and trailing slash off the URI. Chris@0: $filename = substr($file->uri, strlen(DRUPAL_ROOT) + 1); Chris@0: if (stripos($filename, '/Tests/')) { Chris@0: $files[$filename] = $filename; Chris@0: } Chris@0: } Chris@0: foreach ($files as $file) { Chris@0: $content = file_get_contents($file); Chris@0: // Extract a potential namespace. Chris@0: $namespace = FALSE; Chris@0: if (preg_match('@^\s*namespace ([^ ;]+)@m', $content, $matches)) { Chris@0: $namespace = $matches[1]; Chris@0: } Chris@0: // Extract all class names. Chris@0: // Abstract classes are excluded on purpose. Chris@0: preg_match_all('@^\s*class ([^ ]+)@m', $content, $matches); Chris@0: if (!$namespace) { Chris@0: $test_list = array_merge($test_list, $matches[1]); Chris@0: } Chris@0: else { Chris@0: foreach ($matches[1] as $class_name) { Chris@0: $namespace_class = $namespace . '\\' . $class_name; Chris@0: if (is_subclass_of($namespace_class, '\Drupal\simpletest\TestBase') || is_subclass_of($namespace_class, TestCase::class)) { Chris@0: $test_list[] = $namespace_class; Chris@0: } Chris@0: } Chris@0: } Chris@0: } Chris@0: } Chris@0: else { Chris@0: try { Chris@17: $groups = $test_discovery->getTestClasses(NULL, $args['types']); Chris@0: $types_processed = TRUE; Chris@0: } Chris@0: catch (Exception $e) { Chris@0: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@18: // Store all the groups so we can suggest alternatives if we need to. Chris@18: $all_groups = array_keys($groups); Chris@18: // Verify that the groups exist. Chris@18: if (!empty($unknown_groups = array_diff($args['test_names'], $all_groups))) { Chris@18: $first_group = reset($unknown_groups); Chris@18: simpletest_script_print_error('Test group not found: ' . $first_group); Chris@18: simpletest_script_print_alternatives($first_group, $all_groups); Chris@18: exit(SIMPLETEST_SCRIPT_EXIT_FAILURE); Chris@18: } Chris@18: // Ensure our list of tests contains only one entry for each test. Chris@0: foreach ($args['test_names'] as $group_name) { Chris@18: $test_list = array_merge($test_list, array_flip(array_keys($groups[$group_name]))); Chris@0: } Chris@18: $test_list = array_flip($test_list); Chris@0: } Chris@0: } Chris@0: Chris@0: // If the test list creation does not automatically limit by test type then Chris@0: // we need to do so here. Chris@0: if (!$types_processed) { Chris@0: $test_list = array_filter($test_list, function ($test_class) use ($args) { Chris@0: $test_info = TestDiscovery::getTestInfo($test_class); Chris@0: return in_array($test_info['type'], $args['types'], TRUE); Chris@0: }); Chris@0: } Chris@0: Chris@0: if (empty($test_list)) { Chris@0: simpletest_script_print_error('No valid tests were specified.'); Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_FAILURE); Chris@0: } Chris@0: return $test_list; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Initialize the reporter. Chris@0: */ Chris@0: function simpletest_script_reporter_init() { Chris@0: global $args, $test_list, $results_map; Chris@0: Chris@14: $results_map = [ Chris@0: 'pass' => 'Pass', Chris@0: 'fail' => 'Fail', Chris@0: 'exception' => 'Exception', Chris@14: ]; Chris@0: Chris@0: echo "\n"; Chris@0: echo "Drupal test run\n"; Chris@0: echo "---------------\n"; Chris@0: echo "\n"; Chris@0: Chris@0: // Tell the user about what tests are to be run. Chris@0: if ($args['all']) { Chris@0: echo "All tests will run.\n\n"; Chris@0: } Chris@0: else { Chris@0: echo "Tests to be run:\n"; Chris@0: foreach ($test_list as $class_name) { Chris@0: echo " - $class_name\n"; Chris@0: } Chris@0: echo "\n"; Chris@0: } Chris@0: Chris@0: echo "Test run started:\n"; Chris@0: echo " " . date('l, F j, Y - H:i', $_SERVER['REQUEST_TIME']) . "\n"; Chris@0: Timer::start('run-tests'); Chris@0: echo "\n"; Chris@0: Chris@0: echo "Test summary\n"; Chris@0: echo "------------\n"; Chris@0: echo "\n"; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Displays the assertion result summary for a single test class. Chris@0: * Chris@0: * @param string $class Chris@0: * The test class name that was run. Chris@0: * @param array $results Chris@0: * The assertion results using #pass, #fail, #exception, #debug array keys. Chris@0: */ Chris@0: function simpletest_script_reporter_display_summary($class, $results) { Chris@0: // Output all test results vertically aligned. Chris@0: // Cut off the class name after 60 chars, and pad each group with 3 digits Chris@0: // by default (more than 999 assertions are rare). Chris@14: $output = vsprintf('%-60.60s %10s %9s %14s %12s', [ Chris@0: $class, Chris@0: $results['#pass'] . ' passes', Chris@0: !$results['#fail'] ? '' : $results['#fail'] . ' fails', Chris@0: !$results['#exception'] ? '' : $results['#exception'] . ' exceptions', Chris@0: !$results['#debug'] ? '' : $results['#debug'] . ' messages', Chris@14: ]); Chris@0: Chris@0: $status = ($results['#fail'] || $results['#exception'] ? 'fail' : 'pass'); Chris@0: simpletest_script_print($output . "\n", simpletest_script_color_code($status)); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Display jUnit XML test results. Chris@0: */ Chris@0: function simpletest_script_reporter_write_xml_results() { Chris@0: global $args, $test_ids, $results_map; Chris@0: Chris@0: try { Chris@0: $results = simpletest_script_load_messages_by_test_id($test_ids); Chris@0: } Chris@0: catch (Exception $e) { Chris@0: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@0: Chris@0: $test_class = ''; Chris@14: $xml_files = []; Chris@0: Chris@0: foreach ($results as $result) { Chris@0: if (isset($results_map[$result->status])) { Chris@0: if ($result->test_class != $test_class) { Chris@0: // We've moved onto a new class, so write the last classes results to a Chris@0: // file: Chris@0: if (isset($xml_files[$test_class])) { Chris@0: file_put_contents($args['xml'] . '/' . str_replace('\\', '_', $test_class) . '.xml', $xml_files[$test_class]['doc']->saveXML()); Chris@0: unset($xml_files[$test_class]); Chris@0: } Chris@0: $test_class = $result->test_class; Chris@0: if (!isset($xml_files[$test_class])) { Chris@0: $doc = new DomDocument('1.0'); Chris@0: $root = $doc->createElement('testsuite'); Chris@0: $root = $doc->appendChild($root); Chris@14: $xml_files[$test_class] = ['doc' => $doc, 'suite' => $root]; Chris@0: } Chris@0: } Chris@0: Chris@0: // For convenience: Chris@0: $dom_document = &$xml_files[$test_class]['doc']; Chris@0: Chris@0: // Create the XML element for this test case: Chris@0: $case = $dom_document->createElement('testcase'); Chris@0: $case->setAttribute('classname', $test_class); Chris@0: if (strpos($result->function, '->') !== FALSE) { Chris@0: list($class, $name) = explode('->', $result->function, 2); Chris@0: } Chris@0: else { Chris@0: $name = $result->function; Chris@0: } Chris@0: $case->setAttribute('name', $name); Chris@0: Chris@0: // Passes get no further attention, but failures and exceptions get to add Chris@0: // more detail: Chris@0: if ($result->status == 'fail') { Chris@0: $fail = $dom_document->createElement('failure'); Chris@0: $fail->setAttribute('type', 'failure'); Chris@0: $fail->setAttribute('message', $result->message_group); Chris@0: $text = $dom_document->createTextNode($result->message); Chris@0: $fail->appendChild($text); Chris@0: $case->appendChild($fail); Chris@0: } Chris@0: elseif ($result->status == 'exception') { Chris@0: // In the case of an exception the $result->function may not be a class Chris@0: // method so we record the full function name: Chris@0: $case->setAttribute('name', $result->function); Chris@0: Chris@0: $fail = $dom_document->createElement('error'); Chris@0: $fail->setAttribute('type', 'exception'); Chris@0: $fail->setAttribute('message', $result->message_group); Chris@0: $full_message = $result->message . "\n\nline: " . $result->line . "\nfile: " . $result->file; Chris@0: $text = $dom_document->createTextNode($full_message); Chris@0: $fail->appendChild($text); Chris@0: $case->appendChild($fail); Chris@0: } Chris@0: // Append the test case XML to the test suite: Chris@0: $xml_files[$test_class]['suite']->appendChild($case); Chris@0: } Chris@0: } Chris@0: // The last test case hasn't been saved to a file yet, so do that now: Chris@0: if (isset($xml_files[$test_class])) { Chris@0: file_put_contents($args['xml'] . '/' . str_replace('\\', '_', $test_class) . '.xml', $xml_files[$test_class]['doc']->saveXML()); Chris@0: unset($xml_files[$test_class]); Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Stop the test timer. Chris@0: */ Chris@0: function simpletest_script_reporter_timer_stop() { Chris@0: echo "\n"; Chris@0: $end = Timer::stop('run-tests'); Chris@0: echo "Test run duration: " . \Drupal::service('date.formatter')->formatInterval($end['time'] / 1000); Chris@0: echo "\n\n"; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Display test results. Chris@0: */ Chris@0: function simpletest_script_reporter_display_results() { Chris@0: global $args, $test_ids, $results_map; Chris@0: Chris@0: if ($args['verbose']) { Chris@0: // Report results. Chris@0: echo "Detailed test results\n"; Chris@0: echo "---------------------\n"; Chris@0: Chris@0: try { Chris@0: $results = simpletest_script_load_messages_by_test_id($test_ids); Chris@0: } Chris@0: catch (Exception $e) { Chris@0: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@0: $test_class = ''; Chris@0: foreach ($results as $result) { Chris@0: if (isset($results_map[$result->status])) { Chris@0: if ($result->test_class != $test_class) { Chris@0: // Display test class every time results are for new test class. Chris@0: echo "\n\n---- $result->test_class ----\n\n\n"; Chris@0: $test_class = $result->test_class; Chris@0: Chris@0: // Print table header. Chris@0: echo "Status Group Filename Line Function \n"; Chris@0: echo "--------------------------------------------------------------------------------\n"; Chris@0: } Chris@0: Chris@0: simpletest_script_format_result($result); Chris@0: } Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Format the result so that it fits within 80 characters. Chris@0: * Chris@0: * @param object $result Chris@0: * The result object to format. Chris@0: */ Chris@0: function simpletest_script_format_result($result) { Chris@0: global $args, $results_map, $color; Chris@0: Chris@0: $summary = sprintf("%-9.9s %-10.10s %-17.17s %4.4s %-35.35s\n", Chris@0: $results_map[$result->status], $result->message_group, basename($result->file), $result->line, $result->function); Chris@0: Chris@0: simpletest_script_print($summary, simpletest_script_color_code($result->status)); Chris@0: Chris@0: $message = trim(strip_tags($result->message)); Chris@0: if ($args['non-html']) { Chris@0: $message = Html::decodeEntities($message, ENT_QUOTES, 'UTF-8'); Chris@0: } Chris@0: $lines = explode("\n", wordwrap($message), 76); Chris@0: foreach ($lines as $line) { Chris@0: echo " $line\n"; Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Print error messages so the user will notice them. Chris@0: * Chris@0: * Print error message prefixed with " ERROR: " and displayed in fail color if Chris@0: * color output is enabled. Chris@0: * Chris@0: * @param string $message Chris@0: * The message to print. Chris@0: */ Chris@0: function simpletest_script_print_error($message) { Chris@0: simpletest_script_print(" ERROR: $message\n", SIMPLETEST_SCRIPT_COLOR_FAIL); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Print a message to the console, using a color. Chris@0: * Chris@0: * @param string $message Chris@0: * The message to print. Chris@0: * @param int $color_code Chris@0: * The color code to use for coloring. Chris@0: */ Chris@0: function simpletest_script_print($message, $color_code) { Chris@0: global $args; Chris@0: if ($args['color']) { Chris@0: echo "\033[" . $color_code . "m" . $message . "\033[0m"; Chris@0: } Chris@0: else { Chris@0: echo $message; Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Get the color code associated with the specified status. Chris@0: * Chris@0: * @param string $status Chris@0: * The status string to get code for. Special cases are: 'pass', 'fail', or Chris@0: * 'exception'. Chris@0: * Chris@0: * @return int Chris@0: * Color code. Returns 0 for default case. Chris@0: */ Chris@0: function simpletest_script_color_code($status) { Chris@0: switch ($status) { Chris@0: case 'pass': Chris@0: return SIMPLETEST_SCRIPT_COLOR_PASS; Chris@0: Chris@0: case 'fail': Chris@0: return SIMPLETEST_SCRIPT_COLOR_FAIL; Chris@0: Chris@0: case 'exception': Chris@0: return SIMPLETEST_SCRIPT_COLOR_EXCEPTION; Chris@0: } Chris@0: // Default formatting. Chris@0: return 0; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Prints alternative test names. Chris@0: * Chris@0: * Searches the provided array of string values for close matches based on the Chris@0: * Levenshtein algorithm. Chris@0: * Chris@0: * @param string $string Chris@0: * A string to test. Chris@0: * @param array $array Chris@0: * A list of strings to search. Chris@0: * @param int $degree Chris@0: * The matching strictness. Higher values return fewer matches. A value of Chris@0: * 4 means that the function will return strings from $array if the candidate Chris@0: * string in $array would be identical to $string by changing 1/4 or fewer of Chris@0: * its characters. Chris@0: * Chris@14: * @see http://php.net/manual/function.levenshtein.php Chris@0: */ Chris@0: function simpletest_script_print_alternatives($string, $array, $degree = 4) { Chris@14: $alternatives = []; Chris@0: foreach ($array as $item) { Chris@0: $lev = levenshtein($string, $item); Chris@0: if ($lev <= strlen($item) / $degree || FALSE !== strpos($string, $item)) { Chris@0: $alternatives[] = $item; Chris@0: } Chris@0: } Chris@0: if (!empty($alternatives)) { Chris@0: simpletest_script_print(" Did you mean?\n", SIMPLETEST_SCRIPT_COLOR_FAIL); Chris@0: foreach ($alternatives as $alternative) { Chris@0: simpletest_script_print(" - $alternative\n", SIMPLETEST_SCRIPT_COLOR_FAIL); Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Loads the simpletest messages from the database. Chris@0: * Chris@0: * Messages are ordered by test class and message id. Chris@0: * Chris@0: * @param array $test_ids Chris@0: * Array of test IDs of the messages to be loaded. Chris@0: * Chris@0: * @return array Chris@0: * Array of simpletest messages from the database. Chris@0: */ Chris@0: function simpletest_script_load_messages_by_test_id($test_ids) { Chris@0: global $args; Chris@14: $results = []; Chris@0: Chris@0: // Sqlite has a maximum number of variables per query. If required, the Chris@0: // database query is split into chunks. Chris@0: if (count($test_ids) > SIMPLETEST_SCRIPT_SQLITE_VARIABLE_LIMIT && !empty($args['sqlite'])) { Chris@0: $test_id_chunks = array_chunk($test_ids, SIMPLETEST_SCRIPT_SQLITE_VARIABLE_LIMIT); Chris@0: } Chris@0: else { Chris@14: $test_id_chunks = [$test_ids]; Chris@0: } Chris@0: Chris@0: foreach ($test_id_chunks as $test_id_chunk) { Chris@0: try { Chris@0: $result_chunk = Database::getConnection('default', 'test-runner') Chris@14: ->query("SELECT * FROM {simpletest} WHERE test_id IN ( :test_ids[] ) ORDER BY test_class, message_id", [ Chris@0: ':test_ids[]' => $test_id_chunk, Chris@14: ])->fetchAll(); Chris@0: } Chris@0: catch (Exception $e) { Chris@0: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@0: if ($result_chunk) { Chris@0: $results = array_merge($results, $result_chunk); Chris@0: } Chris@0: } Chris@0: Chris@0: return $results; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Display test results. Chris@0: */ Chris@0: function simpletest_script_open_browser() { Chris@0: global $test_ids; Chris@0: Chris@0: try { Chris@0: $connection = Database::getConnection('default', 'test-runner'); Chris@0: $results = $connection->select('simpletest') Chris@0: ->fields('simpletest') Chris@0: ->condition('test_id', $test_ids, 'IN') Chris@0: ->orderBy('test_class') Chris@0: ->orderBy('message_id') Chris@0: ->execute() Chris@0: ->fetchAll(); Chris@0: } Chris@0: catch (Exception $e) { Chris@0: echo (string) $e; Chris@0: exit(SIMPLETEST_SCRIPT_EXIT_EXCEPTION); Chris@0: } Chris@0: Chris@0: // Get the results form. Chris@14: $form = []; Chris@0: SimpletestResultsForm::addResultForm($form, $results); Chris@0: Chris@0: // Get the assets to make the details element collapsible and theme the result Chris@0: // form. Chris@14: $assets = new AttachedAssets(); Chris@0: $assets->setLibraries([ Chris@0: 'core/drupal.collapse', Chris@0: 'system/admin', Chris@0: 'simpletest/drupal.simpletest', Chris@0: ]); Chris@0: $resolver = \Drupal::service('asset.resolver'); Chris@0: list($js_assets_header, $js_assets_footer) = $resolver->getJsAssets($assets, FALSE); Chris@0: $js_collection_renderer = \Drupal::service('asset.js.collection_renderer'); Chris@0: $js_assets_header = $js_collection_renderer->render($js_assets_header); Chris@0: $js_assets_footer = $js_collection_renderer->render($js_assets_footer); Chris@0: $css_assets = \Drupal::service('asset.css.collection_renderer')->render($resolver->getCssAssets($assets, FALSE)); Chris@0: Chris@0: // Make the html page to write to disk. Chris@0: $render_service = \Drupal::service('renderer'); Chris@0: $html = '' . $render_service->renderPlain($js_assets_header) . $render_service->renderPlain($css_assets) . '' . $render_service->renderPlain($form) . $render_service->renderPlain($js_assets_footer) . ''; Chris@0: Chris@0: // Ensure we have assets verbose directory - tests with no verbose output will Chris@0: // not have created one. Chris@0: $directory = PublicStream::basePath() . '/simpletest/verbose'; Chris@18: \Drupal::service('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS); Chris@0: $php = new Php(); Chris@0: $uuid = $php->generate(); Chris@0: $filename = $directory . '/results-' . $uuid . '.html'; Chris@0: $base_url = getenv('SIMPLETEST_BASE_URL'); Chris@0: if (empty($base_url)) { Chris@0: simpletest_script_print_error("--browser needs argument --url."); Chris@0: } Chris@0: $url = $base_url . '/' . PublicStream::basePath() . '/simpletest/verbose/results-' . $uuid . '.html'; Chris@0: file_put_contents($filename, $html); Chris@0: Chris@0: // See if we can find an OS helper to open URLs in default browser. Chris@0: $browser = FALSE; Chris@0: if (shell_exec('which xdg-open')) { Chris@0: $browser = 'xdg-open'; Chris@0: } Chris@0: elseif (shell_exec('which open')) { Chris@0: $browser = 'open'; Chris@0: } Chris@0: elseif (substr(PHP_OS, 0, 3) == 'WIN') { Chris@0: $browser = 'start'; Chris@0: } Chris@0: Chris@0: if ($browser) { Chris@0: shell_exec($browser . ' ' . escapeshellarg($url)); Chris@0: } Chris@0: else { Chris@0: // Can't find assets valid browser. Chris@0: print 'Open file://' . realpath($filename) . ' in your browser to see the verbose output.'; Chris@0: } Chris@0: }