Chris@0: root = $root; Chris@0: $this->classLoader = $class_loader; Chris@0: $this->moduleHandler = $module_handler; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Registers test namespaces of all extensions and core test classes. Chris@0: * Chris@0: * @return array Chris@0: * An associative array whose keys are PSR-4 namespace prefixes and whose Chris@0: * values are directory names. Chris@0: */ Chris@0: public function registerTestNamespaces() { Chris@0: if (isset($this->testNamespaces)) { Chris@0: return $this->testNamespaces; Chris@0: } Chris@0: $this->testNamespaces = []; Chris@0: Chris@0: $existing = $this->classLoader->getPrefixesPsr4(); Chris@0: Chris@0: // Add PHPUnit test namespaces of Drupal core. Chris@0: $this->testNamespaces['Drupal\\Tests\\'] = [$this->root . '/core/tests/Drupal/Tests']; Chris@0: $this->testNamespaces['Drupal\\KernelTests\\'] = [$this->root . '/core/tests/Drupal/KernelTests']; Chris@0: $this->testNamespaces['Drupal\\FunctionalTests\\'] = [$this->root . '/core/tests/Drupal/FunctionalTests']; Chris@0: $this->testNamespaces['Drupal\\FunctionalJavascriptTests\\'] = [$this->root . '/core/tests/Drupal/FunctionalJavascriptTests']; Chris@0: Chris@0: $this->availableExtensions = []; Chris@0: foreach ($this->getExtensions() as $name => $extension) { Chris@0: $this->availableExtensions[$extension->getType()][$name] = $name; Chris@0: Chris@0: $base_path = $this->root . '/' . $extension->getPath(); Chris@0: Chris@0: // Add namespace of disabled/uninstalled extensions. Chris@0: if (!isset($existing["Drupal\\$name\\"])) { Chris@0: $this->classLoader->addPsr4("Drupal\\$name\\", "$base_path/src"); Chris@0: } Chris@0: // Add Simpletest test namespace. Chris@0: $this->testNamespaces["Drupal\\$name\\Tests\\"][] = "$base_path/src/Tests"; Chris@0: Chris@0: // Add PHPUnit test namespaces. Chris@0: $this->testNamespaces["Drupal\\Tests\\$name\\Unit\\"][] = "$base_path/tests/src/Unit"; Chris@0: $this->testNamespaces["Drupal\\Tests\\$name\\Kernel\\"][] = "$base_path/tests/src/Kernel"; Chris@0: $this->testNamespaces["Drupal\\Tests\\$name\\Functional\\"][] = "$base_path/tests/src/Functional"; Chris@0: $this->testNamespaces["Drupal\\Tests\\$name\\FunctionalJavascript\\"][] = "$base_path/tests/src/FunctionalJavascript"; Chris@0: Chris@0: // Add discovery for traits which are shared between different test Chris@0: // suites. Chris@0: $this->testNamespaces["Drupal\\Tests\\$name\\Traits\\"][] = "$base_path/tests/src/Traits"; Chris@0: } Chris@0: Chris@0: foreach ($this->testNamespaces as $prefix => $paths) { Chris@0: $this->classLoader->addPsr4($prefix, $paths); Chris@0: } Chris@0: Chris@0: return $this->testNamespaces; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Discovers all available tests in all extensions. Chris@0: * Chris@0: * @param string $extension Chris@0: * (optional) The name of an extension to limit discovery to; e.g., 'node'. Chris@0: * @param string[] $types Chris@0: * An array of included test types. Chris@0: * Chris@0: * @return array Chris@18: * An array of tests keyed by the the group name. If a test is annotated to Chris@18: * belong to multiple groups, it will appear under all group keys it belongs Chris@18: * to. Chris@0: * @code Chris@0: * $groups['block'] => array( Chris@0: * 'Drupal\Tests\block\Functional\BlockTest' => array( Chris@0: * 'name' => 'Drupal\Tests\block\Functional\BlockTest', Chris@0: * 'description' => 'Tests block UI CRUD functionality.', Chris@0: * 'group' => 'block', Chris@18: * 'groups' => ['block', 'group2', 'group3'], Chris@0: * ), Chris@0: * ); Chris@0: * @endcode Chris@0: * Chris@0: * @todo Remove singular grouping; retain list of groups in 'group' key. Chris@0: * @see https://www.drupal.org/node/2296615 Chris@0: */ Chris@0: public function getTestClasses($extension = NULL, array $types = []) { Chris@17: if (!isset($extension) && empty($types)) { Chris@17: if (!empty($this->testClasses)) { Chris@17: return $this->testClasses; Chris@0: } Chris@0: } Chris@0: $list = []; Chris@0: Chris@0: $classmap = $this->findAllClassFiles($extension); Chris@0: Chris@0: // Prevent expensive class loader lookups for each reflected test class by Chris@0: // registering the complete classmap of test classes to the class loader. Chris@0: // This also ensures that test classes are loaded from the discovered Chris@0: // pathnames; a namespace/classname mismatch will throw an exception. Chris@0: $this->classLoader->addClassMap($classmap); Chris@0: Chris@0: foreach ($classmap as $classname => $pathname) { Chris@0: $finder = MockFileFinder::create($pathname); Chris@0: $parser = new StaticReflectionParser($classname, $finder, TRUE); Chris@0: try { Chris@0: $info = static::getTestInfo($classname, $parser->getDocComment()); Chris@0: } Chris@0: catch (MissingGroupException $e) { Chris@0: // If the class name ends in Test and is not a migrate table dump. Chris@0: if (preg_match('/Test$/', $classname) && strpos($classname, 'migrate_drupal\Tests\Table') === FALSE) { Chris@0: throw $e; Chris@0: } Chris@0: // If the class is @group annotation just skip it. Most likely it is an Chris@0: // abstract class, trait or test fixture. Chris@0: continue; Chris@0: } Chris@0: // Skip this test class if it is a Simpletest-based test and requires Chris@0: // unavailable modules. TestDiscovery should not filter out module Chris@0: // requirements for PHPUnit-based test classes. Chris@0: // @todo Move this behavior to \Drupal\simpletest\TestBase so tests can be Chris@0: // marked as skipped, instead. Chris@0: // @see https://www.drupal.org/node/1273478 Chris@0: if ($info['type'] == 'Simpletest') { Chris@0: if (!empty($info['requires']['module'])) { Chris@0: if (array_diff($info['requires']['module'], $this->availableExtensions['module'])) { Chris@0: continue; Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@18: foreach ($info['groups'] as $group) { Chris@18: $list[$group][$classname] = $info; Chris@18: } Chris@0: } Chris@0: Chris@0: // Sort the groups and tests within the groups by name. Chris@0: uksort($list, 'strnatcasecmp'); Chris@0: foreach ($list as &$tests) { Chris@0: uksort($tests, 'strnatcasecmp'); Chris@0: } Chris@0: Chris@0: // Allow modules extending core tests to disable originals. Chris@17: $this->moduleHandler->alterDeprecated('Convert your test to a PHPUnit-based one and implement test listeners. See: https://www.drupal.org/node/2939892', 'simpletest', $list); Chris@0: Chris@17: if (!isset($extension) && empty($types)) { Chris@17: $this->testClasses = $list; Chris@0: } Chris@0: Chris@0: if ($types) { Chris@0: $list = NestedArray::filter($list, function ($element) use ($types) { Chris@0: return !(is_array($element) && isset($element['type']) && !in_array($element['type'], $types)); Chris@0: }); Chris@0: } Chris@0: Chris@0: return $list; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Discovers all class files in all available extensions. Chris@0: * Chris@0: * @param string $extension Chris@0: * (optional) The name of an extension to limit discovery to; e.g., 'node'. Chris@0: * Chris@0: * @return array Chris@0: * A classmap containing all discovered class files; i.e., a map of Chris@0: * fully-qualified classnames to pathnames. Chris@0: */ Chris@0: public function findAllClassFiles($extension = NULL) { Chris@0: $classmap = []; Chris@0: $namespaces = $this->registerTestNamespaces(); Chris@0: if (isset($extension)) { Chris@0: // Include tests in the \Drupal\Tests\{$extension} namespace. Chris@0: $pattern = "/Drupal\\\(Tests\\\)?$extension\\\/"; Chris@0: $namespaces = array_intersect_key($namespaces, array_flip(preg_grep($pattern, array_keys($namespaces)))); Chris@0: } Chris@0: foreach ($namespaces as $namespace => $paths) { Chris@0: foreach ($paths as $path) { Chris@0: if (!is_dir($path)) { Chris@0: continue; Chris@0: } Chris@0: $classmap += static::scanDirectory($namespace, $path); Chris@0: } Chris@0: } Chris@0: return $classmap; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Scans a given directory for class files. Chris@0: * Chris@0: * @param string $namespace_prefix Chris@0: * The namespace prefix to use for discovered classes. Must contain a Chris@0: * trailing namespace separator (backslash). Chris@0: * For example: 'Drupal\\node\\Tests\\' Chris@0: * @param string $path Chris@0: * The directory path to scan. Chris@0: * For example: '/path/to/drupal/core/modules/node/tests/src' Chris@0: * Chris@0: * @return array Chris@0: * An associative array whose keys are fully-qualified class names and whose Chris@0: * values are corresponding filesystem pathnames. Chris@0: * Chris@0: * @throws \InvalidArgumentException Chris@0: * If $namespace_prefix does not end in a namespace separator (backslash). Chris@0: * Chris@0: * @todo Limit to '*Test.php' files (~10% less files to reflect/introspect). Chris@0: * @see https://www.drupal.org/node/2296635 Chris@0: */ Chris@0: public static function scanDirectory($namespace_prefix, $path) { Chris@0: if (substr($namespace_prefix, -1) !== '\\') { Chris@0: throw new \InvalidArgumentException("Namespace prefix for $path must contain a trailing namespace separator."); Chris@0: } Chris@0: $flags = \FilesystemIterator::UNIX_PATHS; Chris@0: $flags |= \FilesystemIterator::SKIP_DOTS; Chris@0: $flags |= \FilesystemIterator::FOLLOW_SYMLINKS; Chris@0: $flags |= \FilesystemIterator::CURRENT_AS_SELF; Chris@17: $flags |= \FilesystemIterator::KEY_AS_FILENAME; Chris@0: Chris@0: $iterator = new \RecursiveDirectoryIterator($path, $flags); Chris@17: $filter = new \RecursiveCallbackFilterIterator($iterator, function ($current, $file_name, $iterator) { Chris@0: if ($iterator->hasChildren()) { Chris@0: return TRUE; Chris@0: } Chris@17: // We don't want to discover abstract TestBase classes, traits or Chris@17: // interfaces. They can be deprecated and will call @trigger_error() Chris@17: // during discovery. Chris@18: return substr($file_name, -4) === '.php' && Chris@17: substr($file_name, -12) !== 'TestBase.php' && Chris@17: substr($file_name, -9) !== 'Trait.php' && Chris@17: substr($file_name, -13) !== 'Interface.php'; Chris@0: }); Chris@0: $files = new \RecursiveIteratorIterator($filter); Chris@0: $classes = []; Chris@0: foreach ($files as $fileinfo) { Chris@0: $class = $namespace_prefix; Chris@0: if ('' !== $subpath = $fileinfo->getSubPath()) { Chris@0: $class .= strtr($subpath, '/', '\\') . '\\'; Chris@0: } Chris@0: $class .= $fileinfo->getBasename('.php'); Chris@0: $classes[$class] = $fileinfo->getPathname(); Chris@0: } Chris@0: return $classes; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Retrieves information about a test class for UI purposes. Chris@0: * Chris@0: * @param string $classname Chris@0: * The test classname. Chris@0: * @param string $doc_comment Chris@0: * (optional) The class PHPDoc comment. If not passed in reflection will be Chris@0: * used but this is very expensive when parsing all the test classes. Chris@0: * Chris@0: * @return array Chris@0: * An associative array containing: Chris@0: * - name: The test class name. Chris@0: * - description: The test (PHPDoc) summary. Chris@0: * - group: The test's first @group (parsed from PHPDoc annotations). Chris@18: * - groups: All of the test's @group annotations, as an array (parsed from Chris@18: * PHPDoc annotations). Chris@0: * - requires: An associative array containing test requirements parsed from Chris@0: * PHPDoc annotations: Chris@0: * - module: List of Drupal module extension names the test depends on. Chris@0: * Chris@0: * @throws \Drupal\simpletest\Exception\MissingGroupException Chris@0: * If the class does not have a @group annotation. Chris@0: */ Chris@0: public static function getTestInfo($classname, $doc_comment = NULL) { Chris@16: if ($doc_comment === NULL) { Chris@0: $reflection = new \ReflectionClass($classname); Chris@0: $doc_comment = $reflection->getDocComment(); Chris@0: } Chris@0: $info = [ Chris@0: 'name' => $classname, Chris@0: ]; Chris@0: $annotations = []; Chris@0: // Look for annotations, allow an arbitrary amount of spaces before the Chris@0: // * but nothing else. Chris@0: preg_match_all('/^[ ]*\* \@([^\s]*) (.*$)/m', $doc_comment, $matches); Chris@0: if (isset($matches[1])) { Chris@0: foreach ($matches[1] as $key => $annotation) { Chris@18: // For historical reasons, there is a single-value 'group' result key Chris@18: // and a 'groups' key as an array. Chris@18: if ($annotation === 'group') { Chris@18: $annotations['groups'][] = $matches[2][$key]; Chris@18: } Chris@0: if (!empty($annotations[$annotation])) { Chris@18: // Only @group is allowed to have more than one annotation, in the Chris@18: // 'groups' key. Other annotations only have one value per key. Chris@0: continue; Chris@0: } Chris@0: $annotations[$annotation] = $matches[2][$key]; Chris@0: } Chris@0: } Chris@0: Chris@0: if (empty($annotations['group'])) { Chris@0: // Concrete tests must have a group. Chris@0: throw new MissingGroupException(sprintf('Missing @group annotation in %s', $classname)); Chris@0: } Chris@0: $info['group'] = $annotations['group']; Chris@18: $info['groups'] = $annotations['groups']; Chris@18: Chris@18: // Sort out PHPUnit-runnable tests by type. Chris@0: if ($testsuite = static::getPhpunitTestSuite($classname)) { Chris@0: $info['type'] = 'PHPUnit-' . $testsuite; Chris@0: } Chris@0: else { Chris@0: $info['type'] = 'Simpletest'; Chris@0: } Chris@0: Chris@0: if (!empty($annotations['coversDefaultClass'])) { Chris@0: $info['description'] = 'Tests ' . $annotations['coversDefaultClass'] . '.'; Chris@0: } Chris@0: else { Chris@0: $info['description'] = static::parseTestClassSummary($doc_comment); Chris@0: } Chris@0: if (isset($annotations['dependencies'])) { Chris@0: $info['requires']['module'] = array_map('trim', explode(',', $annotations['dependencies'])); Chris@0: } Chris@0: Chris@0: return $info; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Parses the phpDoc summary line of a test class. Chris@0: * Chris@0: * @param string $doc_comment Chris@0: * Chris@0: * @return string Chris@0: * The parsed phpDoc summary line. An empty string is returned if no summary Chris@0: * line can be parsed. Chris@0: */ Chris@0: public static function parseTestClassSummary($doc_comment) { Chris@0: // Normalize line endings. Chris@0: $doc_comment = preg_replace('/\r\n|\r/', '\n', $doc_comment); Chris@0: // Strip leading and trailing doc block lines. Chris@0: $doc_comment = substr($doc_comment, 4, -4); Chris@0: Chris@0: $lines = explode("\n", $doc_comment); Chris@0: $summary = []; Chris@0: // Add every line to the summary until the first empty line or annotation Chris@0: // is found. Chris@0: foreach ($lines as $line) { Chris@0: if (preg_match('/^[ ]*\*$/', $line) || preg_match('/^[ ]*\* \@/', $line)) { Chris@0: break; Chris@0: } Chris@0: $summary[] = trim($line, ' *'); Chris@0: } Chris@0: return implode(' ', $summary); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Parses annotations in the phpDoc of a test class. Chris@0: * Chris@0: * @param \ReflectionClass $class Chris@0: * The reflected test class. Chris@0: * Chris@0: * @return array Chris@0: * An associative array that contains all annotations on the test class; Chris@0: * typically including: Chris@0: * - group: A list of @group values. Chris@0: * - requires: An associative array of @requires values; e.g.: Chris@0: * - module: A list of Drupal module dependencies that are required to Chris@0: * exist. Chris@0: * Chris@0: * @see PHPUnit_Util_Test::parseTestMethodAnnotations() Chris@0: * @see http://phpunit.de/manual/current/en/incomplete-and-skipped-tests.html#incomplete-and-skipped-tests.skipping-tests-using-requires Chris@0: */ Chris@0: public static function parseTestClassAnnotations(\ReflectionClass $class) { Chris@0: $annotations = PHPUnit_Util_Test::parseTestMethodAnnotations($class->getName())['class']; Chris@0: Chris@0: // @todo Enhance PHPUnit upstream to allow for custom @requires identifiers. Chris@0: // @see PHPUnit_Util_Test::getRequirements() Chris@0: // @todo Add support for 'PHP', 'OS', 'function', 'extension'. Chris@0: // @see https://www.drupal.org/node/1273478 Chris@0: if (isset($annotations['requires'])) { Chris@0: foreach ($annotations['requires'] as $i => $value) { Chris@0: list($type, $value) = explode(' ', $value, 2); Chris@0: if ($type === 'module') { Chris@0: $annotations['requires']['module'][$value] = $value; Chris@0: unset($annotations['requires'][$i]); Chris@0: } Chris@0: } Chris@0: } Chris@0: return $annotations; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Determines the phpunit testsuite for a given classname. Chris@0: * Chris@0: * @param string $classname Chris@0: * The test classname. Chris@0: * Chris@0: * @return string|false Chris@0: * The testsuite name or FALSE if its not a phpunit test. Chris@0: */ Chris@0: public static function getPhpunitTestSuite($classname) { Chris@0: if (preg_match('/Drupal\\\\Tests\\\\Core\\\\(\w+)/', $classname, $matches)) { Chris@0: return 'Unit'; Chris@0: } Chris@0: if (preg_match('/Drupal\\\\Tests\\\\Component\\\\(\w+)/', $classname, $matches)) { Chris@0: return 'Unit'; Chris@0: } Chris@0: // Module tests. Chris@0: if (preg_match('/Drupal\\\\Tests\\\\(\w+)\\\\(\w+)/', $classname, $matches)) { Chris@0: return $matches[2]; Chris@0: } Chris@0: // Core tests. Chris@0: elseif (preg_match('/Drupal\\\\(\w*)Tests\\\\/', $classname, $matches)) { Chris@0: if ($matches[1] == '') { Chris@0: return 'Unit'; Chris@0: } Chris@0: return $matches[1]; Chris@0: } Chris@0: return FALSE; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Returns all available extensions. Chris@0: * Chris@0: * @return \Drupal\Core\Extension\Extension[] Chris@0: * An array of Extension objects, keyed by extension name. Chris@0: */ Chris@0: protected function getExtensions() { Chris@0: $listing = new ExtensionDiscovery($this->root); Chris@0: // Ensure that tests in all profiles are discovered. Chris@0: $listing->setProfileDirectories([]); Chris@0: $extensions = $listing->scan('module', TRUE); Chris@0: $extensions += $listing->scan('profile', TRUE); Chris@0: $extensions += $listing->scan('theme', TRUE); Chris@0: return $extensions; Chris@0: } Chris@0: Chris@0: }