MDL-55713 behat: Adjust how NamedPartials are loaded
[moodle.git] / lib / behat / classes / behat_config_util.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Utils to set Behat config
19  *
20  * @package    core
21  * @copyright  2016 Rajesh Taneja
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 defined('MOODLE_INTERNAL') || die();
27 require_once(__DIR__ . '/../lib.php');
28 require_once(__DIR__ . '/behat_command.php');
29 require_once(__DIR__ . '/../../testing/classes/tests_finder.php');
31 /**
32  * Behat configuration manager
33  *
34  * Creates/updates Behat config files getting tests
35  * and steps from Moodle codebase
36  *
37  * @package    core
38  * @copyright  2016 Rajesh Taneja
39  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
40  */
41 class behat_config_util {
43     /**
44      * @var array list of features in core.
45      */
46     private $features;
48     /**
49      * @var array list of contexts in core.
50      */
51     private $contexts;
53     /**
54      * @var array list of theme specific contexts.
55      */
56     private $themecontexts;
58     /**
59      * @var array list of all contexts in theme suite.
60      */
61     private $themesuitecontexts;
63     /**
64      * @var array list of overridden theme contexts.
65      */
66     private $overriddenthemescontexts;
68     /**
69      * @var array list of components with tests.
70      */
71     private $componentswithtests;
73     /**
74      * @var bool keep track of theme to return suite with all core features included or not.
75      */
76     private $themesuitewithallfeatures = false;
78     /**
79      * @var string filter features which have tags.
80      */
81     private $tags = '';
83     /**
84      * @var int number of parallel runs.
85      */
86     private $parallelruns = 0;
88     /**
89      * @var int current run.
90      */
91     private $currentrun = 0;
93     /**
94      * Set value for theme suite to include all core features. This should be used if your want all core features to be
95      * run with theme.
96      *
97      * @param bool $val
98      */
99     public function set_theme_suite_to_include_core_features($val) {
100         $this->themesuitewithallfeatures = $val;
101     }
103     /**
104      * Set the value for tags, so features which are returned will be using filtered by this.
105      *
106      * @param string $tags
107      */
108     public function set_tag_for_feature_filter($tags) {
109         $this->tags = $tags;
110     }
112     /**
113      * Set parallel run to be used for generating config.
114      *
115      * @param int $parallelruns number of parallel runs.
116      * @param int $currentrun current run
117      */
118     public function set_parallel_run($parallelruns, $currentrun) {
120         if ($parallelruns < $currentrun) {
121             behat_error(BEHAT_EXITCODE_REQUIREMENT,
122                 'Parallel runs('.$parallelruns.') should be more then current run('.$currentrun.')');
123         }
125         $this->parallelruns = $parallelruns;
126         $this->currentrun = $currentrun;
127     }
129     /**
130      * Return parallel runs
131      *
132      * @return int number of parallel runs.
133      */
134     public function get_number_of_parallel_run() {
135         // Get number of parallel runs if not passed.
136         if (empty($this->parallelruns) && ($this->parallelruns !== false)) {
137             $this->parallelruns = behat_config_manager::get_parallel_test_runs();
138         }
140         return $this->parallelruns;
141     }
143     /**
144      * Return current run
145      *
146      * @return int current run.
147      */
148     public function get_current_run() {
149         global $CFG;
151         // Get number of parallel runs if not passed.
152         if (empty($this->currentrun) && ($this->currentrun !== false) && !empty($CFG->behatrunprocess)) {
153             $this->currentrun = $CFG->behatrunprocess;
154         }
156         return $this->currentrun;
157     }
159     /**
160      * Return list of features.
161      *
162      * @param string $tags tags.
163      * @return array
164      */
165     public function get_components_features($tags = '') {
166         global $CFG;
168         // If we already have a list created then just return that, as it's up-to-date.
169         // If tags are passed then it's a new filter of features we need.
170         if (!empty($this->features) && empty($tags)) {
171             return $this->features;
172         }
174         // Gets all the components with features.
175         $features = array();
176         $featurespaths = array();
177         $components = $this->get_components_with_tests();
179         if ($components) {
180             foreach ($components as $componentname => $path) {
181                 $path = $this->clean_path($path) . self::get_behat_tests_path();
182                 if (empty($featurespaths[$path]) && file_exists($path)) {
183                     list($key, $featurepath) = $this->get_clean_feature_key_and_path($path);
184                     $featurespaths[$key] = $featurepath;
185                 }
186             }
187             foreach ($featurespaths as $path) {
188                 $additional = glob("$path/*.feature");
190                 $additionalfeatures = array();
191                 foreach ($additional as $featurepath) {
192                     list($key, $path) = $this->get_clean_feature_key_and_path($featurepath);
193                     $additionalfeatures[$key] = $path;
194                 }
196                 $features = array_merge($features, $additionalfeatures);
197             }
198         }
200         // Optionally include features from additional directories.
201         if (!empty($CFG->behat_additionalfeatures)) {
202             $additional = array_map("realpath", $CFG->behat_additionalfeatures);
203             $additionalfeatures = array();
204             foreach ($additional as $featurepath) {
205                 list($key, $path) = $this->get_clean_feature_key_and_path($featurepath);
206                 $additionalfeatures[$key] = $path;
207             }
208             $features = array_merge($features, $additionalfeatures);
209         }
211         $this->features = $features;
213         return $this->filtered_features_with_tags($features, $tags);
214     }
216     /**
217      * Return feature key for featurepath
218      *
219      * @param string $featurepath
220      * @return array key and featurepath.
221      */
222     public function get_clean_feature_key_and_path($featurepath) {
223         global $CFG;
225         // Fix directory path.
226         $featurepath = testing_cli_fix_directory_separator($featurepath);
227         $dirroot = testing_cli_fix_directory_separator($CFG->dirroot . DIRECTORY_SEPARATOR);
229         $key = basename($featurepath, '.feature');
231         // Get relative path.
232         $featuredirname = str_replace($dirroot , '', $featurepath);
233         // Get 5 levels of feature path to ensure we have a unique key.
234         for ($i = 0; $i < 5; $i++) {
235             if (($featuredirname = dirname($featuredirname)) && $featuredirname !== '.') {
236                 if ($basename = basename($featuredirname)) {
237                     $key .= '_' . $basename;
238                 }
239             }
240         }
242         return array($key, $featurepath);
243     }
245     /**
246      * Get component contexts.
247      *
248      * @param string $component component name.
249      * @return array
250      */
251     private function get_component_contexts($component) {
253         if (empty($component)) {
254             return $this->contexts;
255         }
257         $componentcontexts = array();
258         foreach ($this->contexts as $key => $path) {
259             if ($component == '' || $component === $key) {
260                 $componentcontexts[$key] = $path;
261             }
262         }
264         return $componentcontexts;
265     }
267     /**
268      * Gets the list of Moodle behat contexts
269      *
270      * Class name as a key and the filepath as value
271      *
272      * Externalized from update_config_file() to use
273      * it from the steps definitions web interface
274      *
275      * @param  string $component Restricts the obtained steps definitions to the specified component
276      * @return array
277      */
278     public function get_components_contexts($component = '') {
280         // If we already have a list created then just return that, as it's up-to-date.
281         if (!empty($this->contexts)) {
282             return $this->get_component_contexts($component);
283         }
285         $components = $this->get_components_with_tests();
287         $this->contexts = array();
288         foreach ($components as $componentname => $componentpath) {
289             $componentpath = self::clean_path($componentpath);
291             if (!file_exists($componentpath . self::get_behat_tests_path())) {
292                 continue;
293             }
294             $diriterator = new DirectoryIterator($componentpath . self::get_behat_tests_path());
295             $regite = new RegexIterator($diriterator, '|behat_.*\.php$|');
297             // All behat_*.php inside self::get_behat_tests_path() are added as steps definitions files.
298             foreach ($regite as $file) {
299                 $key = $file->getBasename('.php');
300                 $this->contexts[$key] = $file->getPathname();
301             }
302         }
304         return $this->get_component_contexts($component);
305     }
307     /**
308      * Behat config file specifing the main context class,
309      * the required Behat extensions and Moodle test wwwroot.
310      *
311      * @param array $features The system feature files
312      * @param array $contexts The system steps definitions
313      * @param string $tags filter features with specified tags.
314      * @param int $parallelruns number of parallel runs.
315      * @param int $currentrun current run for which config file is needed.
316      * @return string
317      */
318     public function get_config_file_contents($features = '', $contexts = '', $tags = '', $parallelruns = 0, $currentrun = 0) {
319         global $CFG;
321         // Set current run and parallel run.
322         if (!empty($parallelruns) && !empty($currentrun)) {
323             $this->set_parallel_run($parallelruns, $currentrun);
324         }
326         // If tags defined then use them. This is for BC.
327         if (!empty($tags)) {
328             $this->set_tag_for_feature_filter($tags);
329         }
331         // If features not passed then get it. Empty array means we don't need to include features.
332         if (empty($features) && !is_array($features)) {
333             $features = $this->get_components_features();
334         } else {
335             $this->features = $features;
336         }
338         // If stepdefinitions not passed then get the list.
339         if (empty($contexts)) {
340             $this->get_components_contexts();
341         } else {
342             $this->contexts = $contexts;
343         }
345         // We require here when we are sure behat dependencies are available.
346         require_once($CFG->dirroot . '/vendor/autoload.php');
348         $config = $this->build_config();
350         $config = $this->merge_behat_config($config);
352         $config = $this->merge_behat_profiles($config);
354         return Symfony\Component\Yaml\Yaml::dump($config, 10, 2);
355     }
357     /**
358      * Search feature files for set of tags.
359      *
360      * @param array $features set of feature files.
361      * @param string $tags list of tags (currently support && only.)
362      * @return array filtered list of feature files with tags.
363      */
364     public function filtered_features_with_tags($features = '', $tags = '') {
366         // This is for BC. Features if not passed then we already have a list in this object.
367         if (empty($features)) {
368             $features = $this->features;
369         }
371         // If no tags defined then return full list.
372         if (empty($tags) && empty($this->tags)) {
373             return $features;
374         }
376         // If no tags passed by the caller, then it's already set.
377         if (empty($tags)) {
378             $tags = $this->tags;
379         }
381         $newfeaturelist = array();
382         // Split tags in and and or.
383         $tags = explode('&&', $tags);
384         $andtags = array();
385         $ortags = array();
386         foreach ($tags as $tag) {
387             // Explode all tags seperated by , and add it to ortags.
388             $ortags = array_merge($ortags, explode(',', $tag));
389             // And tags will be the first one before comma(,).
390             $andtags[] = preg_replace('/,.*/', '', $tag);
391         }
393         foreach ($features as $featurefile) {
394             $contents = file_get_contents($featurefile);
395             $includefeature = true;
396             foreach ($andtags as $tag) {
397                 // If negitive tag, then ensure it don't exist.
398                 if (strpos($tag, '~') !== false) {
399                     $tag = substr($tag, 1);
400                     if ($contents && strpos($contents, $tag) !== false) {
401                         $includefeature = false;
402                         break;
403                     }
404                 } else if ($contents && strpos($contents, $tag) === false) {
405                     $includefeature = false;
406                     break;
407                 }
408             }
410             // If feature not included then check or tags.
411             if (!$includefeature && !empty($ortags)) {
412                 foreach ($ortags as $tag) {
413                     if ($contents && (strpos($tag, '~') === false) && (strpos($contents, $tag) !== false)) {
414                         $includefeature = true;
415                         break;
416                     }
417                 }
418             }
420             if ($includefeature) {
421                 $newfeaturelist[] = $featurefile;
422             }
423         }
424         return $newfeaturelist;
425     }
427     /**
428      * Build config for behat.yml.
429      *
430      * @param int $parallelruns how many parallel runs feature needs to be divided.
431      * @param int $currentrun current run for which features should be returned.
432      * @return array
433      */
434     protected function build_config($parallelruns = 0, $currentrun = 0) {
435         global $CFG;
437         if (!empty($parallelruns) && !empty($currentrun)) {
438             $this->set_parallel_run($parallelruns, $currentrun);
439         } else {
440             $currentrun = $this->get_current_run();
441             $parallelruns = $this->get_number_of_parallel_run();
442         }
444         $selenium2wdhost = array('wd_host' => 'http://localhost:4444/wd/hub');
445         // If parallel run, then set wd_host if specified.
446         if (!empty($currentrun) && !empty($parallelruns)) {
447             // Set proper selenium2 wd_host if defined.
448             if (!empty($CFG->behat_parallel_run[$currentrun - 1]['wd_host'])) {
449                 $selenium2wdhost = array('wd_host' => $CFG->behat_parallel_run[$currentrun - 1]['wd_host']);
450             }
451         }
453         // It is possible that it has no value as we don't require a full behat setup to list the step definitions.
454         if (empty($CFG->behat_wwwroot)) {
455             $CFG->behat_wwwroot = 'http://itwillnotbeused.com';
456         }
458         $suites = $this->get_behat_suites($parallelruns, $currentrun);
460         $overriddenthemescontexts = $this->get_overridden_theme_contexts();
461         if (!empty($overriddenthemescontexts)) {
462             $allcontexts = array_merge($this->contexts, $overriddenthemescontexts);
463         } else {
464             $allcontexts = $this->contexts;
465         }
467         // Remove selectors from step definitions.
468         $themes = $this->get_list_of_themes();
469         $selectortypes = ['partial', 'exact'];
470         foreach ($themes as $theme) {
471             foreach ($selectortypes as $selectortype) {
472                 // Don't include selector classes.
473                 $selectorclass = self::get_behat_theme_selector_override_classname($theme, $selectortype);
474                 if (isset($allcontexts[$selectorclass])) {
475                     unset($allcontexts[$selectorclass]);
476                 }
477             }
478         }
480         // Comments use black color, so failure path is not visible. Using color other then black/white is safer.
481         // https://github.com/Behat/Behat/pull/628.
482         $config = array(
483             'default' => array(
484                 'formatters' => array(
485                     'moodle_progress' => array(
486                         'output_styles' => array(
487                             'comment' => array('magenta'))
488                     )
489                 ),
490                 'suites' => $suites,
491                 'extensions' => array(
492                     'Behat\MinkExtension' => array(
493                         'base_url' => $CFG->behat_wwwroot,
494                         'goutte' => null,
495                         'selenium2' => $selenium2wdhost
496                     ),
497                     'Moodle\BehatExtension' => array(
498                         'moodledirroot' => $CFG->dirroot,
499                         'steps_definitions' => $allcontexts,
500                     )
501                 )
502             )
503         );
505         return $config;
506     }
508     /**
509      * Divide features between the runs and return list.
510      *
511      * @param array $features list of features to be divided.
512      * @param int $parallelruns how many parallel runs feature needs to be divided.
513      * @param int $currentrun current run for which features should be returned.
514      * @return array
515      */
516     protected function get_features_for_the_run($features, $parallelruns, $currentrun) {
518         // If no features are passed then just return.
519         if (empty($features)) {
520             return $features;
521         }
523         $allocatedfeatures = $features;
525         // If parallel run, then only divide features.
526         if (!empty($currentrun) && !empty($parallelruns)) {
527             // Attempt to split into weighted buckets using timing information, if available.
528             if ($alloc = $this->profile_guided_allocate($features, max(1, $parallelruns), $currentrun)) {
529                 $allocatedfeatures = $alloc;
530             } else {
531                 // Divide the list of feature files amongst the parallel runners.
532                 // Pull out the features for just this worker.
533                 if (count($features)) {
534                     $features = array_chunk($features, ceil(count($features) / max(1, $parallelruns)));
536                     // Check if there is any feature file for this process.
537                     if (!empty($features[$currentrun - 1])) {
538                         $allocatedfeatures = $features[$currentrun - 1];
539                     } else {
540                         $allocatedfeatures = array();
541                     }
542                 }
543             }
544         }
546         return $allocatedfeatures;
547     }
549     /**
550      * Parse $CFG->behat_profile and return the array with required config structure for behat.yml.
551      *
552      * $CFG->behat_profiles = array(
553      *     'profile' = array(
554      *         'browser' => 'firefox',
555      *         'tags' => '@javascript',
556      *         'wd_host' => 'http://127.0.0.1:4444/wd/hub',
557      *         'capabilities' => array(
558      *             'platform' => 'Linux',
559      *             'version' => 44
560      *         )
561      *     )
562      * );
563      *
564      * @param string $profile profile name
565      * @param array $values values for profile.
566      * @return array
567      */
568     protected function get_behat_profile($profile, $values) {
569         // Values should be an array.
570         if (!is_array($values)) {
571             return array();
572         }
574         // Check suite values.
575         $behatprofilesuites = array();
576         // Fill tags information.
577         if (isset($values['tags'])) {
578             $behatprofilesuites = array(
579                 'suites' => array(
580                     'default' => array(
581                         'filters' => array(
582                             'tags' => $values['tags'],
583                         )
584                     )
585                 )
586             );
587         }
589         // Selenium2 config values.
590         $behatprofileextension = array();
591         $seleniumconfig = array();
592         if (isset($values['browser'])) {
593             $seleniumconfig['browser'] = $values['browser'];
594         }
595         if (isset($values['wd_host'])) {
596             $seleniumconfig['wd_host'] = $values['wd_host'];
597         }
598         if (isset($values['capabilities'])) {
599             $seleniumconfig['capabilities'] = $values['capabilities'];
600         }
601         if (!empty($seleniumconfig)) {
602             $behatprofileextension = array(
603                 'extensions' => array(
604                     'Behat\MinkExtension' => array(
605                         'selenium2' => $seleniumconfig,
606                     )
607                 )
608             );
609         }
611         return array($profile => array_merge($behatprofilesuites, $behatprofileextension));
612     }
614     /**
615      * Attempt to split feature list into fairish buckets using timing information, if available.
616      * Simply add each one to lightest buckets until all files allocated.
617      * PGA = Profile Guided Allocation. I made it up just now.
618      * CAUTION: workers must agree on allocation, do not be random anywhere!
619      *
620      * @param array $features Behat feature files array
621      * @param int $nbuckets Number of buckets to divide into
622      * @param int $instance Index number of this instance
623      * @return array|bool Feature files array, sorted into allocations
624      */
625     public function profile_guided_allocate($features, $nbuckets, $instance) {
627         // No profile guided allocation is required in phpunit.
628         if (defined('PHPUNIT_TEST')) {
629             return false;
630         }
632         $behattimingfile = defined('BEHAT_FEATURE_TIMING_FILE') &&
633         @filesize(BEHAT_FEATURE_TIMING_FILE) ? BEHAT_FEATURE_TIMING_FILE : false;
635         if (!$behattimingfile || !$behattimingdata = @json_decode(file_get_contents($behattimingfile), true)) {
636             // No data available, fall back to relying on steps data.
637             $stepfile = "";
638             if (defined('BEHAT_FEATURE_STEP_FILE') && BEHAT_FEATURE_STEP_FILE) {
639                 $stepfile = BEHAT_FEATURE_STEP_FILE;
640             }
641             // We should never get this. But in case we can't do this then fall back on simple splitting.
642             if (empty($stepfile) || !$behattimingdata = @json_decode(file_get_contents($stepfile), true)) {
643                 return false;
644             }
645         }
647         arsort($behattimingdata); // Ensure most expensive is first.
649         $realroot = realpath(__DIR__.'/../../../').'/';
650         $defaultweight = array_sum($behattimingdata) / count($behattimingdata);
651         $weights = array_fill(0, $nbuckets, 0);
652         $buckets = array_fill(0, $nbuckets, array());
653         $totalweight = 0;
655         // Re-key the features list to match timing data.
656         foreach ($features as $k => $file) {
657             $key = str_replace($realroot, '', $file);
658             $features[$key] = $file;
659             unset($features[$k]);
660             if (!isset($behattimingdata[$key])) {
661                 $behattimingdata[$key] = $defaultweight;
662             }
663         }
665         // Sort features by known weights; largest ones should be allocated first.
666         $behattimingorder = array();
667         foreach ($features as $key => $file) {
668             $behattimingorder[$key] = $behattimingdata[$key];
669         }
670         arsort($behattimingorder);
672         // Finally, add each feature one by one to the lightest bucket.
673         foreach ($behattimingorder as $key => $weight) {
674             $file = $features[$key];
675             $lightbucket = array_search(min($weights), $weights);
676             $weights[$lightbucket] += $weight;
677             $buckets[$lightbucket][] = $file;
678             $totalweight += $weight;
679         }
681         if ($totalweight && !defined('BEHAT_DISABLE_HISTOGRAM') && $instance == $nbuckets && !defined('PHPUNIT_TEST')) {
682             echo "Bucket weightings:\n";
683             foreach ($weights as $k => $weight) {
684                 echo $k + 1 . ": " . str_repeat('*', 70 * $nbuckets * $weight / $totalweight) . PHP_EOL;
685             }
686         }
688         // Return the features for this worker.
689         return $buckets[$instance - 1];
690     }
692     /**
693      * Overrides default config with local config values
694      *
695      * array_merge does not merge completely the array's values
696      *
697      * @param mixed $config The node of the default config
698      * @param mixed $localconfig The node of the local config
699      * @return mixed The merge result
700      */
701     public function merge_config($config, $localconfig) {
703         if (!is_array($config) && !is_array($localconfig)) {
704             return $localconfig;
705         }
707         // Local overrides also deeper default values.
708         if (is_array($config) && !is_array($localconfig)) {
709             return $localconfig;
710         }
712         foreach ($localconfig as $key => $value) {
714             // If defaults are not as deep as local values let locals override.
715             if (!is_array($config)) {
716                 unset($config);
717             }
719             // Add the param if it doesn't exists or merge branches.
720             if (empty($config[$key])) {
721                 $config[$key] = $value;
722             } else {
723                 $config[$key] = $this->merge_config($config[$key], $localconfig[$key]);
724             }
725         }
727         return $config;
728     }
730     /**
731      * Merges $CFG->behat_config with the one passed.
732      *
733      * @param array $config existing config.
734      * @return array merged config with $CFG->behat_config
735      */
736     public function merge_behat_config($config) {
737         global $CFG;
739         // In case user defined overrides respect them over our default ones.
740         if (!empty($CFG->behat_config)) {
741             foreach ($CFG->behat_config as $profile => $values) {
742                 $config = $this->merge_config($config, $this->get_behat_config_for_profile($profile, $values));
743             }
744         }
746         return $config;
747     }
749     /**
750      * Parse $CFG->behat_config and return the array with required config structure for behat.yml
751      *
752      * @param string $profile profile name
753      * @param array $values values for profile
754      * @return array
755      */
756     public function get_behat_config_for_profile($profile, $values) {
757         // Only add profile which are compatible with Behat 3.x
758         // Just check if any of Bheat 2.5 config is set. Not checking for 3.x as it might have some other configs
759         // Like : rerun_cache etc.
760         if (!isset($values['filters']['tags']) && !isset($values['extensions']['Behat\MinkExtension\Extension'])) {
761             return array($profile => $values);
762         }
764         // Parse 2.5 format and get related values.
765         $oldconfigvalues = array();
766         if (isset($values['extensions']['Behat\MinkExtension\Extension'])) {
767             $extensionvalues = $values['extensions']['Behat\MinkExtension\Extension'];
768             if (isset($extensionvalues['selenium2']['browser'])) {
769                 $oldconfigvalues['browser'] = $extensionvalues['selenium2']['browser'];
770             }
771             if (isset($extensionvalues['selenium2']['wd_host'])) {
772                 $oldconfigvalues['wd_host'] = $extensionvalues['selenium2']['wd_host'];
773             }
774             if (isset($extensionvalues['capabilities'])) {
775                 $oldconfigvalues['capabilities'] = $extensionvalues['capabilities'];
776             }
777         }
779         if (isset($values['filters']['tags'])) {
780             $oldconfigvalues['tags'] = $values['filters']['tags'];
781         }
783         if (!empty($oldconfigvalues)) {
784             behat_config_manager::$autoprofileconversion = true;
785             return $this->get_behat_profile($profile, $oldconfigvalues);
786         }
788         // If nothing set above then return empty array.
789         return array();
790     }
792     /**
793      * Merges $CFG->behat_profiles with the one passed.
794      *
795      * @param array $config existing config.
796      * @return array merged config with $CFG->behat_profiles
797      */
798     public function merge_behat_profiles($config) {
799         global $CFG;
801         // Check for Moodle custom ones.
802         if (!empty($CFG->behat_profiles) && is_array($CFG->behat_profiles)) {
803             foreach ($CFG->behat_profiles as $profile => $values) {
804                 $config = $this->merge_config($config, $this->get_behat_profile($profile, $values));
805             }
806         }
808         return $config;
809     }
811     /**
812      * Cleans the path returned by get_components_with_tests() to standarize it
813      *
814      * @see tests_finder::get_all_directories_with_tests() it returns the path including /tests/
815      * @param string $path
816      * @return string The string without the last /tests part
817      */
818     public final function clean_path($path) {
820         $path = rtrim($path, DIRECTORY_SEPARATOR);
822         $parttoremove = DIRECTORY_SEPARATOR . 'tests';
824         $substr = substr($path, strlen($path) - strlen($parttoremove));
825         if ($substr == $parttoremove) {
826             $path = substr($path, 0, strlen($path) - strlen($parttoremove));
827         }
829         return rtrim($path, DIRECTORY_SEPARATOR);
830     }
832     /**
833      * The relative path where components stores their behat tests
834      *
835      * @return string
836      */
837     public static final function get_behat_tests_path() {
838         return DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'behat';
839     }
841     /**
842      * Return context name of behat_theme selector to use.
843      *
844      * @param string $themename name of the theme.
845      * @param string $selectortype The type of selector (partial or exact at this stage)
846      * @param bool $includeclass if class should be included.
847      * @return string
848      */
849     public static final function get_behat_theme_selector_override_classname($themename, $selectortype, $includeclass = false) {
850         global $CFG;
852         if ($selectortype !== 'partial' && $selectortype !== 'exact') {
853             throw new coding_exception("Unknown selector override type '{$selectortype}'");
854         }
856         $overridebehatclassname = "behat_theme_{$themename}_behat_{$selectortype}_selectors";
858         if ($includeclass) {
859             $themeoverrideselector = $CFG->dirroot . DIRECTORY_SEPARATOR . 'theme' . DIRECTORY_SEPARATOR . $themename .
860                 self::get_behat_tests_path() . DIRECTORY_SEPARATOR . $overridebehatclassname . '.php';
862             if (file_exists($themeoverrideselector)) {
863                 require_once($themeoverrideselector);
864             }
865         }
867         return $overridebehatclassname;
868     }
870     /**
871      * List of components which contain behat context or features.
872      *
873      * @return array
874      */
875     private function get_components_with_tests() {
876         if (empty($this->componentswithtests)) {
877             $this->componentswithtests = tests_finder::get_components_with_tests('behat');
878         }
880         return $this->componentswithtests;
881     }
883     /**
884      * Remove list of blacklisted features from the feature list.
885      *
886      * @param array $features list of original features.
887      * @param array|string $blacklist list of features which needs to be removed.
888      * @return array features - blacklisted features.
889      */
890     protected function remove_blacklisted_features_from_list($features, $blacklist) {
892         // If no blacklist passed then return.
893         if (empty($blacklist)) {
894             return $features;
895         }
897         // If there is no feature in suite then just return what was passed.
898         if (empty($features)) {
899             return $features;
900         }
902         if (!is_array($blacklist)) {
903             $blacklist = array($blacklist);
904         }
906         // Remove blacklisted features.
907         foreach ($blacklist as $blacklistpath) {
909             list($key, $featurepath) = $this->get_clean_feature_key_and_path($blacklistpath);
911             if (isset($features[$key])) {
912                 $features[$key] = null;
913                 unset($features[$key]);
914             } else if (empty($this->tags)) {
915                 // If tags not set, then ensure we have a blacklisted feature in core. Else, let user know that
916                 // blacklisted feature is invalid.
917                 $featurestocheck = $this->get_components_features();
918                 if (!isset($featurestocheck[$key]) && !defined('PHPUNIT_TEST')) {
919                     behat_error(BEHAT_EXITCODE_REQUIREMENT, 'Blacklisted feature "' . $blacklistpath . '" not found.');
920                 }
921             }
922         }
924         return $features;
925     }
927     /**
928      * Return list of behat suites. Multiple suites are returned if theme
929      * overrides default step definitions/features.
930      *
931      * @param int $parallelruns number of parallel runs
932      * @param int $currentrun current run.
933      * @return array list of suites.
934      */
935     protected function get_behat_suites($parallelruns = 0, $currentrun = 0) {
936         $features = $this->get_components_features();
937         $contexts = $this->get_components_contexts();
939         // Get number of parallel runs and current run.
940         if (!empty($parallelruns) && !empty($currentrun)) {
941             $this->set_parallel_run($parallelruns, $currentrun);
942         } else {
943             $parallelruns = $this->get_number_of_parallel_run();
944             $currentrun = $this->get_current_run();;
945         }
947         $blacklistedfeatures = array();
948         $themefeatures = array();
949         $themesuitecontexts = array();
950         $themecontexts = array();
952         $themes = $this->get_list_of_themes();
954         // Create list of theme suite features and contexts.
955         foreach ($themes as $theme) {
956             // Get theme features.
957             list($blacklistedfeatures[$theme], $themefeatures[$theme]) = $this->get_behat_features_for_theme($theme);
959             list($themecontexts[$theme], $themesuitecontexts[$theme]) = $this->get_behat_contexts_for_theme($theme);
960         }
962         // Remove list of theme features for default suite, as default suite should not run theme specific features.
963         foreach ($themefeatures as $removethemefeatures) {
964             $features = $this->remove_blacklisted_features_from_list($features, $removethemefeatures);
965         }
967         // Remove list of theme features for default suite, as default suite should not run theme specific features.
968         foreach ($themecontexts as $theme => $themeblacklistcontexts) {
969             if ($themeblacklistcontexts) {
970                 foreach ($themeblacklistcontexts as $c) {
971                     // Remove theme specific contexts from default contexts.
972                     unset($contexts[$c]);
974                     // Remove theme specific contexts from other themes.
975                     foreach ($themes as $currenttheme) {
976                         if (($currenttheme != $theme) && isset($themesuitecontexts[$currenttheme][$c])) {
977                             unset($themesuitecontexts[$currenttheme][$c]);
978                         }
979                     }
980                 }
981             }
982         }
984         // Return sub-set of features if parallel run.
985         $featuresforrun = $this->get_features_for_the_run($features, $parallelruns, $currentrun);
987         // Default suite.
988         $suites = array(
989             'default' => array(
990                 'paths' => array_values($featuresforrun),
991                 'contexts' => array_keys($contexts),
992             )
993         );
995         // Set suite for each theme.
996         foreach ($themes as $theme) {
997             // Get list of features which will be included in theme.
998             // If theme suite with all features is set, then we want all core features to be part of theme suite.
999             if ($this->themesuitewithallfeatures) {
1000                 // If there is no theme specific feature. Then it's just core features.
1001                 if (empty($themefeatures[$theme])) {
1002                     $themesuitefeatures = $features;
1003                 } else {
1004                     $themesuitefeatures = array_merge($features, $themefeatures[$theme]);
1005                 }
1006             } else {
1007                 $themesuitefeatures = $themefeatures[$theme];
1008             }
1010             // Remove blacklisted features.
1011             $themesuitefeatures = $this->remove_blacklisted_features_from_list($themesuitefeatures, $blacklistedfeatures[$theme]);
1013             // Return sub-set of features if parallel run.
1014             $themesuitefeatures = $this->get_features_for_the_run($themesuitefeatures, $parallelruns, $currentrun);
1016             // Add suite no matter what. If there is no feature in suite then it will just exist successfully with no
1017             // scenarios. But if we don't set this then the user has to know which run doesn't have suite and which run do.
1018             $suites = array_merge($suites, array(
1019                 $theme => array(
1020                     'paths'    => array_values($themesuitefeatures),
1021                     'contexts' => array_keys($themesuitecontexts[$theme]),
1022                 )
1023             ));
1024         }
1026         return $suites;
1027     }
1029     /**
1030      * Return list of themes which can be set in moodle.
1031      *
1032      * @return array list of themes with tests.
1033      */
1034     protected function get_list_of_themes() {
1035         $selectablethemes = array();
1037         // Get all themes installed on site.
1038         $themes = core_component::get_plugin_list('theme');
1039         ksort($themes);
1041         foreach ($themes as $themename => $themedir) {
1042             // Load the theme config.
1043             try {
1044                 $theme = theme_config::load($themename);
1045             } catch (Exception $e) {
1046                 // Bad theme, just skip it for now.
1047                 continue;
1048             }
1049             if ($themename !== $theme->name) {
1050                 // Obsoleted or broken theme, just skip for now.
1051                 continue;
1052             }
1053             if ($theme->hidefromselector) {
1054                 // The theme doesn't want to be shown in the theme selector and as theme
1055                 // designer mode is switched off we will respect that decision.
1056                 continue;
1057             }
1058             if ($themename == theme_config::DEFAULT_THEME) {
1059                 // Don't include default theme, as default suite will be running with this theme.
1060                 continue;
1061             }
1062             $selectablethemes[] = $themename;
1063         }
1065         return $selectablethemes;
1066     }
1068     /**
1069      * Returns all the directories having overridden tests.
1070      *
1071      * @param string $theme name of theme
1072      * @param string $testtype The kind of test we are looking for
1073      * @return array all directories having tests
1074      */
1075     protected function get_test_directories_overridden_for_theme($theme, $testtype) {
1076         global $CFG;
1078         $testtypes = array(
1079             'contexts' => '|behat_.*\.php$|',
1080             'features' => '|.*\.feature$|',
1081         );
1082         $themetestdir = "/theme/" . $theme . '/tests/behat';
1083         $themetestdirfullpath = $CFG->dirroot . $themetestdir;
1085         // If test directory doesn't exist then return.
1086         if (!is_dir($themetestdirfullpath)) {
1087             return array();
1088         }
1090         $directoriestosearch = glob($themetestdirfullpath . DIRECTORY_SEPARATOR . '*' , GLOB_ONLYDIR);
1092         // Include theme directory to find tests.
1093         $dirs[realpath($themetestdirfullpath)] = trim(str_replace('/', '_', $themetestdir), '_');
1095         // Search for tests in valid directories.
1096         foreach ($directoriestosearch as $dir) {
1097             $dirite = new RecursiveDirectoryIterator($dir);
1098             $iteite = new RecursiveIteratorIterator($dirite);
1099             $regexp = $testtypes[$testtype];
1100             $regite = new RegexIterator($iteite, $regexp);
1101             foreach ($regite as $path => $element) {
1102                 $key = dirname($path);
1103                 $value = trim(str_replace(DIRECTORY_SEPARATOR, '_', str_replace($CFG->dirroot, '', $key)), '_');
1104                 $dirs[$key] = $value;
1105             }
1106         }
1107         ksort($dirs);
1109         return array_flip($dirs);
1110     }
1112     /**
1113      * Return blacklisted contexts or features for a theme, as defined in blacklist.json.
1114      *
1115      * @param string $theme themename
1116      * @param string $testtype test type (contexts|features)
1117      * @return array list of blacklisted contexts or features
1118      */
1119     protected function get_blacklisted_tests_for_theme($theme, $testtype) {
1120         global $CFG;
1122         $themetestpath = $CFG->dirroot . DIRECTORY_SEPARATOR . "theme" . DIRECTORY_SEPARATOR . $theme .
1123             self::get_behat_tests_path();
1125         if (file_exists($themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json')) {
1126             // Blacklist file exist. Leave it for last to clear the feature and contexts.
1127             $blacklisttests = @json_decode(file_get_contents($themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json'), true);
1128             if (empty($blacklisttests)) {
1129                 behat_error(BEHAT_EXITCODE_REQUIREMENT, $themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json is empty');
1130             }
1132             // If features or contexts not defined then no problem.
1133             if (!isset($blacklisttests[$testtype])) {
1134                 $blacklisttests[$testtype] = array();
1135             }
1136             return $blacklisttests[$testtype];
1137         }
1139         return array();
1140     }
1142     /**
1143      * Return list of features and step definitions in theme.
1144      *
1145      * @param string $theme theme name
1146      * @param string $testtype test type, either features or contexts
1147      * @return array list of contexts $contexts or $features
1148      */
1149     protected function get_tests_for_theme($theme, $testtype) {
1151         $tests = array();
1152         $testtypes = array(
1153             'contexts' => '|behat_.*\.php$|',
1154             'features' => '|.*\.feature$|',
1155         );
1157         // Get all the directories having overridden tests.
1158         $directories = $this->get_test_directories_overridden_for_theme($theme, $testtype);
1160         // Get overridden test contexts.
1161         foreach ($directories as $dirpath) {
1162             // All behat_*.php inside overridden directory.
1163             $diriterator = new DirectoryIterator($dirpath);
1164             $regite = new RegexIterator($diriterator, $testtypes[$testtype]);
1166             // All behat_*.php inside behat_config_manager::get_behat_tests_path() are added as steps definitions files.
1167             foreach ($regite as $file) {
1168                 $key = $file->getBasename('.php');
1169                 $tests[$key] = $file->getPathname();
1170             }
1171         }
1173         return $tests;
1174     }
1176     /**
1177      * Return list of blacklisted behat features for theme and features defined by theme only.
1178      *
1179      * @param string $theme theme name.
1180      * @return array ($themeblacklistfeatures, $themefeatures)
1181      */
1182     protected function get_behat_features_for_theme($theme) {
1184         // Get list of features defined by theme.
1185         $themefeatures = $this->get_tests_for_theme($theme, 'features');
1186         $themeblacklistfeatures = $this->get_blacklisted_tests_for_theme($theme, 'features');
1188         // If tags are specified then we just want features with specified tags.
1189         if (!empty($this->tags)) {
1190             if (!empty($themefeatures)) {
1191                 $themefeatures = $this->filtered_features_with_tags($themefeatures);
1192             }
1193         }
1195         return array($themeblacklistfeatures, $themefeatures);
1196     }
1198     /**
1199      * Return list of contexts overridden by themes.
1200      *
1201      * @return array.
1202      */
1203     protected function get_overridden_theme_contexts() {
1204         if (empty($this->overriddenthemescontexts)) {
1205             $this->overriddenthemescontexts = array();
1206         }
1208         return $this->overriddenthemescontexts;
1209     }
1211     /**
1212      * Return list of behat contexts for theme and update $this->stepdefinitions list.
1213      *
1214      * @param string $theme theme name.
1215      * @return array list($themecontexts, $themesuitecontexts)
1216      */
1217     protected function get_behat_contexts_for_theme($theme) {
1219         // If we already have this list then just return. This will not change by run.
1220         if (!empty($this->themecontexts[$theme]) && !empty($this->themesuitecontexts)) {
1221             return array(array_keys($this->themecontexts[$theme]), $this->themesuitecontexts[$theme]);
1222         }
1224         if (empty($this->overriddenthemescontexts)) {
1225             $this->overriddenthemescontexts = array();
1226         }
1228         $contexts = $this->get_components_contexts();
1230         // Create list of contexts used by theme suite.
1231         $themecontexts = $this->get_tests_for_theme($theme, 'contexts');
1232         $blacklistedcontexts = $this->get_blacklisted_tests_for_theme($theme, 'contexts');
1234         // Theme suite will use all core contexts, except the one overridden by theme.
1235         $themesuitecontexts = $contexts;
1237         foreach ($themecontexts as $context => $path) {
1239             // If a context in theme starts with behat_theme_{themename}_behat_* then it's overriding core context.
1240             if (preg_match('/^behat_theme_'.$theme.'_(\w+)$/', $context, $match)) {
1242                 if (!empty($themesuitecontexts[$match[1]])) {
1243                     unset($themesuitecontexts[$match[1]]);
1244                 }
1246                 // Add this to the list of overridden paths, so it can be added to final contexts list for class resolver.
1247                 $this->overriddenthemescontexts[$context] = $path;
1248             }
1250             $selectortypes = ['partial', 'exact'];
1251             foreach ($selectortypes as $selectortype) {
1252                 // Don't include selector classes.
1253                 if ($context === self::get_behat_theme_selector_override_classname($theme, $selectortype)) {
1254                     unset($this->contexts[$context]);
1255                     unset($themesuitecontexts[$context]);
1256                     continue;
1257                 }
1258             }
1260             // Add theme specific contexts with suffix to steps definitions.
1261             $themesuitecontexts[$context] = $path;
1262         }
1264         // Remove blacklisted contexts.
1265         foreach ($blacklistedcontexts as $blacklistpath) {
1266             $blacklistcontext = basename($blacklistpath, '.php');
1268             unset($themesuitecontexts[$blacklistcontext]);
1269         }
1271         // We are only interested in the class name of context.
1272         $this->themesuitecontexts[$theme] = $themesuitecontexts;
1273         $this->themecontexts[$theme] = $themecontexts;
1275         return array(array_keys($themecontexts), $themesuitecontexts);
1276     }