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