Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

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

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