MDL-68525 behat: chrome/switches caps not allowed in Chrome > 81
[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 overridden theme contexts.
60      */
61     private $overriddenthemescontexts;
63     /**
64      * @var array list of components with tests.
65      */
66     private $componentswithtests;
68     /**
69      * @var array|string keep track of theme to return suite with all core features included or not.
70      */
71     private $themesuitewithallfeatures = array();
73     /**
74      * @var string filter features which have tags.
75      */
76     private $tags = '';
78     /**
79      * @var int number of parallel runs.
80      */
81     private $parallelruns = 0;
83     /**
84      * @var int current run.
85      */
86     private $currentrun = 0;
88     /**
89      * @var string used to specify if behat should be initialised with all themes.
90      */
91     const ALL_THEMES_TO_RUN = 'ALL';
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 $themetoset
98      */
99     public function set_theme_suite_to_include_core_features($themetoset) {
100         // If no value passed to --add-core-features-to-theme or ALL is passed, then set core features for all themes.
101         if (!empty($themetoset)) {
102             if (is_number($themetoset) || is_bool($themetoset) || (self::ALL_THEMES_TO_RUN === strtoupper($themetoset))) {
103                 $this->themesuitewithallfeatures = self::ALL_THEMES_TO_RUN;
104             } else {
105                 $this->themesuitewithallfeatures = explode(',', $themetoset);
106                 $this->themesuitewithallfeatures = array_map('trim', $this->themesuitewithallfeatures);
107             }
108         }
109     }
111     /**
112      * Set the value for tags, so features which are returned will be using filtered by this.
113      *
114      * @param string $tags
115      */
116     public function set_tag_for_feature_filter($tags) {
117         $this->tags = $tags;
118     }
120     /**
121      * Set parallel run to be used for generating config.
122      *
123      * @param int $parallelruns number of parallel runs.
124      * @param int $currentrun current run
125      */
126     public function set_parallel_run($parallelruns, $currentrun) {
128         if ($parallelruns < $currentrun) {
129             behat_error(BEHAT_EXITCODE_REQUIREMENT,
130                 'Parallel runs('.$parallelruns.') should be more then current run('.$currentrun.')');
131         }
133         $this->parallelruns = $parallelruns;
134         $this->currentrun = $currentrun;
135     }
137     /**
138      * Return parallel runs
139      *
140      * @return int number of parallel runs.
141      */
142     public function get_number_of_parallel_run() {
143         // Get number of parallel runs if not passed.
144         if (empty($this->parallelruns) && ($this->parallelruns !== false)) {
145             $this->parallelruns = behat_config_manager::get_behat_run_config_value('parallel');
146         }
148         return $this->parallelruns;
149     }
151     /**
152      * Return current run
153      *
154      * @return int current run.
155      */
156     public function get_current_run() {
157         global $CFG;
159         // Get number of parallel runs if not passed.
160         if (empty($this->currentrun) && ($this->currentrun !== false) && !empty($CFG->behatrunprocess)) {
161             $this->currentrun = $CFG->behatrunprocess;
162         }
164         return $this->currentrun;
165     }
167     /**
168      * Return list of features.
169      *
170      * @param string $tags tags.
171      * @return array
172      */
173     public function get_components_features($tags = '') {
174         global $CFG;
176         // If we already have a list created then just return that, as it's up-to-date.
177         // If tags are passed then it's a new filter of features we need.
178         if (!empty($this->features) && empty($tags)) {
179             return $this->features;
180         }
182         // Gets all the components with features.
183         $features = array();
184         $featurespaths = array();
185         $components = $this->get_components_with_tests();
187         if ($components) {
188             foreach ($components as $componentname => $path) {
189                 $path = $this->clean_path($path) . self::get_behat_tests_path();
190                 if (empty($featurespaths[$path]) && file_exists($path)) {
191                     list($key, $featurepath) = $this->get_clean_feature_key_and_path($path);
192                     $featurespaths[$key] = $featurepath;
193                 }
194             }
195             foreach ($featurespaths as $path) {
196                 $additional = glob("$path/*.feature");
198                 $additionalfeatures = array();
199                 foreach ($additional as $featurepath) {
200                     list($key, $path) = $this->get_clean_feature_key_and_path($featurepath);
201                     $additionalfeatures[$key] = $path;
202                 }
204                 $features = array_merge($features, $additionalfeatures);
205             }
206         }
208         // Optionally include features from additional directories.
209         if (!empty($CFG->behat_additionalfeatures)) {
210             $additional = array_map("realpath", $CFG->behat_additionalfeatures);
211             $additionalfeatures = array();
212             foreach ($additional as $featurepath) {
213                 list($key, $path) = $this->get_clean_feature_key_and_path($featurepath);
214                 $additionalfeatures[$key] = $path;
215             }
216             $features = array_merge($features, $additionalfeatures);
217         }
219         // Sanitize feature key.
220         $cleanfeatures = array();
221         foreach ($features as $featurepath) {
222             list($key, $path) = $this->get_clean_feature_key_and_path($featurepath);
223             $cleanfeatures[$key] = $path;
224         }
226         // Sort feature list.
227         ksort($cleanfeatures);
229         $this->features = $cleanfeatures;
231         // If tags are passed then filter features which has sepecified tags.
232         if (!empty($tags)) {
233             $cleanfeatures = $this->filtered_features_with_tags($cleanfeatures, $tags);
234         }
236         return $cleanfeatures;
237     }
239     /**
240      * Return feature key for featurepath
241      *
242      * @param string $featurepath
243      * @return array key and featurepath.
244      */
245     public function get_clean_feature_key_and_path($featurepath) {
246         global $CFG;
248         // Fix directory path.
249         $featurepath = testing_cli_fix_directory_separator($featurepath);
250         $dirroot = testing_cli_fix_directory_separator($CFG->dirroot . DIRECTORY_SEPARATOR);
252         $key = basename($featurepath, '.feature');
254         // Get relative path.
255         $featuredirname = str_replace($dirroot , '', $featurepath);
256         // Get 5 levels of feature path to ensure we have a unique key.
257         for ($i = 0; $i < 5; $i++) {
258             if (($featuredirname = dirname($featuredirname)) && $featuredirname !== '.') {
259                 if ($basename = basename($featuredirname)) {
260                     $key .= '_' . $basename;
261                 }
262             }
263         }
265         return array($key, $featurepath);
266     }
268     /**
269      * Get component contexts.
270      *
271      * @param string $component component name.
272      * @return array
273      */
274     private function get_component_contexts($component) {
276         if (empty($component)) {
277             return $this->contexts;
278         }
280         $componentcontexts = array();
281         foreach ($this->contexts as $key => $path) {
282             if ($component == '' || $component === $key) {
283                 $componentcontexts[$key] = $path;
284             }
285         }
287         return $componentcontexts;
288     }
290     /**
291      * Gets the list of Moodle behat contexts
292      *
293      * Class name as a key and the filepath as value
294      *
295      * Externalized from update_config_file() to use
296      * it from the steps definitions web interface
297      *
298      * @param  string $component Restricts the obtained steps definitions to the specified component
299      * @return array
300      */
301     public function get_components_contexts($component = '') {
303         // If we already have a list created then just return that, as it's up-to-date.
304         if (!empty($this->contexts)) {
305             return $this->get_component_contexts($component);
306         }
308         $components = $this->get_components_with_tests();
310         $this->contexts = array();
311         foreach ($components as $componentname => $componentpath) {
312             if (false !== strpos($componentname, 'theme_')) {
313                 continue;
314             }
315             $componentpath = self::clean_path($componentpath);
317             if (!file_exists($componentpath . self::get_behat_tests_path())) {
318                 continue;
319             }
320             $diriterator = new DirectoryIterator($componentpath . self::get_behat_tests_path());
321             $regite = new RegexIterator($diriterator, '|^behat_.*\.php$|');
323             // All behat_*.php inside self::get_behat_tests_path() are added as steps definitions files.
324             foreach ($regite as $file) {
325                 $key = $file->getBasename('.php');
326                 $this->contexts[$key] = $file->getPathname();
327             }
328         }
330         // Sort contexts with there name.
331         ksort($this->contexts);
333         return $this->get_component_contexts($component);
334     }
336     /**
337      * Behat config file specifing the main context class,
338      * the required Behat extensions and Moodle test wwwroot.
339      *
340      * @param array $features The system feature files
341      * @param array $contexts The system steps definitions
342      * @param string $tags filter features with specified tags.
343      * @param int $parallelruns number of parallel runs.
344      * @param int $currentrun current run for which config file is needed.
345      * @return string
346      */
347     public function get_config_file_contents($features = '', $contexts = '', $tags = '', $parallelruns = 0, $currentrun = 0) {
348         global $CFG;
350         // Set current run and parallel run.
351         if (!empty($parallelruns) && !empty($currentrun)) {
352             $this->set_parallel_run($parallelruns, $currentrun);
353         }
355         // If tags defined then use them. This is for BC.
356         if (!empty($tags)) {
357             $this->set_tag_for_feature_filter($tags);
358         }
360         // If features not passed then get it. Empty array means we don't need to include features.
361         if (empty($features) && !is_array($features)) {
362             $features = $this->get_components_features();
363         } else {
364             $this->features = $features;
365         }
367         // If stepdefinitions not passed then get the list.
368         if (empty($contexts)) {
369             $this->get_components_contexts();
370         } else {
371             $this->contexts = $contexts;
372         }
374         // We require here when we are sure behat dependencies are available.
375         require_once($CFG->dirroot . '/vendor/autoload.php');
377         $config = $this->build_config();
379         $config = $this->merge_behat_config($config);
381         $config = $this->merge_behat_profiles($config);
383         // Return config array for phpunit, so it can be tested.
384         if (defined('PHPUNIT_TEST') && PHPUNIT_TEST) {
385             return $config;
386         }
388         return Symfony\Component\Yaml\Yaml::dump($config, 10, 2);
389     }
391     /**
392      * Search feature files for set of tags.
393      *
394      * @param array $features set of feature files.
395      * @param string $tags list of tags (currently support && only.)
396      * @return array filtered list of feature files with tags.
397      */
398     public function filtered_features_with_tags($features = '', $tags = '') {
400         // This is for BC. Features if not passed then we already have a list in this object.
401         if (empty($features)) {
402             $features = $this->features;
403         }
405         // If no tags defined then return full list.
406         if (empty($tags) && empty($this->tags)) {
407             return $features;
408         }
410         // If no tags passed by the caller, then it's already set.
411         if (empty($tags)) {
412             $tags = $this->tags;
413         }
415         $newfeaturelist = array();
416         // Split tags in and and or.
417         $tags = explode('&&', $tags);
418         $andtags = array();
419         $ortags = array();
420         foreach ($tags as $tag) {
421             // Explode all tags seperated by , and add it to ortags.
422             $ortags = array_merge($ortags, explode(',', $tag));
423             // And tags will be the first one before comma(,).
424             $andtags[] = preg_replace('/,.*/', '', $tag);
425         }
427         foreach ($features as $key => $featurefile) {
428             $contents = file_get_contents($featurefile);
429             $includefeature = true;
430             foreach ($andtags as $tag) {
431                 // If negitive tag, then ensure it don't exist.
432                 if (strpos($tag, '~') !== false) {
433                     $tag = substr($tag, 1);
434                     if ($contents && strpos($contents, $tag) !== false) {
435                         $includefeature = false;
436                         break;
437                     }
438                 } else if ($contents && strpos($contents, $tag) === false) {
439                     $includefeature = false;
440                     break;
441                 }
442             }
444             // If feature not included then check or tags.
445             if (!$includefeature && !empty($ortags)) {
446                 foreach ($ortags as $tag) {
447                     if ($contents && (strpos($tag, '~') === false) && (strpos($contents, $tag) !== false)) {
448                         $includefeature = true;
449                         break;
450                     }
451                 }
452             }
454             if ($includefeature) {
455                 $newfeaturelist[$key] = $featurefile;
456             }
457         }
458         return $newfeaturelist;
459     }
461     /**
462      * Build config for behat.yml.
463      *
464      * @param int $parallelruns how many parallel runs feature needs to be divided.
465      * @param int $currentrun current run for which features should be returned.
466      * @return array
467      */
468     protected function build_config($parallelruns = 0, $currentrun = 0) {
469         global $CFG;
471         if (!empty($parallelruns) && !empty($currentrun)) {
472             $this->set_parallel_run($parallelruns, $currentrun);
473         } else {
474             $currentrun = $this->get_current_run();
475             $parallelruns = $this->get_number_of_parallel_run();
476         }
478         $selenium2wdhost = array('wd_host' => 'http://localhost:4444/wd/hub');
479         // If parallel run, then set wd_host if specified.
480         if (!empty($currentrun) && !empty($parallelruns)) {
481             // Set proper selenium2 wd_host if defined.
482             if (!empty($CFG->behat_parallel_run[$currentrun - 1]['wd_host'])) {
483                 $selenium2wdhost = array('wd_host' => $CFG->behat_parallel_run[$currentrun - 1]['wd_host']);
484             }
485         }
487         // It is possible that it has no value as we don't require a full behat setup to list the step definitions.
488         if (empty($CFG->behat_wwwroot)) {
489             $CFG->behat_wwwroot = 'http://itwillnotbeused.com';
490         }
492         $suites = $this->get_behat_suites($parallelruns, $currentrun);
494         $selectortypes = ['named_partial', 'named_exact'];
495         $allpaths = [];
496         foreach (array_keys($suites) as $theme) {
497             // Remove selectors from step definitions.
498             foreach ($selectortypes as $selectortype) {
499                 // Don't include selector classes.
500                 $selectorclass = self::get_behat_theme_selector_override_classname($theme, $selectortype);
501                 if (isset($suites[$theme]['contexts'][$selectorclass])) {
502                     unset($suites[$theme]['contexts'][$selectorclass]);
503                 }
504             }
506             // Get a list of all step definition paths.
507             $allpaths = array_merge($allpaths, $suites[$theme]['contexts']);
509             // Convert the contexts array to a list of names only.
510             $suites[$theme]['contexts'] = array_keys($suites[$theme]['contexts']);
511         }
513         // Comments use black color, so failure path is not visible. Using color other then black/white is safer.
514         // https://github.com/Behat/Behat/pull/628.
515         $config = array(
516             'default' => array(
517                 'formatters' => array(
518                     'moodle_progress' => array(
519                         'output_styles' => array(
520                             'comment' => array('magenta'))
521                     )
522                 ),
523                 'suites' => $suites,
524                 'extensions' => array(
525                     'Behat\MinkExtension' => array(
526                         'base_url' => $CFG->behat_wwwroot,
527                         'goutte' => null,
528                         'selenium2' => $selenium2wdhost
529                     ),
530                     'Moodle\BehatExtension' => array(
531                         'moodledirroot' => $CFG->dirroot,
532                         'steps_definitions' => $allpaths,
533                     )
534                 )
535             )
536         );
538         return $config;
539     }
541     /**
542      * Divide features between the runs and return list.
543      *
544      * @param array $features list of features to be divided.
545      * @param int $parallelruns how many parallel runs feature needs to be divided.
546      * @param int $currentrun current run for which features should be returned.
547      * @return array
548      */
549     protected function get_features_for_the_run($features, $parallelruns, $currentrun) {
551         // If no features are passed then just return.
552         if (empty($features)) {
553             return $features;
554         }
556         $allocatedfeatures = $features;
558         // If parallel run, then only divide features.
559         if (!empty($currentrun) && !empty($parallelruns)) {
561             $featurestodivide['withtags'] = $features;
562             $allocatedfeatures = array();
564             // If tags are set then split features with tags first.
565             if (!empty($this->tags)) {
566                 $featurestodivide['withtags'] = $this->filtered_features_with_tags($features);
567                 $featurestodivide['withouttags'] = $this->remove_blacklisted_features_from_list($features,
568                     $featurestodivide['withtags']);
569             }
571             // Attempt to split into weighted buckets using timing information, if available.
572             foreach ($featurestodivide as $tagfeatures) {
573                 if ($alloc = $this->profile_guided_allocate($tagfeatures, max(1, $parallelruns), $currentrun)) {
574                     $allocatedfeatures = array_merge($allocatedfeatures, $alloc);
575                 } else {
576                     // Divide the list of feature files amongst the parallel runners.
577                     // Pull out the features for just this worker.
578                     if (count($tagfeatures)) {
579                         $splitfeatures = array_chunk($tagfeatures, ceil(count($tagfeatures) / max(1, $parallelruns)));
581                         // Check if there is any feature file for this process.
582                         if (!empty($splitfeatures[$currentrun - 1])) {
583                             $allocatedfeatures = array_merge($allocatedfeatures, $splitfeatures[$currentrun - 1]);
584                         }
585                     }
586                 }
587             }
588         }
590         return $allocatedfeatures;
591     }
593     /**
594      * Parse $CFG->behat_profile and return the array with required config structure for behat.yml.
595      *
596      * $CFG->behat_profiles = array(
597      *     'profile' = array(
598      *         'browser' => 'firefox',
599      *         'tags' => '@javascript',
600      *         'wd_host' => 'http://127.0.0.1:4444/wd/hub',
601      *         'capabilities' => array(
602      *             'platform' => 'Linux',
603      *             'version' => 44
604      *         )
605      *     )
606      * );
607      *
608      * @param string $profile profile name
609      * @param array $values values for profile.
610      * @return array
611      */
612     protected function get_behat_profile($profile, $values) {
613         // Values should be an array.
614         if (!is_array($values)) {
615             return array();
616         }
618         // Check suite values.
619         $behatprofilesuites = array();
621         // Automatically set tags information to skip app testing if necessary. We skip app testing
622         // if the browser is not Chrome. (Note: We also skip if it's not configured, but that is
623         // done on the theme/suite level.)
624         if (empty($values['browser']) || $values['browser'] !== 'chrome') {
625             if (!empty($values['tags'])) {
626                 $values['tags'] .= ' && ~@app';
627             } else {
628                 $values['tags'] = '~@app';
629             }
630         }
632         // Automatically add Chrome command line option to skip the prompt about allowing file
633         // storage - needed for mobile app testing (won't hurt for everything else either).
634         // We also need to disable web security, otherwise it can't make CSS requests to the server
635         // on localhost due to CORS restrictions.
636         if (!empty($values['browser']) && $values['browser'] === 'chrome') {
637             if (!isset($values['capabilities'])) {
638                 $values['capabilities'] = [];
639             }
640             if (!isset($values['capabilities']['extra_capabilities'])) {
641                 $values['capabilities']['extra_capabilities'] = [];
642             }
643             if (!isset($values['capabilities']['extra_capabilities']['chromeOptions'])) {
644                 $values['capabilities']['extra_capabilities']['chromeOptions'] = [];
645             }
646             if (!isset($values['capabilities']['extra_capabilities']['chromeOptions']['args'])) {
647                 $values['capabilities']['extra_capabilities']['chromeOptions']['args'] = [];
648             }
649             $values['capabilities']['extra_capabilities']['chromeOptions']['args'][] = '--unlimited-storage';
650             $values['capabilities']['extra_capabilities']['chromeOptions']['args'][] = '--disable-web-security';
652             // If the mobile app is enabled, check its version and add appropriate tags.
653             if ($mobiletags = $this->get_mobile_version_tags()) {
654                 if (!empty($values['tags'])) {
655                     $values['tags'] .= ' && ' . $mobiletags;
656                 } else {
657                     $values['tags'] = $mobiletags;
658                 }
659             }
660         }
662         // Fill tags information.
663         if (isset($values['tags'])) {
664             $behatprofilesuites = array(
665                 'suites' => array(
666                     'default' => array(
667                         'filters' => array(
668                             'tags' => $values['tags'],
669                         )
670                     )
671                 )
672             );
673         }
675         // Selenium2 config values.
676         $behatprofileextension = array();
677         $seleniumconfig = array();
678         if (isset($values['browser'])) {
679             $seleniumconfig['browser'] = $values['browser'];
680         }
681         if (isset($values['wd_host'])) {
682             $seleniumconfig['wd_host'] = $values['wd_host'];
683         }
684         if (isset($values['capabilities'])) {
685             $seleniumconfig['capabilities'] = $values['capabilities'];
686         }
687         if (!empty($seleniumconfig)) {
688             $behatprofileextension = array(
689                 'extensions' => array(
690                     'Behat\MinkExtension' => array(
691                         'selenium2' => $seleniumconfig,
692                     )
693                 )
694             );
695         }
697         return array($profile => array_merge($behatprofilesuites, $behatprofileextension));
698     }
700     /**
701      * Gets version tags to use for the mobile app.
702      *
703      * This is based on the current mobile app version (from its package.json) and all known
704      * mobile app versions (based on the list appversions.json in the lib/behat directory).
705      *
706      * @param bool $verbose If true, outputs information about installed app version
707      * @return string List of tags or '' if not supporting mobile
708      */
709     protected function get_mobile_version_tags($verbose = true) : string {
710         global $CFG;
712         if (!empty($CFG->behat_ionic_dirroot)) {
713             // Get app version from package.json.
714             $jsonpath = $CFG->behat_ionic_dirroot . '/package.json';
715             $json = @file_get_contents($jsonpath);
716             if (!$json) {
717                 throw new coding_exception('Unable to load app version from ' . $jsonpath);
718             }
719             $package = json_decode($json);
720             if ($package === null || empty($package->version)) {
721                 throw new coding_exception('Invalid app package data in ' . $jsonpath);
722             }
723             $installedversion = $package->version;
724         } else if (!empty($CFG->behat_ionic_wwwroot)) {
725             // Get app version from config.json inside wwwroot.
726             $jsonurl = $CFG->behat_ionic_wwwroot . '/config.json';
727             $json = @download_file_content($jsonurl);
728             if (!$json) {
729                 throw new coding_exception('Unable to load app version from ' . $jsonurl);
730             }
731             $config = json_decode($json);
732             if ($config === null || empty($config->versionname)) {
733                 throw new coding_exception('Invalid app config data in ' . $jsonurl);
734             }
735             $installedversion = str_replace('-dev', '', $config->versionname);
736         } else {
737             return '';
738         }
740         // Read all feature files to check which mobile tags are used. (Note: This could be cached
741         // but ideally, it is the sort of thing that really ought to be refreshed by doing a new
742         // Behat init. Also, at time of coding it only takes 0.3 seconds and only if app enabled.)
743         $usedtags = [];
744         foreach ($this->features as $filepath) {
745             $feature = file_get_contents($filepath);
746             // This may incorrectly detect versions used e.g. in a comment or something, but it
747             // doesn't do much harm if we have extra ones.
748             if (preg_match_all('~@app_(?:from|upto)(?:[0-9]+(?:\.[0-9]+)*)~', $feature, $matches)) {
749                 foreach ($matches[0] as $tag) {
750                     // Store as key in array so we don't get duplicates.
751                     $usedtags[$tag] = true;
752                 }
753             }
754         }
756         // Set up relevant tags for each version.
757         $tags = [];
758         foreach ($usedtags as $usedtag => $ignored) {
759             if (!preg_match('~^@app_(from|upto)([0-9]+(?:\.[0-9]+)*)$~', $usedtag, $matches)) {
760                 throw new coding_exception('Unexpected tag format');
761             }
762             $direction = $matches[1];
763             $version = $matches[2];
765             switch (version_compare($installedversion, $version)) {
766                 case -1:
767                     // Installed version OLDER than the one being considered, so do not
768                     // include any scenarios that only run from the considered version up.
769                     if ($direction === 'from') {
770                         $tags[] = '~@app_from' . $version;
771                     }
772                     break;
774                 case 0:
775                     // Installed version EQUAL to the one being considered - no tags need
776                     // excluding.
777                     break;
779                 case 1:
780                     // Installed version NEWER than the one being considered, so do not
781                     // include any scenarios that only run up to that version.
782                     if ($direction === 'upto') {
783                         $tags[] = '~@app_upto' . $version;
784                     }
785                     break;
786             }
787         }
789         if ($verbose) {
790             mtrace('Configured app tests for version ' . $installedversion);
791         }
793         return join(' && ', $tags);
794     }
796     /**
797      * Attempt to split feature list into fairish buckets using timing information, if available.
798      * Simply add each one to lightest buckets until all files allocated.
799      * PGA = Profile Guided Allocation. I made it up just now.
800      * CAUTION: workers must agree on allocation, do not be random anywhere!
801      *
802      * @param array $features Behat feature files array
803      * @param int $nbuckets Number of buckets to divide into
804      * @param int $instance Index number of this instance
805      * @return array|bool Feature files array, sorted into allocations
806      */
807     public function profile_guided_allocate($features, $nbuckets, $instance) {
809         // No profile guided allocation is required in phpunit.
810         if (defined('PHPUNIT_TEST') && PHPUNIT_TEST) {
811             return false;
812         }
814         $behattimingfile = defined('BEHAT_FEATURE_TIMING_FILE') &&
815         @filesize(BEHAT_FEATURE_TIMING_FILE) ? BEHAT_FEATURE_TIMING_FILE : false;
817         if (!$behattimingfile || !$behattimingdata = @json_decode(file_get_contents($behattimingfile), true)) {
818             // No data available, fall back to relying on steps data.
819             $stepfile = "";
820             if (defined('BEHAT_FEATURE_STEP_FILE') && BEHAT_FEATURE_STEP_FILE) {
821                 $stepfile = BEHAT_FEATURE_STEP_FILE;
822             }
823             // We should never get this. But in case we can't do this then fall back on simple splitting.
824             if (empty($stepfile) || !$behattimingdata = @json_decode(file_get_contents($stepfile), true)) {
825                 return false;
826             }
827         }
829         arsort($behattimingdata); // Ensure most expensive is first.
831         $realroot = realpath(__DIR__.'/../../../').'/';
832         $defaultweight = array_sum($behattimingdata) / count($behattimingdata);
833         $weights = array_fill(0, $nbuckets, 0);
834         $buckets = array_fill(0, $nbuckets, array());
835         $totalweight = 0;
837         // Re-key the features list to match timing data.
838         foreach ($features as $k => $file) {
839             $key = str_replace($realroot, '', $file);
840             $features[$key] = $file;
841             unset($features[$k]);
842             if (!isset($behattimingdata[$key])) {
843                 $behattimingdata[$key] = $defaultweight;
844             }
845         }
847         // Sort features by known weights; largest ones should be allocated first.
848         $behattimingorder = array();
849         foreach ($features as $key => $file) {
850             $behattimingorder[$key] = $behattimingdata[$key];
851         }
852         arsort($behattimingorder);
854         // Finally, add each feature one by one to the lightest bucket.
855         foreach ($behattimingorder as $key => $weight) {
856             $file = $features[$key];
857             $lightbucket = array_search(min($weights), $weights);
858             $weights[$lightbucket] += $weight;
859             $buckets[$lightbucket][] = $file;
860             $totalweight += $weight;
861         }
863         if ($totalweight && !defined('BEHAT_DISABLE_HISTOGRAM') && $instance == $nbuckets
864                 && (!defined('PHPUNIT_TEST') || !PHPUNIT_TEST)) {
865             echo "Bucket weightings:\n";
866             foreach ($weights as $k => $weight) {
867                 echo $k + 1 . ": " . str_repeat('*', 70 * $nbuckets * $weight / $totalweight) . PHP_EOL;
868             }
869         }
871         // Return the features for this worker.
872         return $buckets[$instance - 1];
873     }
875     /**
876      * Overrides default config with local config values
877      *
878      * array_merge does not merge completely the array's values
879      *
880      * @param mixed $config The node of the default config
881      * @param mixed $localconfig The node of the local config
882      * @return mixed The merge result
883      */
884     public function merge_config($config, $localconfig) {
886         if (!is_array($config) && !is_array($localconfig)) {
887             return $localconfig;
888         }
890         // Local overrides also deeper default values.
891         if (is_array($config) && !is_array($localconfig)) {
892             return $localconfig;
893         }
895         foreach ($localconfig as $key => $value) {
897             // If defaults are not as deep as local values let locals override.
898             if (!is_array($config)) {
899                 unset($config);
900             }
902             // Add the param if it doesn't exists or merge branches.
903             if (empty($config[$key])) {
904                 $config[$key] = $value;
905             } else {
906                 $config[$key] = $this->merge_config($config[$key], $localconfig[$key]);
907             }
908         }
910         return $config;
911     }
913     /**
914      * Merges $CFG->behat_config with the one passed.
915      *
916      * @param array $config existing config.
917      * @return array merged config with $CFG->behat_config
918      */
919     public function merge_behat_config($config) {
920         global $CFG;
922         // In case user defined overrides respect them over our default ones.
923         if (!empty($CFG->behat_config)) {
924             foreach ($CFG->behat_config as $profile => $values) {
925                 $config = $this->merge_config($config, $this->get_behat_config_for_profile($profile, $values));
926             }
927         }
929         return $config;
930     }
932     /**
933      * Parse $CFG->behat_config and return the array with required config structure for behat.yml
934      *
935      * @param string $profile profile name
936      * @param array $values values for profile
937      * @return array
938      */
939     public function get_behat_config_for_profile($profile, $values) {
940         // Only add profile which are compatible with Behat 3.x
941         // Just check if any of Bheat 2.5 config is set. Not checking for 3.x as it might have some other configs
942         // Like : rerun_cache etc.
943         if (!isset($values['filters']['tags']) && !isset($values['extensions']['Behat\MinkExtension\Extension'])) {
944             return array($profile => $values);
945         }
947         // Parse 2.5 format and get related values.
948         $oldconfigvalues = array();
949         if (isset($values['extensions']['Behat\MinkExtension\Extension'])) {
950             $extensionvalues = $values['extensions']['Behat\MinkExtension\Extension'];
951             if (isset($extensionvalues['selenium2']['browser'])) {
952                 $oldconfigvalues['browser'] = $extensionvalues['selenium2']['browser'];
953             }
954             if (isset($extensionvalues['selenium2']['wd_host'])) {
955                 $oldconfigvalues['wd_host'] = $extensionvalues['selenium2']['wd_host'];
956             }
957             if (isset($extensionvalues['capabilities'])) {
958                 $oldconfigvalues['capabilities'] = $extensionvalues['capabilities'];
959             }
960         }
962         if (isset($values['filters']['tags'])) {
963             $oldconfigvalues['tags'] = $values['filters']['tags'];
964         }
966         if (!empty($oldconfigvalues)) {
967             behat_config_manager::$autoprofileconversion = true;
968             return $this->get_behat_profile($profile, $oldconfigvalues);
969         }
971         // If nothing set above then return empty array.
972         return array();
973     }
975     /**
976      * Merges $CFG->behat_profiles with the one passed.
977      *
978      * @param array $config existing config.
979      * @return array merged config with $CFG->behat_profiles
980      */
981     public function merge_behat_profiles($config) {
982         global $CFG;
984         // Check for Moodle custom ones.
985         if (!empty($CFG->behat_profiles) && is_array($CFG->behat_profiles)) {
986             foreach ($CFG->behat_profiles as $profile => $values) {
987                 $config = $this->merge_config($config, $this->get_behat_profile($profile, $values));
988             }
989         }
991         return $config;
992     }
994     /**
995      * Cleans the path returned by get_components_with_tests() to standarize it
996      *
997      * @see tests_finder::get_all_directories_with_tests() it returns the path including /tests/
998      * @param string $path
999      * @return string The string without the last /tests part
1000      */
1001     public final function clean_path($path) {
1003         $path = rtrim($path, DIRECTORY_SEPARATOR);
1005         $parttoremove = DIRECTORY_SEPARATOR . 'tests';
1007         $substr = substr($path, strlen($path) - strlen($parttoremove));
1008         if ($substr == $parttoremove) {
1009             $path = substr($path, 0, strlen($path) - strlen($parttoremove));
1010         }
1012         return rtrim($path, DIRECTORY_SEPARATOR);
1013     }
1015     /**
1016      * The relative path where components stores their behat tests
1017      *
1018      * @return string
1019      */
1020     public static final function get_behat_tests_path() {
1021         return DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'behat';
1022     }
1024     /**
1025      * Return context name of behat_theme selector to use.
1026      *
1027      * @param string $themename name of the theme.
1028      * @param string $selectortype The type of selector (partial or exact at this stage)
1029      * @param bool $includeclass if class should be included.
1030      * @return string
1031      */
1032     public static final function get_behat_theme_selector_override_classname($themename, $selectortype, $includeclass = false) {
1033         global $CFG;
1035         if ($selectortype !== 'named_partial' && $selectortype !== 'named_exact') {
1036             throw new coding_exception("Unknown selector override type '{$selectortype}'");
1037         }
1039         $overridebehatclassname = "behat_theme_{$themename}_behat_{$selectortype}_selectors";
1041         if ($includeclass) {
1042             $themeoverrideselector = $CFG->dirroot . DIRECTORY_SEPARATOR . 'theme' . DIRECTORY_SEPARATOR . $themename .
1043                 self::get_behat_tests_path() . DIRECTORY_SEPARATOR . $overridebehatclassname . '.php';
1045             if (file_exists($themeoverrideselector)) {
1046                 require_once($themeoverrideselector);
1047             }
1048         }
1050         return $overridebehatclassname;
1051     }
1053     /**
1054      * List of components which contain behat context or features.
1055      *
1056      * @return array
1057      */
1058     protected function get_components_with_tests() {
1059         if (empty($this->componentswithtests)) {
1060             $this->componentswithtests = tests_finder::get_components_with_tests('behat');
1061         }
1063         return $this->componentswithtests;
1064     }
1066     /**
1067      * Remove list of blacklisted features from the feature list.
1068      *
1069      * @param array $features list of original features.
1070      * @param array|string $blacklist list of features which needs to be removed.
1071      * @return array features - blacklisted features.
1072      */
1073     protected function remove_blacklisted_features_from_list($features, $blacklist) {
1075         // If no blacklist passed then return.
1076         if (empty($blacklist)) {
1077             return $features;
1078         }
1080         // If there is no feature in suite then just return what was passed.
1081         if (empty($features)) {
1082             return $features;
1083         }
1085         if (!is_array($blacklist)) {
1086             $blacklist = array($blacklist);
1087         }
1089         // Remove blacklisted features.
1090         foreach ($blacklist as $blacklistpath) {
1092             list($key, $featurepath) = $this->get_clean_feature_key_and_path($blacklistpath);
1094             if (isset($features[$key])) {
1095                 $features[$key] = null;
1096                 unset($features[$key]);
1097             } else {
1098                 $featurestocheck = $this->get_components_features();
1099                 if (!isset($featurestocheck[$key]) && (!defined('PHPUNIT_TEST') || !PHPUNIT_TEST)) {
1100                     behat_error(BEHAT_EXITCODE_REQUIREMENT, 'Blacklisted feature "' . $blacklistpath . '" not found.');
1101                 }
1102             }
1103         }
1105         return $features;
1106     }
1108     /**
1109      * Return list of behat suites. Multiple suites are returned if theme
1110      * overrides default step definitions/features.
1111      *
1112      * @param int $parallelruns number of parallel runs
1113      * @param int $currentrun current run.
1114      * @return array list of suites.
1115      */
1116     protected function get_behat_suites($parallelruns = 0, $currentrun = 0) {
1117         $features = $this->get_components_features();
1119         // Get number of parallel runs and current run.
1120         if (!empty($parallelruns) && !empty($currentrun)) {
1121             $this->set_parallel_run($parallelruns, $currentrun);
1122         } else {
1123             $parallelruns = $this->get_number_of_parallel_run();
1124             $currentrun = $this->get_current_run();;
1125         }
1127         $themefeatures = array();
1128         $themecontexts = array();
1130         $themes = $this->get_list_of_themes();
1132         // Create list of theme suite features and contexts.
1133         foreach ($themes as $theme) {
1134             // Get theme features and contexts.
1135             $themefeatures[$theme] = $this->get_behat_features_for_theme($theme);
1136             $themecontexts[$theme] = $this->get_behat_contexts_for_theme($theme);
1137         }
1139         // Remove list of theme features for default suite, as default suite should not run theme specific features.
1140         foreach ($themefeatures as $themename => $removethemefeatures) {
1141             if (!empty($removethemefeatures['features'])) {
1142                 $features = $this->remove_blacklisted_features_from_list($features, $removethemefeatures['features']);
1143             }
1144         }
1146         // Set suite for each theme.
1147         $suites = array();
1148         foreach ($themes as $theme) {
1149             // Get list of features which will be included in theme.
1150             // If theme suite with all features or default theme, then we want all core features to be part of theme suite.
1151             if ((is_string($this->themesuitewithallfeatures) && ($this->themesuitewithallfeatures === self::ALL_THEMES_TO_RUN)) ||
1152                 in_array($theme, $this->themesuitewithallfeatures) || ($this->get_default_theme() === $theme)) {
1153                 // If there is no theme specific feature. Then it's just core features.
1154                 if (empty($themefeatures[$theme]['features'])) {
1155                     $themesuitefeatures = $features;
1156                 } else {
1157                     $themesuitefeatures = array_merge($features, $themefeatures[$theme]['features']);
1158                 }
1159             } else {
1160                 $themesuitefeatures = $themefeatures[$theme]['features'];
1161             }
1163             // Remove blacklisted features.
1164             $themesuitefeatures = $this->remove_blacklisted_features_from_list($themesuitefeatures,
1165                 $themefeatures[$theme]['blacklistfeatures']);
1167             // Return sub-set of features if parallel run.
1168             $themesuitefeatures = $this->get_features_for_the_run($themesuitefeatures, $parallelruns, $currentrun);
1170             // Default theme is part of default suite.
1171             if ($this->get_default_theme() === $theme) {
1172                 $suitename = 'default';
1173             } else {
1174                 $suitename = $theme;
1175             }
1177             // Add suite no matter what. If there is no feature in suite then it will just exist successfully with no scenarios.
1178             // But if we don't set this then the user has to know which run doesn't have suite and which run do.
1179             $suites = array_merge($suites, array(
1180                 $suitename => array(
1181                     'paths'    => array_values($themesuitefeatures),
1182                     'contexts' => $themecontexts[$theme],
1183                 )
1184             ));
1185         }
1187         return $suites;
1188     }
1190     /**
1191      * Return name of default theme.
1192      *
1193      * @return string
1194      */
1195     protected function get_default_theme() {
1196         return theme_config::DEFAULT_THEME;
1197     }
1199     /**
1200      * Return list of themes which can be set in moodle.
1201      *
1202      * @return array list of themes with tests.
1203      */
1204     protected function get_list_of_themes() {
1205         $selectablethemes = array();
1207         // Get all themes installed on site.
1208         $themes = core_component::get_plugin_list('theme');
1209         ksort($themes);
1211         foreach ($themes as $themename => $themedir) {
1212             // Load the theme config.
1213             try {
1214                 $theme = $this->get_theme_config($themename);
1215             } catch (Exception $e) {
1216                 // Bad theme, just skip it for now.
1217                 continue;
1218             }
1219             if ($themename !== $theme->name) {
1220                 // Obsoleted or broken theme, just skip for now.
1221                 continue;
1222             }
1223             if ($theme->hidefromselector) {
1224                 // The theme doesn't want to be shown in the theme selector and as theme
1225                 // designer mode is switched off we will respect that decision.
1226                 continue;
1227             }
1228             $selectablethemes[] = $themename;
1229         }
1231         return $selectablethemes;
1232     }
1234     /**
1235      * Return the theme config for a given theme name.
1236      * This is done so we can mock it in PHPUnit.
1237      *
1238      * @param string $themename name of theme
1239      * @return theme_config
1240      */
1241     public function get_theme_config($themename) {
1242         return theme_config::load($themename);
1243     }
1245     /**
1246      * Return theme directory.
1247      *
1248      * @param string $themename name of theme
1249      * @return string theme directory
1250      */
1251     protected function get_theme_test_directory($themename) {
1252         global $CFG;
1254         $themetestdir = "/theme/" . $themename;
1256         return $CFG->dirroot . $themetestdir  . self::get_behat_tests_path();
1257     }
1259     /**
1260      * Returns all the directories having overridden tests.
1261      *
1262      * @param string $theme name of theme
1263      * @param string $testtype The kind of test we are looking for
1264      * @return array all directories having tests
1265      */
1266     protected function get_test_directories_overridden_for_theme($theme, $testtype) {
1267         global $CFG;
1269         $testtypes = array(
1270             'contexts' => '|behat_.*\.php$|',
1271             'features' => '|.*\.feature$|',
1272         );
1273         $themetestdirfullpath = $this->get_theme_test_directory($theme);
1275         // If test directory doesn't exist then return.
1276         if (!is_dir($themetestdirfullpath)) {
1277             return array();
1278         }
1280         $directoriestosearch = glob($themetestdirfullpath . DIRECTORY_SEPARATOR . '*' , GLOB_ONLYDIR);
1282         // Include theme directory to find tests.
1283         $dirs[realpath($themetestdirfullpath)] = trim(str_replace('/', '_', $themetestdirfullpath), '_');
1285         // Search for tests in valid directories.
1286         foreach ($directoriestosearch as $dir) {
1287             $dirite = new RecursiveDirectoryIterator($dir);
1288             $iteite = new RecursiveIteratorIterator($dirite);
1289             $regexp = $testtypes[$testtype];
1290             $regite = new RegexIterator($iteite, $regexp);
1291             foreach ($regite as $path => $element) {
1292                 $key = dirname($path);
1293                 $value = trim(str_replace(DIRECTORY_SEPARATOR, '_', str_replace($CFG->dirroot, '', $key)), '_');
1294                 $dirs[$key] = $value;
1295             }
1296         }
1297         ksort($dirs);
1299         return array_flip($dirs);
1300     }
1302     /**
1303      * Return blacklisted contexts or features for a theme, as defined in blacklist.json.
1304      *
1305      * @param string $theme themename
1306      * @param string $testtype test type (contexts|features)
1307      * @return array list of blacklisted contexts or features
1308      */
1309     protected function get_blacklisted_tests_for_theme($theme, $testtype) {
1311         $themetestpath = $this->get_theme_test_directory($theme);
1313         if (file_exists($themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json')) {
1314             // Blacklist file exist. Leave it for last to clear the feature and contexts.
1315             $blacklisttests = @json_decode(file_get_contents($themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json'), true);
1316             if (empty($blacklisttests)) {
1317                 behat_error(BEHAT_EXITCODE_REQUIREMENT, $themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json is empty');
1318             }
1320             // If features or contexts not defined then no problem.
1321             if (!isset($blacklisttests[$testtype])) {
1322                 $blacklisttests[$testtype] = array();
1323             }
1324             return $blacklisttests[$testtype];
1325         }
1327         return array();
1328     }
1330     /**
1331      * Return list of features and step definitions in theme.
1332      *
1333      * @param string $theme theme name
1334      * @param string $testtype test type, either features or contexts
1335      * @return array list of contexts $contexts or $features
1336      */
1337     protected function get_tests_for_theme($theme, $testtype) {
1339         $tests = array();
1340         $testtypes = array(
1341             'contexts' => '|^behat_.*\.php$|',
1342             'features' => '|.*\.feature$|',
1343         );
1345         // Get all the directories having overridden tests.
1346         $directories = $this->get_test_directories_overridden_for_theme($theme, $testtype);
1348         // Get overridden test contexts.
1349         foreach ($directories as $dirpath) {
1350             // All behat_*.php inside overridden directory.
1351             $diriterator = new DirectoryIterator($dirpath);
1352             $regite = new RegexIterator($diriterator, $testtypes[$testtype]);
1354             // All behat_*.php inside behat_config_manager::get_behat_tests_path() are added as steps definitions files.
1355             foreach ($regite as $file) {
1356                 $key = $file->getBasename('.php');
1357                 $tests[$key] = $file->getPathname();
1358             }
1359         }
1361         return $tests;
1362     }
1364     /**
1365      * Return list of blacklisted behat features for theme and features defined by theme only.
1366      *
1367      * @param string $theme theme name.
1368      * @return array ($blacklistfeatures, $blacklisttags, $features)
1369      */
1370     protected function get_behat_features_for_theme($theme) {
1371         global $CFG;
1373         // Get list of features defined by theme.
1374         $themefeatures = $this->get_tests_for_theme($theme, 'features');
1375         $themeblacklistfeatures = $this->get_blacklisted_tests_for_theme($theme, 'features');
1376         $themeblacklisttags = $this->get_blacklisted_tests_for_theme($theme, 'tags');
1378         // Mobile app tests are not theme-specific, so run only for the default theme (and if
1379         // configured).
1380         if ((empty($CFG->behat_ionic_dirroot) && empty($CFG->behat_ionic_wwwroot)) ||
1381                 $theme !== $this->get_default_theme()) {
1382             $themeblacklisttags[] = '@app';
1383         }
1385         // Clean feature key and path.
1386         $features = array();
1387         $blacklistfeatures = array();
1389         foreach ($themefeatures as $themefeature) {
1390             list($featurekey, $featurepath) = $this->get_clean_feature_key_and_path($themefeature);
1391             $features[$featurekey] = $featurepath;
1392         }
1394         foreach ($themeblacklistfeatures as $themeblacklistfeature) {
1395             list($blacklistfeaturekey, $blacklistfeaturepath) = $this->get_clean_feature_key_and_path($themeblacklistfeature);
1396             $blacklistfeatures[$blacklistfeaturekey] = $blacklistfeaturepath;
1397         }
1399         // If blacklist tags then add those features to list.
1400         if (!empty($themeblacklisttags)) {
1401             // Remove @ if given, so we are sure we have only tag names.
1402             $themeblacklisttags = array_map(function($v) {
1403                 return ltrim($v, '@');
1404             }, $themeblacklisttags);
1406             $themeblacklisttags = '@' . implode(',@', $themeblacklisttags);
1407             $blacklistedfeatureswithtag = $this->filtered_features_with_tags($this->get_components_features(),
1408                 $themeblacklisttags);
1410             // Add features with blacklisted tags.
1411             if (!empty($blacklistedfeatureswithtag)) {
1412                 foreach ($blacklistedfeatureswithtag as $themeblacklistfeature) {
1413                     list($key, $path) = $this->get_clean_feature_key_and_path($themeblacklistfeature);
1414                     $blacklistfeatures[$key] = $path;
1415                 }
1416             }
1417         }
1419         ksort($features);
1421         $retval = array(
1422             'blacklistfeatures' => $blacklistfeatures,
1423             'features' => $features
1424         );
1426         return $retval;
1427     }
1429     /**
1430      * Return list of behat contexts for theme and update $this->stepdefinitions list.
1431      *
1432      * @param string $theme theme name.
1433      * @return  List of contexts
1434      */
1435     protected function get_behat_contexts_for_theme($theme) : array {
1436         // If we already have this list then just return. This will not change by run.
1437         if (!empty($this->themecontexts[$theme])) {
1438             return $this->themecontexts[$theme];
1439         }
1441         try {
1442             $themeconfig = $this->get_theme_config($theme);
1443         } catch (Exception $e) {
1444             // This theme has no theme config.
1445             return [];
1446         }
1448         // The theme will use all core contexts, except the one overridden by theme or its parent.
1449         $parentcontexts = [];
1450         if (isset($themeconfig->parents)) {
1451             foreach ($themeconfig->parents as $parent) {
1452                 if ($parentcontexts = $this->get_behat_contexts_for_theme($parent)) {
1453                     break;
1454                 }
1455             }
1456         }
1458         if (empty($parentcontexts)) {
1459             $parentcontexts = $this->get_components_contexts();
1460         }
1462         // Remove contexts which have been actively blacklisted.
1463         $blacklistedcontexts = $this->get_blacklisted_tests_for_theme($theme, 'contexts');
1464         foreach ($blacklistedcontexts as $blacklistpath) {
1465             $blacklistcontext = basename($blacklistpath, '.php');
1467             unset($parentcontexts[$blacklistcontext]);
1468         }
1470         // Apply overrides.
1471         $contexts = array_merge($parentcontexts, $this->get_tests_for_theme($theme, 'contexts'));
1473         // Remove classes which are overridden.
1474         foreach ($contexts as $contextclass => $path) {
1475             require_once($path);
1476             if (!class_exists($contextclass)) {
1477                 // This may be a Poorly named class.
1478                 continue;
1479             }
1481             $rc = new \ReflectionClass($contextclass);
1482             while ($rc = $rc->getParentClass()) {
1483                 if (isset($contexts[$rc->name])) {
1484                     unset($contexts[$rc->name]);
1485                 }
1486             }
1487         }
1489         // Sort the list of contexts.
1490         ksort($contexts);
1492         $this->themecontexts[$theme] = $contexts;
1494         return $contexts;
1495     }