Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

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

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