Merge branch 'MDL-55713-master-3' of git://github.com/andrewnicols/moodle
[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         // If tags are passed then filter features which has sepecified tags.
214         if (!empty($tags)) {
215             $features = $this->filtered_features_with_tags($features, $tags);
216         }
218         // Return sorted list.
219         ksort($features);
221         return $features;
222     }
224     /**
225      * Return feature key for featurepath
226      *
227      * @param string $featurepath
228      * @return array key and featurepath.
229      */
230     public function get_clean_feature_key_and_path($featurepath) {
231         global $CFG;
233         // Fix directory path.
234         $featurepath = testing_cli_fix_directory_separator($featurepath);
235         $dirroot = testing_cli_fix_directory_separator($CFG->dirroot . DIRECTORY_SEPARATOR);
237         $key = basename($featurepath, '.feature');
239         // Get relative path.
240         $featuredirname = str_replace($dirroot , '', $featurepath);
241         // Get 5 levels of feature path to ensure we have a unique key.
242         for ($i = 0; $i < 5; $i++) {
243             if (($featuredirname = dirname($featuredirname)) && $featuredirname !== '.') {
244                 if ($basename = basename($featuredirname)) {
245                     $key .= '_' . $basename;
246                 }
247             }
248         }
250         return array($key, $featurepath);
251     }
253     /**
254      * Get component contexts.
255      *
256      * @param string $component component name.
257      * @return array
258      */
259     private function get_component_contexts($component) {
261         if (empty($component)) {
262             return $this->contexts;
263         }
265         $componentcontexts = array();
266         foreach ($this->contexts as $key => $path) {
267             if ($component == '' || $component === $key) {
268                 $componentcontexts[$key] = $path;
269             }
270         }
272         return $componentcontexts;
273     }
275     /**
276      * Gets the list of Moodle behat contexts
277      *
278      * Class name as a key and the filepath as value
279      *
280      * Externalized from update_config_file() to use
281      * it from the steps definitions web interface
282      *
283      * @param  string $component Restricts the obtained steps definitions to the specified component
284      * @return array
285      */
286     public function get_components_contexts($component = '') {
288         // If we already have a list created then just return that, as it's up-to-date.
289         if (!empty($this->contexts)) {
290             return $this->get_component_contexts($component);
291         }
293         $components = $this->get_components_with_tests();
295         $this->contexts = array();
296         foreach ($components as $componentname => $componentpath) {
297             $componentpath = self::clean_path($componentpath);
299             if (!file_exists($componentpath . self::get_behat_tests_path())) {
300                 continue;
301             }
302             $diriterator = new DirectoryIterator($componentpath . self::get_behat_tests_path());
303             $regite = new RegexIterator($diriterator, '|behat_.*\.php$|');
305             // All behat_*.php inside self::get_behat_tests_path() are added as steps definitions files.
306             foreach ($regite as $file) {
307                 $key = $file->getBasename('.php');
308                 $this->contexts[$key] = $file->getPathname();
309             }
310         }
312         // Sort contexts with there name.
313         ksort($this->contexts);
315         return $this->get_component_contexts($component);
316     }
318     /**
319      * Behat config file specifing the main context class,
320      * the required Behat extensions and Moodle test wwwroot.
321      *
322      * @param array $features The system feature files
323      * @param array $contexts The system steps definitions
324      * @param string $tags filter features with specified tags.
325      * @param int $parallelruns number of parallel runs.
326      * @param int $currentrun current run for which config file is needed.
327      * @return string
328      */
329     public function get_config_file_contents($features = '', $contexts = '', $tags = '', $parallelruns = 0, $currentrun = 0) {
330         global $CFG;
332         // Set current run and parallel run.
333         if (!empty($parallelruns) && !empty($currentrun)) {
334             $this->set_parallel_run($parallelruns, $currentrun);
335         }
337         // If tags defined then use them. This is for BC.
338         if (!empty($tags)) {
339             $this->set_tag_for_feature_filter($tags);
340         }
342         // If features not passed then get it. Empty array means we don't need to include features.
343         if (empty($features) && !is_array($features)) {
344             $features = $this->get_components_features();
345         } else {
346             $this->features = $features;
347         }
349         // If stepdefinitions not passed then get the list.
350         if (empty($contexts)) {
351             $this->get_components_contexts();
352         } else {
353             $this->contexts = $contexts;
354         }
356         // We require here when we are sure behat dependencies are available.
357         require_once($CFG->dirroot . '/vendor/autoload.php');
359         $config = $this->build_config();
361         $config = $this->merge_behat_config($config);
363         $config = $this->merge_behat_profiles($config);
365         // Return config array for phpunit, so it can be tested.
366         if (defined('PHPUNIT_TEST') && PHPUNIT_TEST) {
367             return $config;
368         }
370         return Symfony\Component\Yaml\Yaml::dump($config, 10, 2);
371     }
373     /**
374      * Search feature files for set of tags.
375      *
376      * @param array $features set of feature files.
377      * @param string $tags list of tags (currently support && only.)
378      * @return array filtered list of feature files with tags.
379      */
380     public function filtered_features_with_tags($features = '', $tags = '') {
382         // This is for BC. Features if not passed then we already have a list in this object.
383         if (empty($features)) {
384             $features = $this->features;
385         }
387         // If no tags defined then return full list.
388         if (empty($tags) && empty($this->tags)) {
389             return $features;
390         }
392         // If no tags passed by the caller, then it's already set.
393         if (empty($tags)) {
394             $tags = $this->tags;
395         }
397         $newfeaturelist = array();
398         // Split tags in and and or.
399         $tags = explode('&&', $tags);
400         $andtags = array();
401         $ortags = array();
402         foreach ($tags as $tag) {
403             // Explode all tags seperated by , and add it to ortags.
404             $ortags = array_merge($ortags, explode(',', $tag));
405             // And tags will be the first one before comma(,).
406             $andtags[] = preg_replace('/,.*/', '', $tag);
407         }
409         foreach ($features as $key => $featurefile) {
410             $contents = file_get_contents($featurefile);
411             $includefeature = true;
412             foreach ($andtags as $tag) {
413                 // If negitive tag, then ensure it don't exist.
414                 if (strpos($tag, '~') !== false) {
415                     $tag = substr($tag, 1);
416                     if ($contents && strpos($contents, $tag) !== false) {
417                         $includefeature = false;
418                         break;
419                     }
420                 } else if ($contents && strpos($contents, $tag) === false) {
421                     $includefeature = false;
422                     break;
423                 }
424             }
426             // If feature not included then check or tags.
427             if (!$includefeature && !empty($ortags)) {
428                 foreach ($ortags as $tag) {
429                     if ($contents && (strpos($tag, '~') === false) && (strpos($contents, $tag) !== false)) {
430                         $includefeature = true;
431                         break;
432                     }
433                 }
434             }
436             if ($includefeature) {
437                 $newfeaturelist[$key] = $featurefile;
438             }
439         }
440         return $newfeaturelist;
441     }
443     /**
444      * Build config for behat.yml.
445      *
446      * @param int $parallelruns how many parallel runs feature needs to be divided.
447      * @param int $currentrun current run for which features should be returned.
448      * @return array
449      */
450     protected function build_config($parallelruns = 0, $currentrun = 0) {
451         global $CFG;
453         if (!empty($parallelruns) && !empty($currentrun)) {
454             $this->set_parallel_run($parallelruns, $currentrun);
455         } else {
456             $currentrun = $this->get_current_run();
457             $parallelruns = $this->get_number_of_parallel_run();
458         }
460         $selenium2wdhost = array('wd_host' => 'http://localhost:4444/wd/hub');
461         // If parallel run, then set wd_host if specified.
462         if (!empty($currentrun) && !empty($parallelruns)) {
463             // Set proper selenium2 wd_host if defined.
464             if (!empty($CFG->behat_parallel_run[$currentrun - 1]['wd_host'])) {
465                 $selenium2wdhost = array('wd_host' => $CFG->behat_parallel_run[$currentrun - 1]['wd_host']);
466             }
467         }
469         // It is possible that it has no value as we don't require a full behat setup to list the step definitions.
470         if (empty($CFG->behat_wwwroot)) {
471             $CFG->behat_wwwroot = 'http://itwillnotbeused.com';
472         }
474         $suites = $this->get_behat_suites($parallelruns, $currentrun);
476         $overriddenthemescontexts = $this->get_overridden_theme_contexts();
477         if (!empty($overriddenthemescontexts)) {
478             $allcontexts = array_merge($this->contexts, $overriddenthemescontexts);
479         } else {
480             $allcontexts = $this->contexts;
481         }
483         // Remove selectors from step definitions.
484         $themes = $this->get_list_of_themes();
485         $selectortypes = ['partial', 'exact'];
486         foreach ($themes as $theme) {
487             foreach ($selectortypes as $selectortype) {
488                 // Don't include selector classes.
489                 $selectorclass = self::get_behat_theme_selector_override_classname($theme, $selectortype);
490                 if (isset($allcontexts[$selectorclass])) {
491                     unset($allcontexts[$selectorclass]);
492                 }
493             }
494         }
496         // Comments use black color, so failure path is not visible. Using color other then black/white is safer.
497         // https://github.com/Behat/Behat/pull/628.
498         $config = array(
499             'default' => array(
500                 'formatters' => array(
501                     'moodle_progress' => array(
502                         'output_styles' => array(
503                             'comment' => array('magenta'))
504                     )
505                 ),
506                 'suites' => $suites,
507                 'extensions' => array(
508                     'Behat\MinkExtension' => array(
509                         'base_url' => $CFG->behat_wwwroot,
510                         'goutte' => null,
511                         'selenium2' => $selenium2wdhost
512                     ),
513                     'Moodle\BehatExtension' => array(
514                         'moodledirroot' => $CFG->dirroot,
515                         'steps_definitions' => $allcontexts,
516                     )
517                 )
518             )
519         );
521         return $config;
522     }
524     /**
525      * Divide features between the runs and return list.
526      *
527      * @param array $features list of features to be divided.
528      * @param int $parallelruns how many parallel runs feature needs to be divided.
529      * @param int $currentrun current run for which features should be returned.
530      * @return array
531      */
532     protected function get_features_for_the_run($features, $parallelruns, $currentrun) {
534         // If no features are passed then just return.
535         if (empty($features)) {
536             return $features;
537         }
539         $allocatedfeatures = $features;
541         // If parallel run, then only divide features.
542         if (!empty($currentrun) && !empty($parallelruns)) {
544             $featurestodivide['withtags'] = $features;
545             $allocatedfeatures = array();
547             // If tags are set then split features with tags first.
548             if (!empty($this->tags)) {
549                 $featurestodivide['withtags'] = $this->filtered_features_with_tags($features);
550                 $featurestodivide['withouttags'] = $this->remove_blacklisted_features_from_list($features,
551                     $featurestodivide['withtags']);
552             }
554             // Attempt to split into weighted buckets using timing information, if available.
555             foreach ($featurestodivide as $tagfeatures) {
556                 if ($alloc = $this->profile_guided_allocate($tagfeatures, max(1, $parallelruns), $currentrun)) {
557                     $allocatedfeatures = array_merge($allocatedfeatures, $alloc);
558                 } else {
559                     // Divide the list of feature files amongst the parallel runners.
560                     // Pull out the features for just this worker.
561                     if (count($tagfeatures)) {
562                         $splitfeatures = array_chunk($tagfeatures, ceil(count($tagfeatures) / max(1, $parallelruns)));
564                         // Check if there is any feature file for this process.
565                         if (!empty($splitfeatures[$currentrun - 1])) {
566                             $allocatedfeatures = array_merge($allocatedfeatures, $splitfeatures[$currentrun - 1]);
567                         }
568                     }
569                 }
570             }
571         }
573         return $allocatedfeatures;
574     }
576     /**
577      * Parse $CFG->behat_profile and return the array with required config structure for behat.yml.
578      *
579      * $CFG->behat_profiles = array(
580      *     'profile' = array(
581      *         'browser' => 'firefox',
582      *         'tags' => '@javascript',
583      *         'wd_host' => 'http://127.0.0.1:4444/wd/hub',
584      *         'capabilities' => array(
585      *             'platform' => 'Linux',
586      *             'version' => 44
587      *         )
588      *     )
589      * );
590      *
591      * @param string $profile profile name
592      * @param array $values values for profile.
593      * @return array
594      */
595     protected function get_behat_profile($profile, $values) {
596         // Values should be an array.
597         if (!is_array($values)) {
598             return array();
599         }
601         // Check suite values.
602         $behatprofilesuites = array();
603         // Fill tags information.
604         if (isset($values['tags'])) {
605             $behatprofilesuites = array(
606                 'suites' => array(
607                     'default' => array(
608                         'filters' => array(
609                             'tags' => $values['tags'],
610                         )
611                     )
612                 )
613             );
614         }
616         // Selenium2 config values.
617         $behatprofileextension = array();
618         $seleniumconfig = array();
619         if (isset($values['browser'])) {
620             $seleniumconfig['browser'] = $values['browser'];
621         }
622         if (isset($values['wd_host'])) {
623             $seleniumconfig['wd_host'] = $values['wd_host'];
624         }
625         if (isset($values['capabilities'])) {
626             $seleniumconfig['capabilities'] = $values['capabilities'];
627         }
628         if (!empty($seleniumconfig)) {
629             $behatprofileextension = array(
630                 'extensions' => array(
631                     'Behat\MinkExtension' => array(
632                         'selenium2' => $seleniumconfig,
633                     )
634                 )
635             );
636         }
638         return array($profile => array_merge($behatprofilesuites, $behatprofileextension));
639     }
641     /**
642      * Attempt to split feature list into fairish buckets using timing information, if available.
643      * Simply add each one to lightest buckets until all files allocated.
644      * PGA = Profile Guided Allocation. I made it up just now.
645      * CAUTION: workers must agree on allocation, do not be random anywhere!
646      *
647      * @param array $features Behat feature files array
648      * @param int $nbuckets Number of buckets to divide into
649      * @param int $instance Index number of this instance
650      * @return array|bool Feature files array, sorted into allocations
651      */
652     public function profile_guided_allocate($features, $nbuckets, $instance) {
654         // No profile guided allocation is required in phpunit.
655         if (defined('PHPUNIT_TEST')) {
656             return false;
657         }
659         $behattimingfile = defined('BEHAT_FEATURE_TIMING_FILE') &&
660         @filesize(BEHAT_FEATURE_TIMING_FILE) ? BEHAT_FEATURE_TIMING_FILE : false;
662         if (!$behattimingfile || !$behattimingdata = @json_decode(file_get_contents($behattimingfile), true)) {
663             // No data available, fall back to relying on steps data.
664             $stepfile = "";
665             if (defined('BEHAT_FEATURE_STEP_FILE') && BEHAT_FEATURE_STEP_FILE) {
666                 $stepfile = BEHAT_FEATURE_STEP_FILE;
667             }
668             // We should never get this. But in case we can't do this then fall back on simple splitting.
669             if (empty($stepfile) || !$behattimingdata = @json_decode(file_get_contents($stepfile), true)) {
670                 return false;
671             }
672         }
674         arsort($behattimingdata); // Ensure most expensive is first.
676         $realroot = realpath(__DIR__.'/../../../').'/';
677         $defaultweight = array_sum($behattimingdata) / count($behattimingdata);
678         $weights = array_fill(0, $nbuckets, 0);
679         $buckets = array_fill(0, $nbuckets, array());
680         $totalweight = 0;
682         // Re-key the features list to match timing data.
683         foreach ($features as $k => $file) {
684             $key = str_replace($realroot, '', $file);
685             $features[$key] = $file;
686             unset($features[$k]);
687             if (!isset($behattimingdata[$key])) {
688                 $behattimingdata[$key] = $defaultweight;
689             }
690         }
692         // Sort features by known weights; largest ones should be allocated first.
693         $behattimingorder = array();
694         foreach ($features as $key => $file) {
695             $behattimingorder[$key] = $behattimingdata[$key];
696         }
697         arsort($behattimingorder);
699         // Finally, add each feature one by one to the lightest bucket.
700         foreach ($behattimingorder as $key => $weight) {
701             $file = $features[$key];
702             $lightbucket = array_search(min($weights), $weights);
703             $weights[$lightbucket] += $weight;
704             $buckets[$lightbucket][] = $file;
705             $totalweight += $weight;
706         }
708         if ($totalweight && !defined('BEHAT_DISABLE_HISTOGRAM') && $instance == $nbuckets && !defined('PHPUNIT_TEST')) {
709             echo "Bucket weightings:\n";
710             foreach ($weights as $k => $weight) {
711                 echo $k + 1 . ": " . str_repeat('*', 70 * $nbuckets * $weight / $totalweight) . PHP_EOL;
712             }
713         }
715         // Return the features for this worker.
716         return $buckets[$instance - 1];
717     }
719     /**
720      * Overrides default config with local config values
721      *
722      * array_merge does not merge completely the array's values
723      *
724      * @param mixed $config The node of the default config
725      * @param mixed $localconfig The node of the local config
726      * @return mixed The merge result
727      */
728     public function merge_config($config, $localconfig) {
730         if (!is_array($config) && !is_array($localconfig)) {
731             return $localconfig;
732         }
734         // Local overrides also deeper default values.
735         if (is_array($config) && !is_array($localconfig)) {
736             return $localconfig;
737         }
739         foreach ($localconfig as $key => $value) {
741             // If defaults are not as deep as local values let locals override.
742             if (!is_array($config)) {
743                 unset($config);
744             }
746             // Add the param if it doesn't exists or merge branches.
747             if (empty($config[$key])) {
748                 $config[$key] = $value;
749             } else {
750                 $config[$key] = $this->merge_config($config[$key], $localconfig[$key]);
751             }
752         }
754         return $config;
755     }
757     /**
758      * Merges $CFG->behat_config with the one passed.
759      *
760      * @param array $config existing config.
761      * @return array merged config with $CFG->behat_config
762      */
763     public function merge_behat_config($config) {
764         global $CFG;
766         // In case user defined overrides respect them over our default ones.
767         if (!empty($CFG->behat_config)) {
768             foreach ($CFG->behat_config as $profile => $values) {
769                 $config = $this->merge_config($config, $this->get_behat_config_for_profile($profile, $values));
770             }
771         }
773         return $config;
774     }
776     /**
777      * Parse $CFG->behat_config and return the array with required config structure for behat.yml
778      *
779      * @param string $profile profile name
780      * @param array $values values for profile
781      * @return array
782      */
783     public function get_behat_config_for_profile($profile, $values) {
784         // Only add profile which are compatible with Behat 3.x
785         // Just check if any of Bheat 2.5 config is set. Not checking for 3.x as it might have some other configs
786         // Like : rerun_cache etc.
787         if (!isset($values['filters']['tags']) && !isset($values['extensions']['Behat\MinkExtension\Extension'])) {
788             return array($profile => $values);
789         }
791         // Parse 2.5 format and get related values.
792         $oldconfigvalues = array();
793         if (isset($values['extensions']['Behat\MinkExtension\Extension'])) {
794             $extensionvalues = $values['extensions']['Behat\MinkExtension\Extension'];
795             if (isset($extensionvalues['selenium2']['browser'])) {
796                 $oldconfigvalues['browser'] = $extensionvalues['selenium2']['browser'];
797             }
798             if (isset($extensionvalues['selenium2']['wd_host'])) {
799                 $oldconfigvalues['wd_host'] = $extensionvalues['selenium2']['wd_host'];
800             }
801             if (isset($extensionvalues['capabilities'])) {
802                 $oldconfigvalues['capabilities'] = $extensionvalues['capabilities'];
803             }
804         }
806         if (isset($values['filters']['tags'])) {
807             $oldconfigvalues['tags'] = $values['filters']['tags'];
808         }
810         if (!empty($oldconfigvalues)) {
811             behat_config_manager::$autoprofileconversion = true;
812             return $this->get_behat_profile($profile, $oldconfigvalues);
813         }
815         // If nothing set above then return empty array.
816         return array();
817     }
819     /**
820      * Merges $CFG->behat_profiles with the one passed.
821      *
822      * @param array $config existing config.
823      * @return array merged config with $CFG->behat_profiles
824      */
825     public function merge_behat_profiles($config) {
826         global $CFG;
828         // Check for Moodle custom ones.
829         if (!empty($CFG->behat_profiles) && is_array($CFG->behat_profiles)) {
830             foreach ($CFG->behat_profiles as $profile => $values) {
831                 $config = $this->merge_config($config, $this->get_behat_profile($profile, $values));
832             }
833         }
835         return $config;
836     }
838     /**
839      * Cleans the path returned by get_components_with_tests() to standarize it
840      *
841      * @see tests_finder::get_all_directories_with_tests() it returns the path including /tests/
842      * @param string $path
843      * @return string The string without the last /tests part
844      */
845     public final function clean_path($path) {
847         $path = rtrim($path, DIRECTORY_SEPARATOR);
849         $parttoremove = DIRECTORY_SEPARATOR . 'tests';
851         $substr = substr($path, strlen($path) - strlen($parttoremove));
852         if ($substr == $parttoremove) {
853             $path = substr($path, 0, strlen($path) - strlen($parttoremove));
854         }
856         return rtrim($path, DIRECTORY_SEPARATOR);
857     }
859     /**
860      * The relative path where components stores their behat tests
861      *
862      * @return string
863      */
864     public static final function get_behat_tests_path() {
865         return DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'behat';
866     }
868     /**
869      * Return context name of behat_theme selector to use.
870      *
871      * @param string $themename name of the theme.
872      * @param string $selectortype The type of selector (partial or exact at this stage)
873      * @param bool $includeclass if class should be included.
874      * @return string
875      */
876     public static final function get_behat_theme_selector_override_classname($themename, $selectortype, $includeclass = false) {
877         global $CFG;
879         if ($selectortype !== 'partial' && $selectortype !== 'exact') {
880             throw new coding_exception("Unknown selector override type '{$selectortype}'");
881         }
883         $overridebehatclassname = "behat_theme_{$themename}_behat_{$selectortype}_selectors";
885         if ($includeclass) {
886             $themeoverrideselector = $CFG->dirroot . DIRECTORY_SEPARATOR . 'theme' . DIRECTORY_SEPARATOR . $themename .
887                 self::get_behat_tests_path() . DIRECTORY_SEPARATOR . $overridebehatclassname . '.php';
889             if (file_exists($themeoverrideselector)) {
890                 require_once($themeoverrideselector);
891             }
892         }
894         return $overridebehatclassname;
895     }
897     /**
898      * List of components which contain behat context or features.
899      *
900      * @return array
901      */
902     protected function get_components_with_tests() {
903         if (empty($this->componentswithtests)) {
904             $this->componentswithtests = tests_finder::get_components_with_tests('behat');
905         }
907         return $this->componentswithtests;
908     }
910     /**
911      * Remove list of blacklisted features from the feature list.
912      *
913      * @param array $features list of original features.
914      * @param array|string $blacklist list of features which needs to be removed.
915      * @return array features - blacklisted features.
916      */
917     protected function remove_blacklisted_features_from_list($features, $blacklist) {
919         // If no blacklist passed then return.
920         if (empty($blacklist)) {
921             return $features;
922         }
924         // If there is no feature in suite then just return what was passed.
925         if (empty($features)) {
926             return $features;
927         }
929         if (!is_array($blacklist)) {
930             $blacklist = array($blacklist);
931         }
933         // Remove blacklisted features.
934         foreach ($blacklist as $blacklistpath) {
936             list($key, $featurepath) = $this->get_clean_feature_key_and_path($blacklistpath);
938             if (isset($features[$key])) {
939                 $features[$key] = null;
940                 unset($features[$key]);
941             } else {
942                 $featurestocheck = $this->get_components_features();
943                 if (!isset($featurestocheck[$key]) && !defined('PHPUNIT_TEST')) {
944                     behat_error(BEHAT_EXITCODE_REQUIREMENT, 'Blacklisted feature "' . $blacklistpath . '" not found.');
945                 }
946             }
947         }
949         return $features;
950     }
952     /**
953      * Return list of behat suites. Multiple suites are returned if theme
954      * overrides default step definitions/features.
955      *
956      * @param int $parallelruns number of parallel runs
957      * @param int $currentrun current run.
958      * @return array list of suites.
959      */
960     protected function get_behat_suites($parallelruns = 0, $currentrun = 0) {
961         $features = $this->get_components_features();
962         $contexts = $this->get_components_contexts();
964         // Get number of parallel runs and current run.
965         if (!empty($parallelruns) && !empty($currentrun)) {
966             $this->set_parallel_run($parallelruns, $currentrun);
967         } else {
968             $parallelruns = $this->get_number_of_parallel_run();
969             $currentrun = $this->get_current_run();;
970         }
972         $themefeatures = array();
973         $themecontexts = array();
975         $themes = $this->get_list_of_themes();
977         // Create list of theme suite features and contexts.
978         foreach ($themes as $theme) {
979             // Get theme features.
980             $themefeatures[$theme] = $this->get_behat_features_for_theme($theme);
982             $themecontexts[$theme] = $this->get_behat_contexts_for_theme($theme);
983         }
985         // Remove list of theme features for default suite, as default suite should not run theme specific features.
986         foreach ($themefeatures as $removethemefeatures) {
987             if (!empty($removethemefeatures['features'])) {
988                 $features = $this->remove_blacklisted_features_from_list($features, $removethemefeatures['features']);
989             }
990         }
992         // Remove list of theme features for default suite, as default suite should not run theme specific features.
993         foreach ($themecontexts as $themename => $themecontext) {
994             if (!empty($themecontext['contexts'])) {
995                 foreach ($themecontext['contexts'] as $contextkey => $contextpath) {
996                     // Remove theme specific contexts from default contexts.
997                     unset($contexts[$contextkey]);
999                     // Remove theme specific contexts from other themes.
1000                     foreach ($themes as $currenttheme) {
1001                         if (($currenttheme != $themename) && isset($themecontexts[$currenttheme]['suitecontexts'][$contextkey])) {
1002                             unset($themecontexts[$currenttheme]['suitecontexts'][$contextkey]);
1003                         }
1004                     }
1005                 }
1006             }
1007         }
1009         $featuresforrun = $this->get_features_for_the_run($features, $parallelruns, $currentrun);
1011         // Default suite.
1012         $suites = array(
1013             'default' => array(
1014                 'paths' => array_values($featuresforrun),
1015                 'contexts' => array_keys($contexts),
1016             )
1017         );
1019         // Set suite for each theme.
1020         foreach ($themes as $theme) {
1021             // Get list of features which will be included in theme.
1022             // If theme suite with all features is set, then we want all core features to be part of theme suite.
1023             if ($this->themesuitewithallfeatures) {
1024                 // If there is no theme specific feature. Then it's just core features.
1025                 if (empty($themefeatures[$theme]['features'])) {
1026                     $themesuitefeatures = $features;
1027                 } else {
1028                     $themesuitefeatures = array_merge($features, $themefeatures[$theme]['features']);
1029                 }
1030             } else {
1031                 $themesuitefeatures = $themefeatures[$theme]['features'];
1032             }
1034             // Remove blacklisted features.
1035             $themesuitefeatures = $this->remove_blacklisted_features_from_list($themesuitefeatures,
1036                 $themefeatures[$theme]['blacklistfeatures']);
1038             // Return sub-set of features if parallel run.
1039             $themesuitefeatures = $this->get_features_for_the_run($themesuitefeatures, $parallelruns, $currentrun);
1041             // Add suite no matter what. If there is no feature in suite then it will just exist successfully with no
1042             // scenarios. But if we don't set this then the user has to know which run doesn't have suite and which run do.
1043             $suites = array_merge($suites, array(
1044                 $theme => array(
1045                     'paths'    => array_values($themesuitefeatures),
1046                     'contexts' => array_keys($themecontexts[$theme]['suitecontexts']),
1047                 )
1048             ));
1049         }
1051         return $suites;
1052     }
1054     /**
1055      * Return list of themes which can be set in moodle.
1056      *
1057      * @return array list of themes with tests.
1058      */
1059     protected function get_list_of_themes() {
1060         $selectablethemes = array();
1062         // Get all themes installed on site.
1063         $themes = core_component::get_plugin_list('theme');
1064         ksort($themes);
1066         foreach ($themes as $themename => $themedir) {
1067             // Load the theme config.
1068             try {
1069                 $theme = theme_config::load($themename);
1070             } catch (Exception $e) {
1071                 // Bad theme, just skip it for now.
1072                 continue;
1073             }
1074             if ($themename !== $theme->name) {
1075                 // Obsoleted or broken theme, just skip for now.
1076                 continue;
1077             }
1078             if ($theme->hidefromselector) {
1079                 // The theme doesn't want to be shown in the theme selector and as theme
1080                 // designer mode is switched off we will respect that decision.
1081                 continue;
1082             }
1083             if ($themename == theme_config::DEFAULT_THEME) {
1084                 // Don't include default theme, as default suite will be running with this theme.
1085                 continue;
1086             }
1087             $selectablethemes[] = $themename;
1088         }
1090         return $selectablethemes;
1091     }
1093     /**
1094      * Return theme directory.
1095      *
1096      * @param string $themename
1097      * @return string theme directory
1098      */
1099     protected function get_theme_test_directory($themename) {
1100         global $CFG;
1102         $themetestdir = "/theme/" . $themename;
1104         return $CFG->dirroot . $themetestdir  . self::get_behat_tests_path();
1105     }
1107     /**
1108      * Returns all the directories having overridden tests.
1109      *
1110      * @param string $theme name of theme
1111      * @param string $testtype The kind of test we are looking for
1112      * @return array all directories having tests
1113      */
1114     protected function get_test_directories_overridden_for_theme($theme, $testtype) {
1115         global $CFG;
1117         $testtypes = array(
1118             'contexts' => '|behat_.*\.php$|',
1119             'features' => '|.*\.feature$|',
1120         );
1121         $themetestdirfullpath = $this->get_theme_test_directory($theme);
1123         // If test directory doesn't exist then return.
1124         if (!is_dir($themetestdirfullpath)) {
1125             return array();
1126         }
1128         $directoriestosearch = glob($themetestdirfullpath . DIRECTORY_SEPARATOR . '*' , GLOB_ONLYDIR);
1130         // Include theme directory to find tests.
1131         $dirs[realpath($themetestdirfullpath)] = trim(str_replace('/', '_', $themetestdirfullpath), '_');
1133         // Search for tests in valid directories.
1134         foreach ($directoriestosearch as $dir) {
1135             $dirite = new RecursiveDirectoryIterator($dir);
1136             $iteite = new RecursiveIteratorIterator($dirite);
1137             $regexp = $testtypes[$testtype];
1138             $regite = new RegexIterator($iteite, $regexp);
1139             foreach ($regite as $path => $element) {
1140                 $key = dirname($path);
1141                 $value = trim(str_replace(DIRECTORY_SEPARATOR, '_', str_replace($CFG->dirroot, '', $key)), '_');
1142                 $dirs[$key] = $value;
1143             }
1144         }
1145         ksort($dirs);
1147         return array_flip($dirs);
1148     }
1150     /**
1151      * Return blacklisted contexts or features for a theme, as defined in blacklist.json.
1152      *
1153      * @param string $theme themename
1154      * @param string $testtype test type (contexts|features)
1155      * @return array list of blacklisted contexts or features
1156      */
1157     protected function get_blacklisted_tests_for_theme($theme, $testtype) {
1159         $themetestpath = $this->get_theme_test_directory($theme);
1161         if (file_exists($themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json')) {
1162             // Blacklist file exist. Leave it for last to clear the feature and contexts.
1163             $blacklisttests = @json_decode(file_get_contents($themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json'), true);
1164             if (empty($blacklisttests)) {
1165                 behat_error(BEHAT_EXITCODE_REQUIREMENT, $themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json is empty');
1166             }
1168             // If features or contexts not defined then no problem.
1169             if (!isset($blacklisttests[$testtype])) {
1170                 $blacklisttests[$testtype] = array();
1171             }
1172             return $blacklisttests[$testtype];
1173         }
1175         return array();
1176     }
1178     /**
1179      * Return list of features and step definitions in theme.
1180      *
1181      * @param string $theme theme name
1182      * @param string $testtype test type, either features or contexts
1183      * @return array list of contexts $contexts or $features
1184      */
1185     protected function get_tests_for_theme($theme, $testtype) {
1187         $tests = array();
1188         $testtypes = array(
1189             'contexts' => '|behat_.*\.php$|',
1190             'features' => '|.*\.feature$|',
1191         );
1193         // Get all the directories having overridden tests.
1194         $directories = $this->get_test_directories_overridden_for_theme($theme, $testtype);
1196         // Get overridden test contexts.
1197         foreach ($directories as $dirpath) {
1198             // All behat_*.php inside overridden directory.
1199             $diriterator = new DirectoryIterator($dirpath);
1200             $regite = new RegexIterator($diriterator, $testtypes[$testtype]);
1202             // All behat_*.php inside behat_config_manager::get_behat_tests_path() are added as steps definitions files.
1203             foreach ($regite as $file) {
1204                 $key = $file->getBasename('.php');
1205                 $tests[$key] = $file->getPathname();
1206             }
1207         }
1209         return $tests;
1210     }
1212     /**
1213      * Return list of blacklisted behat features for theme and features defined by theme only.
1214      *
1215      * @param string $theme theme name.
1216      * @return array ($blacklistfeatures, $blacklisttags, $features)
1217      */
1218     protected function get_behat_features_for_theme($theme) {
1220         // Get list of features defined by theme.
1221         $themefeatures = $this->get_tests_for_theme($theme, 'features');
1222         $themeblacklistfeatures = $this->get_blacklisted_tests_for_theme($theme, 'features');
1224         // Clean feature key and path.
1225         $features = array();
1226         $blacklistfeatures = array();
1228         foreach ($themefeatures as $themefeature) {
1229             list($featurekey, $featurepath) = $this->get_clean_feature_key_and_path($themefeature);
1230             $features[$featurekey] = $featurepath;
1231         }
1232         foreach ($themeblacklistfeatures as $themeblacklistfeature) {
1233             list($blacklistfeaturekey, $blacklistfeaturepath) = $this->get_clean_feature_key_and_path($themeblacklistfeature);
1234             $blacklistfeatures[$blacklistfeaturekey] = $blacklistfeaturepath;
1235         }
1237         ksort($features);
1239         $retval = array(
1240             'blacklistfeatures' => $blacklistfeatures,
1241             'features' => $features
1242         );
1244         return $retval;
1245     }
1247     /**
1248      * Return list of contexts overridden by themes.
1249      *
1250      * @return array.
1251      */
1252     protected function get_overridden_theme_contexts() {
1253         if (empty($this->overriddenthemescontexts)) {
1254             $this->overriddenthemescontexts = array();
1255         }
1257         return $this->overriddenthemescontexts;
1258     }
1260     /**
1261      * Return list of behat contexts for theme and update $this->stepdefinitions list.
1262      *
1263      * @param string $theme theme name.
1264      * @return array list($themecontexts, $themesuitecontexts)
1265      */
1266     protected function get_behat_contexts_for_theme($theme) {
1268         // If we already have this list then just return. This will not change by run.
1269         if (!empty($this->themecontexts[$theme]) && !empty($this->themesuitecontexts)) {
1270             return array(
1271                 'contexts' => $this->themecontexts[$theme],
1272                 'suitecontexts' => $this->themesuitecontexts[$theme],
1273             );
1274         }
1276         if (empty($this->overriddenthemescontexts)) {
1277             $this->overriddenthemescontexts = array();
1278         }
1280         $contexts = $this->get_components_contexts();
1282         // Create list of contexts used by theme suite.
1283         $themecontexts = $this->get_tests_for_theme($theme, 'contexts');
1284         $blacklistedcontexts = $this->get_blacklisted_tests_for_theme($theme, 'contexts');
1286         // Theme suite will use all core contexts, except the one overridden by theme.
1287         $themesuitecontexts = $contexts;
1289         foreach ($themecontexts as $context => $path) {
1291             // If a context in theme starts with behat_theme_{themename}_behat_* then it's overriding core context.
1292             if (preg_match('/^behat_theme_'.$theme.'_(\w+)$/', $context, $match)) {
1294                 if (!empty($themesuitecontexts[$match[1]])) {
1295                     unset($themesuitecontexts[$match[1]]);
1296                 }
1298                 // Add this to the list of overridden paths, so it can be added to final contexts list for class resolver.
1299                 $this->overriddenthemescontexts[$context] = $path;
1300             }
1302             $selectortypes = ['partial', 'exact'];
1303             foreach ($selectortypes as $selectortype) {
1304                 // Don't include selector classes.
1305                 if ($context === self::get_behat_theme_selector_override_classname($theme, $selectortype)) {
1306                     unset($this->contexts[$context]);
1307                     unset($themesuitecontexts[$context]);
1308                     continue;
1309                 }
1310             }
1312             // Add theme specific contexts with suffix to steps definitions.
1313             $themesuitecontexts[$context] = $path;
1314         }
1316         // Remove blacklisted contexts.
1317         foreach ($blacklistedcontexts as $blacklistpath) {
1318             $blacklistcontext = basename($blacklistpath, '.php');
1320             unset($themesuitecontexts[$blacklistcontext]);
1321         }
1323         // We are only interested in the class name of context.
1324         $this->themesuitecontexts[$theme] = $themesuitecontexts;
1325         $this->themecontexts[$theme] = $themecontexts;
1327         $retval = array(
1328             'contexts' => $themecontexts,
1329             'suitecontexts' => $themesuitecontexts,
1330         );
1332         return $retval;
1333     }