Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

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