Updated Drupal to 8.6. This goes with the following updates because it's possible...
[yaffs-website] / web / core / modules / simpletest / src / TestDiscovery.php
1 <?php
2
3 namespace Drupal\simpletest;
4
5 use Doctrine\Common\Annotations\SimpleAnnotationReader;
6 use Doctrine\Common\Reflection\StaticReflectionParser;
7 use Drupal\Component\Annotation\Reflection\MockFileFinder;
8 use Drupal\Component\Utility\NestedArray;
9 use Drupal\Core\Extension\ExtensionDiscovery;
10 use Drupal\Core\Extension\ModuleHandlerInterface;
11 use Drupal\simpletest\Exception\MissingGroupException;
12 use PHPUnit_Util_Test;
13
14 /**
15  * Discovers available tests.
16  */
17 class TestDiscovery {
18
19   /**
20    * The class loader.
21    *
22    * @var \Composer\Autoload\ClassLoader
23    */
24   protected $classLoader;
25
26   /**
27    * Statically cached list of test classes.
28    *
29    * @var array
30    */
31   protected $testClasses;
32
33   /**
34    * Cached map of all test namespaces to respective directories.
35    *
36    * @var array
37    */
38   protected $testNamespaces;
39
40   /**
41    * Cached list of all available extension names, keyed by extension type.
42    *
43    * @var array
44    */
45   protected $availableExtensions;
46
47   /**
48    * The app root.
49    *
50    * @var string
51    */
52   protected $root;
53
54   /**
55    * The module handler.
56    *
57    * @var \Drupal\Core\Extension\ModuleHandlerInterface
58    */
59   protected $moduleHandler;
60
61   /**
62    * Constructs a new test discovery.
63    *
64    * @param string $root
65    *   The app root.
66    * @param $class_loader
67    *   The class loader. Normally Composer's ClassLoader, as included by the
68    *   front controller, but may also be decorated; e.g.,
69    *   \Symfony\Component\ClassLoader\ApcClassLoader.
70    * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
71    *   The module handler.
72    */
73   public function __construct($root, $class_loader, ModuleHandlerInterface $module_handler) {
74     $this->root = $root;
75     $this->classLoader = $class_loader;
76     $this->moduleHandler = $module_handler;
77   }
78
79   /**
80    * Registers test namespaces of all extensions and core test classes.
81    *
82    * @return array
83    *   An associative array whose keys are PSR-4 namespace prefixes and whose
84    *   values are directory names.
85    */
86   public function registerTestNamespaces() {
87     if (isset($this->testNamespaces)) {
88       return $this->testNamespaces;
89     }
90     $this->testNamespaces = [];
91
92     $existing = $this->classLoader->getPrefixesPsr4();
93
94     // Add PHPUnit test namespaces of Drupal core.
95     $this->testNamespaces['Drupal\\Tests\\'] = [$this->root . '/core/tests/Drupal/Tests'];
96     $this->testNamespaces['Drupal\\KernelTests\\'] = [$this->root . '/core/tests/Drupal/KernelTests'];
97     $this->testNamespaces['Drupal\\FunctionalTests\\'] = [$this->root . '/core/tests/Drupal/FunctionalTests'];
98     $this->testNamespaces['Drupal\\FunctionalJavascriptTests\\'] = [$this->root . '/core/tests/Drupal/FunctionalJavascriptTests'];
99
100     $this->availableExtensions = [];
101     foreach ($this->getExtensions() as $name => $extension) {
102       $this->availableExtensions[$extension->getType()][$name] = $name;
103
104       $base_path = $this->root . '/' . $extension->getPath();
105
106       // Add namespace of disabled/uninstalled extensions.
107       if (!isset($existing["Drupal\\$name\\"])) {
108         $this->classLoader->addPsr4("Drupal\\$name\\", "$base_path/src");
109       }
110       // Add Simpletest test namespace.
111       $this->testNamespaces["Drupal\\$name\\Tests\\"][] = "$base_path/src/Tests";
112
113       // Add PHPUnit test namespaces.
114       $this->testNamespaces["Drupal\\Tests\\$name\\Unit\\"][] = "$base_path/tests/src/Unit";
115       $this->testNamespaces["Drupal\\Tests\\$name\\Kernel\\"][] = "$base_path/tests/src/Kernel";
116       $this->testNamespaces["Drupal\\Tests\\$name\\Functional\\"][] = "$base_path/tests/src/Functional";
117       $this->testNamespaces["Drupal\\Tests\\$name\\FunctionalJavascript\\"][] = "$base_path/tests/src/FunctionalJavascript";
118
119       // Add discovery for traits which are shared between different test
120       // suites.
121       $this->testNamespaces["Drupal\\Tests\\$name\\Traits\\"][] = "$base_path/tests/src/Traits";
122     }
123
124     foreach ($this->testNamespaces as $prefix => $paths) {
125       $this->classLoader->addPsr4($prefix, $paths);
126     }
127
128     return $this->testNamespaces;
129   }
130
131   /**
132    * Discovers all available tests in all extensions.
133    *
134    * @param string $extension
135    *   (optional) The name of an extension to limit discovery to; e.g., 'node'.
136    * @param string[] $types
137    *   An array of included test types.
138    *
139    * @return array
140    *   An array of tests keyed by the the group name.
141    * @code
142    *     $groups['block'] => array(
143    *       'Drupal\Tests\block\Functional\BlockTest' => array(
144    *         'name' => 'Drupal\Tests\block\Functional\BlockTest',
145    *         'description' => 'Tests block UI CRUD functionality.',
146    *         'group' => 'block',
147    *       ),
148    *     );
149    * @endcode
150    *
151    * @todo Remove singular grouping; retain list of groups in 'group' key.
152    * @see https://www.drupal.org/node/2296615
153    */
154   public function getTestClasses($extension = NULL, array $types = []) {
155     $reader = new SimpleAnnotationReader();
156     $reader->addNamespace('Drupal\\simpletest\\Annotation');
157
158     if (!isset($extension) && empty($types)) {
159       if (!empty($this->testClasses)) {
160         return $this->testClasses;
161       }
162     }
163     $list = [];
164
165     $classmap = $this->findAllClassFiles($extension);
166
167     // Prevent expensive class loader lookups for each reflected test class by
168     // registering the complete classmap of test classes to the class loader.
169     // This also ensures that test classes are loaded from the discovered
170     // pathnames; a namespace/classname mismatch will throw an exception.
171     $this->classLoader->addClassMap($classmap);
172
173     foreach ($classmap as $classname => $pathname) {
174       $finder = MockFileFinder::create($pathname);
175       $parser = new StaticReflectionParser($classname, $finder, TRUE);
176       try {
177         $info = static::getTestInfo($classname, $parser->getDocComment());
178       }
179       catch (MissingGroupException $e) {
180         // If the class name ends in Test and is not a migrate table dump.
181         if (preg_match('/Test$/', $classname) && strpos($classname, 'migrate_drupal\Tests\Table') === FALSE) {
182           throw $e;
183         }
184         // If the class is @group annotation just skip it. Most likely it is an
185         // abstract class, trait or test fixture.
186         continue;
187       }
188       // Skip this test class if it is a Simpletest-based test and requires
189       // unavailable modules. TestDiscovery should not filter out module
190       // requirements for PHPUnit-based test classes.
191       // @todo Move this behavior to \Drupal\simpletest\TestBase so tests can be
192       //       marked as skipped, instead.
193       // @see https://www.drupal.org/node/1273478
194       if ($info['type'] == 'Simpletest') {
195         if (!empty($info['requires']['module'])) {
196           if (array_diff($info['requires']['module'], $this->availableExtensions['module'])) {
197             continue;
198           }
199         }
200       }
201
202       $list[$info['group']][$classname] = $info;
203     }
204
205     // Sort the groups and tests within the groups by name.
206     uksort($list, 'strnatcasecmp');
207     foreach ($list as &$tests) {
208       uksort($tests, 'strnatcasecmp');
209     }
210
211     // Allow modules extending core tests to disable originals.
212     $this->moduleHandler->alterDeprecated('Convert your test to a PHPUnit-based one and implement test listeners. See: https://www.drupal.org/node/2939892', 'simpletest', $list);
213
214     if (!isset($extension) && empty($types)) {
215       $this->testClasses = $list;
216     }
217
218     if ($types) {
219       $list = NestedArray::filter($list, function ($element) use ($types) {
220         return !(is_array($element) && isset($element['type']) && !in_array($element['type'], $types));
221       });
222     }
223
224     return $list;
225   }
226
227   /**
228    * Discovers all class files in all available extensions.
229    *
230    * @param string $extension
231    *   (optional) The name of an extension to limit discovery to; e.g., 'node'.
232    *
233    * @return array
234    *   A classmap containing all discovered class files; i.e., a map of
235    *   fully-qualified classnames to pathnames.
236    */
237   public function findAllClassFiles($extension = NULL) {
238     $classmap = [];
239     $namespaces = $this->registerTestNamespaces();
240     if (isset($extension)) {
241       // Include tests in the \Drupal\Tests\{$extension} namespace.
242       $pattern = "/Drupal\\\(Tests\\\)?$extension\\\/";
243       $namespaces = array_intersect_key($namespaces, array_flip(preg_grep($pattern, array_keys($namespaces))));
244     }
245     foreach ($namespaces as $namespace => $paths) {
246       foreach ($paths as $path) {
247         if (!is_dir($path)) {
248           continue;
249         }
250         $classmap += static::scanDirectory($namespace, $path);
251       }
252     }
253     return $classmap;
254   }
255
256   /**
257    * Scans a given directory for class files.
258    *
259    * @param string $namespace_prefix
260    *   The namespace prefix to use for discovered classes. Must contain a
261    *   trailing namespace separator (backslash).
262    *   For example: 'Drupal\\node\\Tests\\'
263    * @param string $path
264    *   The directory path to scan.
265    *   For example: '/path/to/drupal/core/modules/node/tests/src'
266    *
267    * @return array
268    *   An associative array whose keys are fully-qualified class names and whose
269    *   values are corresponding filesystem pathnames.
270    *
271    * @throws \InvalidArgumentException
272    *   If $namespace_prefix does not end in a namespace separator (backslash).
273    *
274    * @todo Limit to '*Test.php' files (~10% less files to reflect/introspect).
275    * @see https://www.drupal.org/node/2296635
276    */
277   public static function scanDirectory($namespace_prefix, $path) {
278     if (substr($namespace_prefix, -1) !== '\\') {
279       throw new \InvalidArgumentException("Namespace prefix for $path must contain a trailing namespace separator.");
280     }
281     $flags = \FilesystemIterator::UNIX_PATHS;
282     $flags |= \FilesystemIterator::SKIP_DOTS;
283     $flags |= \FilesystemIterator::FOLLOW_SYMLINKS;
284     $flags |= \FilesystemIterator::CURRENT_AS_SELF;
285     $flags |= \FilesystemIterator::KEY_AS_FILENAME;
286
287     $iterator = new \RecursiveDirectoryIterator($path, $flags);
288     $filter = new \RecursiveCallbackFilterIterator($iterator, function ($current, $file_name, $iterator) {
289       if ($iterator->hasChildren()) {
290         return TRUE;
291       }
292       // We don't want to discover abstract TestBase classes, traits or
293       // interfaces. They can be deprecated and will call @trigger_error()
294       // during discovery.
295       return
296         substr($file_name, -4) === '.php' &&
297         substr($file_name, -12) !== 'TestBase.php' &&
298         substr($file_name, -9) !== 'Trait.php' &&
299         substr($file_name, -13) !== 'Interface.php';
300     });
301     $files = new \RecursiveIteratorIterator($filter);
302     $classes = [];
303     foreach ($files as $fileinfo) {
304       $class = $namespace_prefix;
305       if ('' !== $subpath = $fileinfo->getSubPath()) {
306         $class .= strtr($subpath, '/', '\\') . '\\';
307       }
308       $class .= $fileinfo->getBasename('.php');
309       $classes[$class] = $fileinfo->getPathname();
310     }
311     return $classes;
312   }
313
314   /**
315    * Retrieves information about a test class for UI purposes.
316    *
317    * @param string $classname
318    *   The test classname.
319    * @param string $doc_comment
320    *   (optional) The class PHPDoc comment. If not passed in reflection will be
321    *   used but this is very expensive when parsing all the test classes.
322    *
323    * @return array
324    *   An associative array containing:
325    *   - name: The test class name.
326    *   - description: The test (PHPDoc) summary.
327    *   - group: The test's first @group (parsed from PHPDoc annotations).
328    *   - requires: An associative array containing test requirements parsed from
329    *     PHPDoc annotations:
330    *     - module: List of Drupal module extension names the test depends on.
331    *
332    * @throws \Drupal\simpletest\Exception\MissingGroupException
333    *   If the class does not have a @group annotation.
334    */
335   public static function getTestInfo($classname, $doc_comment = NULL) {
336     if ($doc_comment === NULL) {
337       $reflection = new \ReflectionClass($classname);
338       $doc_comment = $reflection->getDocComment();
339     }
340     $info = [
341       'name' => $classname,
342     ];
343     $annotations = [];
344     // Look for annotations, allow an arbitrary amount of spaces before the
345     // * but nothing else.
346     preg_match_all('/^[ ]*\* \@([^\s]*) (.*$)/m', $doc_comment, $matches);
347     if (isset($matches[1])) {
348       foreach ($matches[1] as $key => $annotation) {
349         if (!empty($annotations[$annotation])) {
350           // Only have the first match per annotation. This deals with
351           // multiple @group annotations.
352           continue;
353         }
354         $annotations[$annotation] = $matches[2][$key];
355       }
356     }
357
358     if (empty($annotations['group'])) {
359       // Concrete tests must have a group.
360       throw new MissingGroupException(sprintf('Missing @group annotation in %s', $classname));
361     }
362     $info['group'] = $annotations['group'];
363     // Put PHPUnit test suites into their own custom groups.
364     if ($testsuite = static::getPhpunitTestSuite($classname)) {
365       $info['type'] = 'PHPUnit-' . $testsuite;
366     }
367     else {
368       $info['type'] = 'Simpletest';
369     }
370
371     if (!empty($annotations['coversDefaultClass'])) {
372       $info['description'] = 'Tests ' . $annotations['coversDefaultClass'] . '.';
373     }
374     else {
375       $info['description'] = static::parseTestClassSummary($doc_comment);
376     }
377     if (isset($annotations['dependencies'])) {
378       $info['requires']['module'] = array_map('trim', explode(',', $annotations['dependencies']));
379     }
380
381     return $info;
382   }
383
384   /**
385    * Parses the phpDoc summary line of a test class.
386    *
387    * @param string $doc_comment
388    *
389    * @return string
390    *   The parsed phpDoc summary line. An empty string is returned if no summary
391    *   line can be parsed.
392    */
393   public static function parseTestClassSummary($doc_comment) {
394     // Normalize line endings.
395     $doc_comment = preg_replace('/\r\n|\r/', '\n', $doc_comment);
396     // Strip leading and trailing doc block lines.
397     $doc_comment = substr($doc_comment, 4, -4);
398
399     $lines = explode("\n", $doc_comment);
400     $summary = [];
401     // Add every line to the summary until the first empty line or annotation
402     // is found.
403     foreach ($lines as $line) {
404       if (preg_match('/^[ ]*\*$/', $line) || preg_match('/^[ ]*\* \@/', $line)) {
405         break;
406       }
407       $summary[] = trim($line, ' *');
408     }
409     return implode(' ', $summary);
410   }
411
412   /**
413    * Parses annotations in the phpDoc of a test class.
414    *
415    * @param \ReflectionClass $class
416    *   The reflected test class.
417    *
418    * @return array
419    *   An associative array that contains all annotations on the test class;
420    *   typically including:
421    *   - group: A list of @group values.
422    *   - requires: An associative array of @requires values; e.g.:
423    *     - module: A list of Drupal module dependencies that are required to
424    *       exist.
425    *
426    * @see PHPUnit_Util_Test::parseTestMethodAnnotations()
427    * @see http://phpunit.de/manual/current/en/incomplete-and-skipped-tests.html#incomplete-and-skipped-tests.skipping-tests-using-requires
428    */
429   public static function parseTestClassAnnotations(\ReflectionClass $class) {
430     $annotations = PHPUnit_Util_Test::parseTestMethodAnnotations($class->getName())['class'];
431
432     // @todo Enhance PHPUnit upstream to allow for custom @requires identifiers.
433     // @see PHPUnit_Util_Test::getRequirements()
434     // @todo Add support for 'PHP', 'OS', 'function', 'extension'.
435     // @see https://www.drupal.org/node/1273478
436     if (isset($annotations['requires'])) {
437       foreach ($annotations['requires'] as $i => $value) {
438         list($type, $value) = explode(' ', $value, 2);
439         if ($type === 'module') {
440           $annotations['requires']['module'][$value] = $value;
441           unset($annotations['requires'][$i]);
442         }
443       }
444     }
445     return $annotations;
446   }
447
448   /**
449    * Determines the phpunit testsuite for a given classname.
450    *
451    * @param string $classname
452    *   The test classname.
453    *
454    * @return string|false
455    *   The testsuite name or FALSE if its not a phpunit test.
456    */
457   public static function getPhpunitTestSuite($classname) {
458     if (preg_match('/Drupal\\\\Tests\\\\Core\\\\(\w+)/', $classname, $matches)) {
459       return 'Unit';
460     }
461     if (preg_match('/Drupal\\\\Tests\\\\Component\\\\(\w+)/', $classname, $matches)) {
462       return 'Unit';
463     }
464     // Module tests.
465     if (preg_match('/Drupal\\\\Tests\\\\(\w+)\\\\(\w+)/', $classname, $matches)) {
466       return $matches[2];
467     }
468     // Core tests.
469     elseif (preg_match('/Drupal\\\\(\w*)Tests\\\\/', $classname, $matches)) {
470       if ($matches[1] == '') {
471         return 'Unit';
472       }
473       return $matches[1];
474     }
475     return FALSE;
476   }
477
478   /**
479    * Returns all available extensions.
480    *
481    * @return \Drupal\Core\Extension\Extension[]
482    *   An array of Extension objects, keyed by extension name.
483    */
484   protected function getExtensions() {
485     $listing = new ExtensionDiscovery($this->root);
486     // Ensure that tests in all profiles are discovered.
487     $listing->setProfileDirectories([]);
488     $extensions = $listing->scan('module', TRUE);
489     $extensions += $listing->scan('profile', TRUE);
490     $extensions += $listing->scan('theme', TRUE);
491     return $extensions;
492   }
493
494 }