Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

Differences Between: [Versions 310 and 402] [Versions 311 and 402] [Versions 39 and 402] [Versions 400 and 402] [Versions 402 and 403]

   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                          'goutte' => 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          $json = @file_get_contents($jsonurl);
 766  
 767          if (!$json) {
 768              throw new coding_exception('Unable to load app version from ' . $jsonurl);
 769          }
 770  
 771          $env = json_decode($json);
 772  
 773          if (empty($env->build->version ?? null)) {
 774              throw new coding_exception('Invalid app config data in ' . $jsonurl);
 775          }
 776  
 777          $installedversion = $env->build->version;
 778  
 779          // Read all feature files to check which mobile tags are used. (Note: This could be cached
 780          // but ideally, it is the sort of thing that really ought to be refreshed by doing a new
 781          // Behat init. Also, at time of coding it only takes 0.3 seconds and only if app enabled.)
 782          $usedtags = [];
 783          foreach ($this->features as $filepath) {
 784              $feature = file_get_contents($filepath);
 785              // This may incorrectly detect versions used e.g. in a comment or something, but it
 786              // doesn't do much harm if we have extra ones.
 787              if (preg_match_all('~@app_(?:from|upto)(?:[0-9]+(?:\.[0-9]+)*)~', $feature, $matches)) {
 788                  foreach ($matches[0] as $tag) {
 789                      // Store as key in array so we don't get duplicates.
 790                      $usedtags[$tag] = true;
 791                  }
 792              }
 793          }
 794  
 795          // Set up relevant tags for each version.
 796          $tags = [];
 797          foreach ($usedtags as $usedtag => $ignored) {
 798              if (!preg_match('~^@app_(from|upto)([0-9]+(?:\.[0-9]+)*)$~', $usedtag, $matches)) {
 799                  throw new coding_exception('Unexpected tag format');
 800              }
 801              $direction = $matches[1];
 802              $version = $matches[2];
 803  
 804              switch (version_compare($installedversion, $version)) {
 805                  case -1:
 806                      // Installed version OLDER than the one being considered, so do not
 807                      // include any scenarios that only run from the considered version up.
 808                      if ($direction === 'from') {
 809                          $tags[] = '~@app_from' . $version;
 810                      }
 811                      break;
 812  
 813                  case 0:
 814                      // Installed version EQUAL to the one being considered - no tags need
 815                      // excluding.
 816                      break;
 817  
 818                  case 1:
 819                      // Installed version NEWER than the one being considered, so do not
 820                      // include any scenarios that only run up to that version.
 821                      if ($direction === 'upto') {
 822                          $tags[] = '~@app_upto' . $version;
 823                      }
 824                      break;
 825              }
 826          }
 827  
 828          if ($verbose) {
 829              mtrace('Configured app tests for version ' . $installedversion);
 830          }
 831  
 832          return join(' && ', $tags);
 833      }
 834  
 835      /**
 836       * Attempt to split feature list into fairish buckets using timing information, if available.
 837       * Simply add each one to lightest buckets until all files allocated.
 838       * PGA = Profile Guided Allocation. I made it up just now.
 839       * CAUTION: workers must agree on allocation, do not be random anywhere!
 840       *
 841       * @param array $features Behat feature files array
 842       * @param int $nbuckets Number of buckets to divide into
 843       * @param int $instance Index number of this instance
 844       * @return array|bool Feature files array, sorted into allocations
 845       */
 846      public function profile_guided_allocate($features, $nbuckets, $instance) {
 847  
 848          // No profile guided allocation is required in phpunit.
 849          if (defined('PHPUNIT_TEST') && PHPUNIT_TEST) {
 850              return false;
 851          }
 852  
 853          $behattimingfile = defined('BEHAT_FEATURE_TIMING_FILE') &&
 854          @filesize(BEHAT_FEATURE_TIMING_FILE) ? BEHAT_FEATURE_TIMING_FILE : false;
 855  
 856          if (!$behattimingfile || !$behattimingdata = @json_decode(file_get_contents($behattimingfile), true)) {
 857              // No data available, fall back to relying on steps data.
 858              $stepfile = "";
 859              if (defined('BEHAT_FEATURE_STEP_FILE') && BEHAT_FEATURE_STEP_FILE) {
 860                  $stepfile = BEHAT_FEATURE_STEP_FILE;
 861              }
 862              // We should never get this. But in case we can't do this then fall back on simple splitting.
 863              if (empty($stepfile) || !$behattimingdata = @json_decode(file_get_contents($stepfile), true)) {
 864                  return false;
 865              }
 866          }
 867  
 868          arsort($behattimingdata); // Ensure most expensive is first.
 869  
 870          $realroot = realpath(__DIR__.'/../../../').'/';
 871          $defaultweight = array_sum($behattimingdata) / count($behattimingdata);
 872          $weights = array_fill(0, $nbuckets, 0);
 873          $buckets = array_fill(0, $nbuckets, array());
 874          $totalweight = 0;
 875  
 876          // Re-key the features list to match timing data.
 877          foreach ($features as $k => $file) {
 878              $key = str_replace($realroot, '', $file);
 879              $features[$key] = $file;
 880              unset($features[$k]);
 881              if (!isset($behattimingdata[$key])) {
 882                  $behattimingdata[$key] = $defaultweight;
 883              }
 884          }
 885  
 886          // Sort features by known weights; largest ones should be allocated first.
 887          $behattimingorder = array();
 888          foreach ($features as $key => $file) {
 889              $behattimingorder[$key] = $behattimingdata[$key];
 890          }
 891          arsort($behattimingorder);
 892  
 893          // Finally, add each feature one by one to the lightest bucket.
 894          foreach ($behattimingorder as $key => $weight) {
 895              $file = $features[$key];
 896              $lightbucket = array_search(min($weights), $weights);
 897              $weights[$lightbucket] += $weight;
 898              $buckets[$lightbucket][] = $file;
 899              $totalweight += $weight;
 900          }
 901  
 902          if ($totalweight && !defined('BEHAT_DISABLE_HISTOGRAM') && $instance == $nbuckets
 903                  && (!defined('PHPUNIT_TEST') || !PHPUNIT_TEST)) {
 904              echo "Bucket weightings:\n";
 905              foreach ($weights as $k => $weight) {
 906                  echo $k + 1 . ": " . str_repeat('*', (int)(70 * $nbuckets * $weight / $totalweight)) . PHP_EOL;
 907              }
 908          }
 909  
 910          // Return the features for this worker.
 911          return $buckets[$instance - 1];
 912      }
 913  
 914      /**
 915       * Overrides default config with local config values
 916       *
 917       * array_merge does not merge completely the array's values
 918       *
 919       * @param mixed $config The node of the default config
 920       * @param mixed $localconfig The node of the local config
 921       * @return mixed The merge result
 922       */
 923      public function merge_config($config, $localconfig) {
 924  
 925          if (!is_array($config) && !is_array($localconfig)) {
 926              return $localconfig;
 927          }
 928  
 929          // Local overrides also deeper default values.
 930          if (is_array($config) && !is_array($localconfig)) {
 931              return $localconfig;
 932          }
 933  
 934          foreach ($localconfig as $key => $value) {
 935  
 936              // If defaults are not as deep as local values let locals override.
 937              if (!is_array($config)) {
 938                  unset($config);
 939              }
 940  
 941              // Add the param if it doesn't exists or merge branches.
 942              if (empty($config[$key])) {
 943                  $config[$key] = $value;
 944              } else {
 945                  $config[$key] = $this->merge_config($config[$key], $localconfig[$key]);
 946              }
 947          }
 948  
 949          return $config;
 950      }
 951  
 952      /**
 953       * Merges $CFG->behat_config with the one passed.
 954       *
 955       * @param array $config existing config.
 956       * @return array merged config with $CFG->behat_config
 957       */
 958      public function merge_behat_config($config) {
 959          global $CFG;
 960  
 961          // In case user defined overrides respect them over our default ones.
 962          if (!empty($CFG->behat_config)) {
 963              foreach ($CFG->behat_config as $profile => $values) {
 964                  $values = $this->fix_legacy_profile_data($profile, $values);
 965                  $config = $this->merge_config($config, $this->get_behat_config_for_profile($profile, $values));
 966              }
 967          }
 968  
 969          return $config;
 970      }
 971  
 972      /**
 973       * Parse $CFG->behat_config and return the array with required config structure for behat.yml
 974       *
 975       * @param string $profile profile name
 976       * @param array $values values for profile
 977       * @return array
 978       */
 979      public function get_behat_config_for_profile($profile, $values) {
 980          // Only add profile which are compatible with Behat 3.x
 981          // Just check if any of Bheat 2.5 config is set. Not checking for 3.x as it might have some other configs
 982          // Like : rerun_cache etc.
 983          if (!isset($values['filters']['tags']) && !isset($values['extensions']['Behat\MinkExtension\Extension'])) {
 984              return array($profile => $values);
 985          }
 986  
 987          // Parse 2.5 format and get related values.
 988          $oldconfigvalues = array();
 989          if (isset($values['extensions']['Behat\MinkExtension\Extension'])) {
 990              $extensionvalues = $values['extensions']['Behat\MinkExtension\Extension'];
 991              if (isset($extensionvalues['webdriver']['browser'])) {
 992                  $oldconfigvalues['browser'] = $extensionvalues['webdriver']['browser'];
 993              }
 994              if (isset($extensionvalues['webdriver']['wd_host'])) {
 995                  $oldconfigvalues['wd_host'] = $extensionvalues['webdriver']['wd_host'];
 996              }
 997              if (isset($extensionvalues['capabilities'])) {
 998                  $oldconfigvalues['capabilities'] = $extensionvalues['capabilities'];
 999              }
1000          }
1001  
1002          if (isset($values['filters']['tags'])) {
1003              $oldconfigvalues['tags'] = $values['filters']['tags'];
1004          }
1005  
1006          if (!empty($oldconfigvalues)) {
1007              behat_config_manager::$autoprofileconversion = true;
1008              return $this->get_behat_profile($profile, $oldconfigvalues);
1009          }
1010  
1011          // If nothing set above then return empty array.
1012          return array();
1013      }
1014  
1015      /**
1016       * Merges $CFG->behat_profiles with the one passed.
1017       *
1018       * @param array $config existing config.
1019       * @return array merged config with $CFG->behat_profiles
1020       */
1021      public function merge_behat_profiles($config) {
1022          global $CFG;
1023  
1024          // Check for Moodle custom ones.
1025          if (!empty($CFG->behat_profiles) && is_array($CFG->behat_profiles)) {
1026              foreach ($CFG->behat_profiles as $profile => $values) {
1027                  $config = $this->merge_config($config, $this->get_behat_profile($profile, $values));
1028              }
1029          }
1030  
1031          return $config;
1032      }
1033  
1034      /**
1035       * Check for and attempt to fix legacy profile data.
1036       *
1037       * The Mink Driver used for W3C no longer uses the `selenium2` naming but otherwise is backwards compatibly.
1038       *
1039       * Emit a warning that users should update their configuration.
1040       *
1041       * @param   string $profilename The name of this profile
1042       * @param   array $data The profile data for this profile
1043       * @return  array Th eamended profile data
1044       */
1045      protected function fix_legacy_profile_data(string $profilename, array $data): array {
1046          // Check for legacy instaclick profiles.
1047          if (!array_key_exists('Behat\MinkExtension', $data['extensions'])) {
1048              return $data;
1049          }
1050          if (array_key_exists('selenium2', $data['extensions']['Behat\MinkExtension'])) {
1051              echo("\n\n");
1052              echo("=> Warning: Legacy selenium2 profileuration was found for {$profilename} profile.\n");
1053              echo("=> This has been renamed from 'selenium2' to 'webdriver'.\n");
1054              echo("=> You should update your Behat configuration.\n");
1055              echo("\n");
1056              $data['extensions']['Behat\MinkExtension']['webdriver'] = $data['extensions']['Behat\MinkExtension']['selenium2'];
1057              unset($data['extensions']['Behat\MinkExtension']['selenium2']);
1058          }
1059  
1060          return $data;
1061      }
1062  
1063      /**
1064       * Cleans the path returned by get_components_with_tests() to standarize it
1065       *
1066       * @see tests_finder::get_all_directories_with_tests() it returns the path including /tests/
1067       * @param string $path
1068       * @return string The string without the last /tests part
1069       */
1070      public final function clean_path($path) {
1071  
1072          $path = rtrim($path, DIRECTORY_SEPARATOR);
1073  
1074          $parttoremove = DIRECTORY_SEPARATOR . 'tests';
1075  
1076          $substr = substr($path, strlen($path) - strlen($parttoremove));
1077          if ($substr == $parttoremove) {
1078              $path = substr($path, 0, strlen($path) - strlen($parttoremove));
1079          }
1080  
1081          return rtrim($path, DIRECTORY_SEPARATOR);
1082      }
1083  
1084      /**
1085       * The relative path where components stores their behat tests
1086       *
1087       * @return string
1088       */
1089      public static final function get_behat_tests_path() {
1090          return DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'behat';
1091      }
1092  
1093      /**
1094       * Return context name of behat_theme selector to use.
1095       *
1096       * @param string $themename name of the theme.
1097       * @param string $selectortype The type of selector (partial or exact at this stage)
1098       * @param bool $includeclass if class should be included.
1099       * @return string
1100       */
1101      public static final function get_behat_theme_selector_override_classname($themename, $selectortype, $includeclass = false) {
1102          global $CFG;
1103  
1104          if ($selectortype !== 'named_partial' && $selectortype !== 'named_exact') {
1105              throw new coding_exception("Unknown selector override type '{$selectortype}'");
1106          }
1107  
1108          $overridebehatclassname = "behat_theme_{$themename}_behat_{$selectortype}_selectors";
1109  
1110          if ($includeclass) {
1111              $themeoverrideselector = $CFG->dirroot . DIRECTORY_SEPARATOR . 'theme' . DIRECTORY_SEPARATOR . $themename .
1112                  self::get_behat_tests_path() . DIRECTORY_SEPARATOR . $overridebehatclassname . '.php';
1113  
1114              if (file_exists($themeoverrideselector)) {
1115                  require_once($themeoverrideselector);
1116              }
1117          }
1118  
1119          return $overridebehatclassname;
1120      }
1121  
1122      /**
1123       * List of components which contain behat context or features.
1124       *
1125       * @return array
1126       */
1127      protected function get_components_with_tests() {
1128          if (empty($this->componentswithtests)) {
1129              $this->componentswithtests = tests_finder::get_components_with_tests('behat');
1130          }
1131  
1132          return $this->componentswithtests;
1133      }
1134  
1135      /**
1136       * Remove list of blacklisted features from the feature list.
1137       *
1138       * @param array $features list of original features.
1139       * @param array|string $blacklist list of features which needs to be removed.
1140       * @return array features - blacklisted features.
1141       */
1142      protected function remove_blacklisted_features_from_list($features, $blacklist) {
1143  
1144          // If no blacklist passed then return.
1145          if (empty($blacklist)) {
1146              return $features;
1147          }
1148  
1149          // If there is no feature in suite then just return what was passed.
1150          if (empty($features)) {
1151              return $features;
1152          }
1153  
1154          if (!is_array($blacklist)) {
1155              $blacklist = array($blacklist);
1156          }
1157  
1158          // Remove blacklisted features.
1159          foreach ($blacklist as $blacklistpath) {
1160  
1161              list($key, $featurepath) = $this->get_clean_feature_key_and_path($blacklistpath);
1162  
1163              if (isset($features[$key])) {
1164                  $features[$key] = null;
1165                  unset($features[$key]);
1166              } else {
1167                  $featurestocheck = $this->get_components_features();
1168                  if (!isset($featurestocheck[$key]) && (!defined('PHPUNIT_TEST') || !PHPUNIT_TEST)) {
1169                      behat_error(BEHAT_EXITCODE_REQUIREMENT, 'Blacklisted feature "' . $blacklistpath . '" not found.');
1170                  }
1171              }
1172          }
1173  
1174          return $features;
1175      }
1176  
1177      /**
1178       * Return list of behat suites. Multiple suites are returned if theme
1179       * overrides default step definitions/features.
1180       *
1181       * @param int $parallelruns number of parallel runs
1182       * @param int $currentrun current run.
1183       * @return array list of suites.
1184       */
1185      protected function get_behat_suites($parallelruns = 0, $currentrun = 0) {
1186          $features = $this->get_components_features();
1187  
1188          // Get number of parallel runs and current run.
1189          if (!empty($parallelruns) && !empty($currentrun)) {
1190              $this->set_parallel_run($parallelruns, $currentrun);
1191          } else {
1192              $parallelruns = $this->get_number_of_parallel_run();
1193              $currentrun = $this->get_current_run();;
1194          }
1195  
1196          $themefeatures = array();
1197          $themecontexts = array();
1198  
1199          $themes = $this->get_list_of_themes();
1200  
1201          // Create list of theme suite features and contexts.
1202          foreach ($themes as $theme) {
1203              // Get theme features and contexts.
1204              $themefeatures[$theme] = $this->get_behat_features_for_theme($theme);
1205              $themecontexts[$theme] = $this->get_behat_contexts_for_theme($theme);
1206          }
1207  
1208          // Remove list of theme features for default suite, as default suite should not run theme specific features.
1209          foreach ($themefeatures as $themename => $removethemefeatures) {
1210              if (!empty($removethemefeatures['features'])) {
1211                  $features = $this->remove_blacklisted_features_from_list($features, $removethemefeatures['features']);
1212              }
1213          }
1214  
1215          // Set suite for each theme.
1216          $suites = array();
1217          foreach ($themes as $theme) {
1218              // Get list of features which will be included in theme.
1219              // If theme suite with all features or default theme, then we want all core features to be part of theme suite.
1220              if ((is_string($this->themesuitewithallfeatures) && ($this->themesuitewithallfeatures === self::ALL_THEMES_TO_RUN)) ||
1221                  in_array($theme, $this->themesuitewithallfeatures) || ($this->get_default_theme() === $theme)) {
1222                  // If there is no theme specific feature. Then it's just core features.
1223                  if (empty($themefeatures[$theme]['features'])) {
1224                      $themesuitefeatures = $features;
1225                  } else {
1226                      $themesuitefeatures = array_merge($features, $themefeatures[$theme]['features']);
1227                  }
1228              } else {
1229                  $themesuitefeatures = $themefeatures[$theme]['features'];
1230              }
1231  
1232              // Remove blacklisted features.
1233              $themesuitefeatures = $this->remove_blacklisted_features_from_list($themesuitefeatures,
1234                  $themefeatures[$theme]['blacklistfeatures']);
1235  
1236              // Return sub-set of features if parallel run.
1237              $themesuitefeatures = $this->get_features_for_the_run($themesuitefeatures, $parallelruns, $currentrun);
1238  
1239              // Default theme is part of default suite.
1240              if ($this->get_default_theme() === $theme) {
1241                  $suitename = 'default';
1242              } else {
1243                  $suitename = $theme;
1244              }
1245  
1246              // Add suite no matter what. If there is no feature in suite then it will just exist successfully with no scenarios.
1247              // But if we don't set this then the user has to know which run doesn't have suite and which run do.
1248              $suites = array_merge($suites, array(
1249                  $suitename => array(
1250                      'paths'    => array_values($themesuitefeatures),
1251                      'contexts' => $themecontexts[$theme],
1252                  )
1253              ));
1254          }
1255  
1256          return $suites;
1257      }
1258  
1259      /**
1260       * Return name of default theme.
1261       *
1262       * @return string
1263       */
1264      protected function get_default_theme() {
1265          return theme_config::DEFAULT_THEME;
1266      }
1267  
1268      /**
1269       * Return list of themes which can be set in moodle.
1270       *
1271       * @return array list of themes with tests.
1272       */
1273      protected function get_list_of_themes() {
1274          $selectablethemes = array();
1275  
1276          // Get all themes installed on site.
1277          $themes = core_component::get_plugin_list('theme');
1278          ksort($themes);
1279  
1280          foreach ($themes as $themename => $themedir) {
1281              // Load the theme config.
1282              try {
1283                  $theme = $this->get_theme_config($themename);
1284              } catch (Exception $e) {
1285                  // Bad theme, just skip it for now.
1286                  continue;
1287              }
1288              if ($themename !== $theme->name) {
1289                  // Obsoleted or broken theme, just skip for now.
1290                  continue;
1291              }
1292              if ($theme->hidefromselector) {
1293                  // The theme doesn't want to be shown in the theme selector and as theme
1294                  // designer mode is switched off we will respect that decision.
1295                  continue;
1296              }
1297              $selectablethemes[] = $themename;
1298          }
1299  
1300          return $selectablethemes;
1301      }
1302  
1303      /**
1304       * Return the theme config for a given theme name.
1305       * This is done so we can mock it in PHPUnit.
1306       *
1307       * @param string $themename name of theme
1308       * @return theme_config
1309       */
1310      public function get_theme_config($themename) {
1311          return theme_config::load($themename);
1312      }
1313  
1314      /**
1315       * Return theme directory.
1316       *
1317       * @param string $themename name of theme
1318       * @return string theme directory
1319       */
1320      protected function get_theme_test_directory($themename) {
1321          global $CFG;
1322  
1323          $themetestdir = "/theme/" . $themename;
1324  
1325          return $CFG->dirroot . $themetestdir  . self::get_behat_tests_path();
1326      }
1327  
1328      /**
1329       * Returns all the directories having overridden tests.
1330       *
1331       * @param string $theme name of theme
1332       * @param string $testtype The kind of test we are looking for
1333       * @return array all directories having tests
1334       */
1335      protected function get_test_directories_overridden_for_theme($theme, $testtype) {
1336          global $CFG;
1337  
1338          $testtypes = array(
1339              'contexts' => '|behat_.*\.php$|',
1340              'features' => '|.*\.feature$|',
1341          );
1342          $themetestdirfullpath = $this->get_theme_test_directory($theme);
1343  
1344          // If test directory doesn't exist then return.
1345          if (!is_dir($themetestdirfullpath)) {
1346              return array();
1347          }
1348  
1349          $directoriestosearch = glob($themetestdirfullpath . DIRECTORY_SEPARATOR . '*' , GLOB_ONLYDIR);
1350  
1351          // Include theme directory to find tests.
1352          $dirs[realpath($themetestdirfullpath)] = trim(str_replace('/', '_', $themetestdirfullpath), '_');
1353  
1354          // Search for tests in valid directories.
1355          foreach ($directoriestosearch as $dir) {
1356              $dirite = new RecursiveDirectoryIterator($dir);
1357              $iteite = new RecursiveIteratorIterator($dirite);
1358              $regexp = $testtypes[$testtype];
1359              $regite = new RegexIterator($iteite, $regexp);
1360              foreach ($regite as $path => $element) {
1361                  $key = dirname($path);
1362                  $value = trim(str_replace(DIRECTORY_SEPARATOR, '_', str_replace($CFG->dirroot, '', $key)), '_');
1363                  $dirs[$key] = $value;
1364              }
1365          }
1366          ksort($dirs);
1367  
1368          return array_flip($dirs);
1369      }
1370  
1371      /**
1372       * Return blacklisted contexts or features for a theme, as defined in blacklist.json.
1373       *
1374       * @param string $theme themename
1375       * @param string $testtype test type (contexts|features)
1376       * @return array list of blacklisted contexts or features
1377       */
1378      protected function get_blacklisted_tests_for_theme($theme, $testtype) {
1379  
1380          $themetestpath = $this->get_theme_test_directory($theme);
1381  
1382          if (file_exists($themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json')) {
1383              // Blacklist file exist. Leave it for last to clear the feature and contexts.
1384              $blacklisttests = @json_decode(file_get_contents($themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json'), true);
1385              if (empty($blacklisttests)) {
1386                  behat_error(BEHAT_EXITCODE_REQUIREMENT, $themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json is empty');
1387              }
1388  
1389              // If features or contexts not defined then no problem.
1390              if (!isset($blacklisttests[$testtype])) {
1391                  $blacklisttests[$testtype] = array();
1392              }
1393              return $blacklisttests[$testtype];
1394          }
1395  
1396          return array();
1397      }
1398  
1399      /**
1400       * Return list of features and step definitions in theme.
1401       *
1402       * @param string $theme theme name
1403       * @param string $testtype test type, either features or contexts
1404       * @return array list of contexts $contexts or $features
1405       */
1406      protected function get_tests_for_theme($theme, $testtype) {
1407  
1408          $tests = array();
1409          $testtypes = array(
1410              'contexts' => '|^behat_.*\.php$|',
1411              'features' => '|.*\.feature$|',
1412          );
1413  
1414          // Get all the directories having overridden tests.
1415          $directories = $this->get_test_directories_overridden_for_theme($theme, $testtype);
1416  
1417          // Get overridden test contexts.
1418          foreach ($directories as $dirpath) {
1419              // All behat_*.php inside overridden directory.
1420              $diriterator = new DirectoryIterator($dirpath);
1421              $regite = new RegexIterator($diriterator, $testtypes[$testtype]);
1422  
1423              // All behat_*.php inside behat_config_manager::get_behat_tests_path() are added as steps definitions files.
1424              foreach ($regite as $file) {
1425                  $key = $file->getBasename('.php');
1426                  $tests[$key] = $file->getPathname();
1427              }
1428          }
1429  
1430          return $tests;
1431      }
1432  
1433      /**
1434       * Return list of blacklisted behat features for theme and features defined by theme only.
1435       *
1436       * @param string $theme theme name.
1437       * @return array ($blacklistfeatures, $blacklisttags, $features)
1438       */
1439      protected function get_behat_features_for_theme($theme) {
1440          global $CFG;
1441  
1442          // Get list of features defined by theme.
1443          $themefeatures = $this->get_tests_for_theme($theme, 'features');
1444          $themeblacklistfeatures = $this->get_blacklisted_tests_for_theme($theme, 'features');
1445          $themeblacklisttags = $this->get_blacklisted_tests_for_theme($theme, 'tags');
1446  
1447          // Mobile app tests are not theme-specific, so run only for the default theme (and if
1448          // configured).
1449          if (empty($CFG->behat_ionic_wwwroot) || $theme !== $this->get_default_theme()) {
1450              $themeblacklisttags[] = '@app';
1451          }
1452  
1453          // Clean feature key and path.
1454          $features = array();
1455          $blacklistfeatures = array();
1456  
1457          foreach ($themefeatures as $themefeature) {
1458              list($featurekey, $featurepath) = $this->get_clean_feature_key_and_path($themefeature);
1459              $features[$featurekey] = $featurepath;
1460          }
1461  
1462          foreach ($themeblacklistfeatures as $themeblacklistfeature) {
1463              list($blacklistfeaturekey, $blacklistfeaturepath) = $this->get_clean_feature_key_and_path($themeblacklistfeature);
1464              $blacklistfeatures[$blacklistfeaturekey] = $blacklistfeaturepath;
1465          }
1466  
1467          // If blacklist tags then add those features to list.
1468          if (!empty($themeblacklisttags)) {
1469              // Remove @ if given, so we are sure we have only tag names.
1470              $themeblacklisttags = array_map(function($v) {
1471                  return ltrim($v, '@');
1472              }, $themeblacklisttags);
1473  
1474              $themeblacklisttags = '@' . implode(',@', $themeblacklisttags);
1475              $blacklistedfeatureswithtag = $this->filtered_features_with_tags($this->get_components_features(),
1476                  $themeblacklisttags);
1477  
1478              // Add features with blacklisted tags.
1479              if (!empty($blacklistedfeatureswithtag)) {
1480                  foreach ($blacklistedfeatureswithtag as $themeblacklistfeature) {
1481                      list($key, $path) = $this->get_clean_feature_key_and_path($themeblacklistfeature);
1482                      $blacklistfeatures[$key] = $path;
1483                  }
1484              }
1485          }
1486  
1487          ksort($features);
1488  
1489          $retval = array(
1490              'blacklistfeatures' => $blacklistfeatures,
1491              'features' => $features
1492          );
1493  
1494          return $retval;
1495      }
1496  
1497      /**
1498       * Return list of behat contexts for theme and update $this->stepdefinitions list.
1499       *
1500       * @param string $theme theme name.
1501       * @return  List of contexts
1502       */
1503      protected function get_behat_contexts_for_theme($theme) : array {
1504          // If we already have this list then just return. This will not change by run.
1505          if (!empty($this->themecontexts[$theme])) {
1506              return $this->themecontexts[$theme];
1507          }
1508  
1509          try {
1510              $themeconfig = $this->get_theme_config($theme);
1511          } catch (Exception $e) {
1512              // This theme has no theme config.
1513              return [];
1514          }
1515  
1516          // The theme will use all core contexts, except the one overridden by theme or its parent.
1517          $parentcontexts = [];
1518          if (isset($themeconfig->parents)) {
1519              foreach ($themeconfig->parents as $parent) {
1520                  if ($parentcontexts = $this->get_behat_contexts_for_theme($parent)) {
1521                      break;
1522                  }
1523              }
1524          }
1525  
1526          if (empty($parentcontexts)) {
1527              $parentcontexts = $this->get_components_contexts();
1528          }
1529  
1530          // Remove contexts which have been actively blacklisted.
1531          $blacklistedcontexts = $this->get_blacklisted_tests_for_theme($theme, 'contexts');
1532          foreach ($blacklistedcontexts as $blacklistpath) {
1533              $blacklistcontext = basename($blacklistpath, '.php');
1534  
1535              unset($parentcontexts[$blacklistcontext]);
1536          }
1537  
1538          // Apply overrides.
1539          $contexts = array_merge($parentcontexts, $this->get_tests_for_theme($theme, 'contexts'));
1540  
1541          // Remove classes which are overridden.
1542          foreach ($contexts as $contextclass => $path) {
1543              require_once($path);
1544              if (!class_exists($contextclass)) {
1545                  // This may be a Poorly named class.
1546                  continue;
1547              }
1548  
1549              $rc = new \ReflectionClass($contextclass);
1550              while ($rc = $rc->getParentClass()) {
1551                  if (isset($contexts[$rc->name])) {
1552                      unset($contexts[$rc->name]);
1553                  }
1554              }
1555          }
1556  
1557          // Sort the list of contexts.
1558          $contexts = $this->sort_component_contexts($contexts);
1559  
1560          // Cache it for subsequent fetches.
1561          $this->themecontexts[$theme] = $contexts;
1562  
1563          return $contexts;
1564      }
1565  }