AutorÃa | Ultima modificación | Ver Log |
<?php// This file is part of Moodle - http://moodle.org///// Moodle is free software: you can redistribute it and/or modify// it under the terms of the GNU General Public License as published by// the Free Software Foundation, either version 3 of the License, or// (at your option) any later version.//// Moodle is distributed in the hope that it will be useful,// but WITHOUT ANY WARRANTY; without even the implied warranty of// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the// GNU General Public License for more details.//// You should have received a copy of the GNU General Public License// along with Moodle. If not, see <http://www.gnu.org/licenses/>./*** Utils to set Behat config** @package core* @copyright 2016 Rajesh Taneja* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/defined('MOODLE_INTERNAL') || die();require_once(__DIR__ . '/../lib.php');require_once(__DIR__ . '/behat_command.php');require_once(__DIR__ . '/../../testing/classes/tests_finder.php');/*** Behat configuration manager** Creates/updates Behat config files getting tests* and steps from Moodle codebase** @package core* @copyright 2016 Rajesh Taneja* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/class behat_config_util {/*** @var array list of features in core.*/private $features;/*** @var array list of contexts in core.*/private $contexts;/*** @var array list of theme specific contexts.*/private $themecontexts;/*** @var array list of overridden theme contexts.*/private $overriddenthemescontexts;/*** @var array list of components with tests.*/private $componentswithtests;/*** @var array|string keep track of theme to return suite with all core features included or not.*/private $themesuitewithallfeatures = array();/*** @var string filter features which have tags.*/private $tags = '';/*** @var int number of parallel runs.*/private $parallelruns = 0;/*** @var int current run.*/private $currentrun = 0;/*** @var string used to specify if behat should be initialised with all themes.*/const ALL_THEMES_TO_RUN = 'ALL';/*** Set value for theme suite to include all core features. This should be used if your want all core features to be* run with theme.** @param bool $themetoset*/public function set_theme_suite_to_include_core_features($themetoset) {// If no value passed to --add-core-features-to-theme or ALL is passed, then set core features for all themes.if (!empty($themetoset)) {if (is_number($themetoset) || is_bool($themetoset) || (self::ALL_THEMES_TO_RUN === strtoupper($themetoset))) {$this->themesuitewithallfeatures = self::ALL_THEMES_TO_RUN;} else {$this->themesuitewithallfeatures = explode(',', $themetoset);$this->themesuitewithallfeatures = array_map('trim', $this->themesuitewithallfeatures);}}}/*** Set the value for tags, so features which are returned will be using filtered by this.** @param string $tags*/public function set_tag_for_feature_filter($tags) {$this->tags = $tags;}/*** Set parallel run to be used for generating config.** @param int $parallelruns number of parallel runs.* @param int $currentrun current run*/public function set_parallel_run($parallelruns, $currentrun) {if ($parallelruns < $currentrun) {behat_error(BEHAT_EXITCODE_REQUIREMENT,'Parallel runs('.$parallelruns.') should be more then current run('.$currentrun.')');}$this->parallelruns = $parallelruns;$this->currentrun = $currentrun;}/*** Return parallel runs** @return int number of parallel runs.*/public function get_number_of_parallel_run() {// Get number of parallel runs if not passed.if (empty($this->parallelruns) && ($this->parallelruns !== false)) {$this->parallelruns = behat_config_manager::get_behat_run_config_value('parallel');}return $this->parallelruns;}/*** Return current run** @return int current run.*/public function get_current_run() {global $CFG;// Get number of parallel runs if not passed.if (empty($this->currentrun) && ($this->currentrun !== false) && !empty($CFG->behatrunprocess)) {$this->currentrun = $CFG->behatrunprocess;}return $this->currentrun;}/*** Return list of features.** @param string $tags tags.* @return array*/public function get_components_features($tags = '') {global $CFG;// If we already have a list created then just return that, as it's up-to-date.// If tags are passed then it's a new filter of features we need.if (!empty($this->features) && empty($tags)) {return $this->features;}// Gets all the components with features.$features = array();$featurespaths = array();$components = $this->get_components_with_tests();if ($components) {foreach ($components as $componentname => $path) {$path = $this->clean_path($path) . self::get_behat_tests_path();if (empty($featurespaths[$path]) && file_exists($path)) {list($key, $featurepath) = $this->get_clean_feature_key_and_path($path);$featurespaths[$key] = $featurepath;}}foreach ($featurespaths as $path) {$additional = glob("$path/*.feature");$additionalfeatures = array();foreach ($additional as $featurepath) {list($key, $path) = $this->get_clean_feature_key_and_path($featurepath);$additionalfeatures[$key] = $path;}$features = array_merge($features, $additionalfeatures);}}// Optionally include features from additional directories.if (!empty($CFG->behat_additionalfeatures)) {$additional = array_map("realpath", $CFG->behat_additionalfeatures);$additionalfeatures = array();foreach ($additional as $featurepath) {list($key, $path) = $this->get_clean_feature_key_and_path($featurepath);$additionalfeatures[$key] = $path;}$features = array_merge($features, $additionalfeatures);}// Sanitize feature key.$cleanfeatures = array();foreach ($features as $featurepath) {list($key, $path) = $this->get_clean_feature_key_and_path($featurepath);$cleanfeatures[$key] = $path;}// Sort feature list.ksort($cleanfeatures);$this->features = $cleanfeatures;// If tags are passed then filter features which has sepecified tags.if (!empty($tags)) {$cleanfeatures = $this->filtered_features_with_tags($cleanfeatures, $tags);}return $cleanfeatures;}/*** Return feature key for featurepath** @param string $featurepath* @return array key and featurepath.*/public function get_clean_feature_key_and_path($featurepath) {global $CFG;// Fix directory path.$featurepath = testing_cli_fix_directory_separator($featurepath);$dirroot = testing_cli_fix_directory_separator($CFG->dirroot . DIRECTORY_SEPARATOR);$key = basename($featurepath, '.feature');// Get relative path.$featuredirname = str_replace($dirroot , '', $featurepath);// Get 5 levels of feature path to ensure we have a unique key.for ($i = 0; $i < 5; $i++) {if (($featuredirname = dirname($featuredirname)) && $featuredirname !== '.') {if ($basename = basename($featuredirname)) {$key .= '_' . $basename;}}}return array($key, $featurepath);}/*** Get component contexts.** @param string $component component name.* @return array*/private function get_component_contexts($component) {if (empty($component)) {return $this->contexts;}$componentcontexts = array();foreach ($this->contexts as $key => $path) {if ($component == '' || $component === $key) {$componentcontexts[$key] = $path;}}return $componentcontexts;}/*** Gets the list of Moodle behat contexts** Class name as a key and the filepath as value** Externalized from update_config_file() to use* it from the steps definitions web interface** @param string $component Restricts the obtained steps definitions to the specified component* @return array*/public function get_components_contexts($component = '') {// If we already have a list created then just return that, as it's up-to-date.if (!empty($this->contexts)) {return $this->get_component_contexts($component);}$components = $this->get_components_with_tests();$this->contexts = array();foreach ($components as $componentname => $componentpath) {if (false !== strpos($componentname, 'theme_')) {continue;}$componentpath = self::clean_path($componentpath);if (!file_exists($componentpath . self::get_behat_tests_path())) {continue;}$diriterator = new DirectoryIterator($componentpath . self::get_behat_tests_path());$regite = new RegexIterator($diriterator, '|^behat_.*\.php$|');// All behat_*.php inside self::get_behat_tests_path() are added as steps definitions files.foreach ($regite as $file) {$key = $file->getBasename('.php');$this->contexts[$key] = $file->getPathname();}}// Sort contexts with there name.ksort($this->contexts);return $this->get_component_contexts($component);}/*** Sort the list of components contexts.** This ensures that contexts are sorted consistently.* Core hooks defined in the behat_hooks class _must_ be defined first.** @param array $contexts* @return array The sorted context list*/protected function sort_component_contexts(array $contexts): array {// Ensure that the lib_tests are first as they include the root of all tests, hooks, and more.uksort($contexts, function($a, $b): int {if ($a === 'behat_hooks') {return -1;}if ($b === 'behat_hooks') {return 1;}if ($a == $b) {return 0;}return ($a < $b) ? -1 : 1;});return $contexts;}/*** Behat config file specifing the main context class,* the required Behat extensions and Moodle test wwwroot.** @param array $features The system feature files* @param array $contexts The system steps definitions* @param string $tags filter features with specified tags.* @param int $parallelruns number of parallel runs.* @param int $currentrun current run for which config file is needed.* @return string*/public function get_config_file_contents($features = '', $contexts = '', $tags = '', $parallelruns = 0, $currentrun = 0) {global $CFG;// Set current run and parallel run.if (!empty($parallelruns) && !empty($currentrun)) {$this->set_parallel_run($parallelruns, $currentrun);}// If tags defined then use them. This is for BC.if (!empty($tags)) {$this->set_tag_for_feature_filter($tags);}// If features not passed then get it. Empty array means we don't need to include features.if (empty($features) && !is_array($features)) {$features = $this->get_components_features();} else {$this->features = $features;}// If stepdefinitions not passed then get the list.if (empty($contexts)) {$this->get_components_contexts();} else {$this->contexts = $contexts;}// We require here when we are sure behat dependencies are available.require_once($CFG->dirroot . '/vendor/autoload.php');$config = $this->build_config();$config = $this->merge_behat_config($config);$config = $this->merge_behat_profiles($config);// Return config array for phpunit, so it can be tested.if (defined('PHPUNIT_TEST') && PHPUNIT_TEST) {return $config;}return Symfony\Component\Yaml\Yaml::dump($config, 10, 2);}/*** Search feature files for set of tags.** @param array $features set of feature files.* @param string $tags list of tags (currently support && only.)* @return array filtered list of feature files with tags.*/public function filtered_features_with_tags($features = '', $tags = '') {// This is for BC. Features if not passed then we already have a list in this object.if (empty($features)) {$features = $this->features;}// If no tags defined then return full list.if (empty($tags) && empty($this->tags)) {return $features;}// If no tags passed by the caller, then it's already set.if (empty($tags)) {$tags = $this->tags;}$newfeaturelist = array();// Split tags in and and or.$tags = explode('&&', $tags);$andtags = array();$ortags = array();foreach ($tags as $tag) {// Explode all tags seperated by , and add it to ortags.$ortags = array_merge($ortags, explode(',', $tag));// And tags will be the first one before comma(,).$andtags[] = preg_replace('/,.*/', '', $tag);}foreach ($features as $key => $featurefile) {$contents = file_get_contents($featurefile);$includefeature = true;foreach ($andtags as $tag) {// If negitive tag, then ensure it don't exist.if (strpos($tag, '~') !== false) {$tag = substr($tag, 1);if ($contents && strpos($contents, $tag) !== false) {$includefeature = false;break;}} else if ($contents && strpos($contents, $tag) === false) {$includefeature = false;break;}}// If feature not included then check or tags.if (!$includefeature && !empty($ortags)) {foreach ($ortags as $tag) {if ($contents && (strpos($tag, '~') === false) && (strpos($contents, $tag) !== false)) {$includefeature = true;break;}}}if ($includefeature) {$newfeaturelist[$key] = $featurefile;}}return $newfeaturelist;}/*** Build config for behat.yml.** @param int $parallelruns how many parallel runs feature needs to be divided.* @param int $currentrun current run for which features should be returned.* @return array*/protected function build_config($parallelruns = 0, $currentrun = 0) {global $CFG;if (!empty($parallelruns) && !empty($currentrun)) {$this->set_parallel_run($parallelruns, $currentrun);} else {$currentrun = $this->get_current_run();$parallelruns = $this->get_number_of_parallel_run();}$webdriverwdhost = array('wd_host' => 'http://localhost:4444/wd/hub');// If parallel run, then set wd_host if specified.if (!empty($currentrun) && !empty($parallelruns)) {// Set proper webdriver wd_host if defined.if (!empty($CFG->behat_parallel_run[$currentrun - 1]['wd_host'])) {$webdriverwdhost = array('wd_host' => $CFG->behat_parallel_run[$currentrun - 1]['wd_host']);}}// It is possible that it has no value as we don't require a full behat setup to list the step definitions.if (empty($CFG->behat_wwwroot)) {$CFG->behat_wwwroot = 'http://itwillnotbeused.com';}$suites = $this->get_behat_suites($parallelruns, $currentrun);$selectortypes = ['named_partial', 'named_exact'];$allpaths = [];foreach (array_keys($suites) as $theme) {// Remove selectors from step definitions.foreach ($selectortypes as $selectortype) {// Don't include selector classes.$selectorclass = self::get_behat_theme_selector_override_classname($theme, $selectortype);if (isset($suites[$theme]['contexts'][$selectorclass])) {unset($suites[$theme]['contexts'][$selectorclass]);}}// Get a list of all step definition paths.$allpaths = array_merge($allpaths, $suites[$theme]['contexts']);// Convert the contexts array to a list of names only.$suites[$theme]['contexts'] = array_keys($suites[$theme]['contexts']);}// Comments use black color, so failure path is not visible. Using color other then black/white is safer.// https://github.com/Behat/Behat/pull/628.$config = array('default' => array('formatters' => array('moodle_progress' => array('output_styles' => array('comment' => array('magenta')))),'suites' => $suites,'extensions' => array('Behat\MinkExtension' => array('base_url' => $CFG->behat_wwwroot,'browserkit_http' => null,'webdriver' => $webdriverwdhost),'Moodle\BehatExtension' => array('moodledirroot' => $CFG->dirroot,'steps_definitions' => $allpaths,))));return $config;}/*** Divide features between the runs and return list.** @param array $features list of features to be divided.* @param int $parallelruns how many parallel runs feature needs to be divided.* @param int $currentrun current run for which features should be returned.* @return array*/protected function get_features_for_the_run($features, $parallelruns, $currentrun) {// If no features are passed then just return.if (empty($features)) {return $features;}$allocatedfeatures = $features;// If parallel run, then only divide features.if (!empty($currentrun) && !empty($parallelruns)) {$featurestodivide['withtags'] = $features;$allocatedfeatures = array();// If tags are set then split features with tags first.if (!empty($this->tags)) {$featurestodivide['withtags'] = $this->filtered_features_with_tags($features);$featurestodivide['withouttags'] = $this->remove_blacklisted_features_from_list($features,$featurestodivide['withtags']);}// Attempt to split into weighted buckets using timing information, if available.foreach ($featurestodivide as $tagfeatures) {if ($alloc = $this->profile_guided_allocate($tagfeatures, max(1, $parallelruns), $currentrun)) {$allocatedfeatures = array_merge($allocatedfeatures, $alloc);} else {// Divide the list of feature files amongst the parallel runners.// Pull out the features for just this worker.if (count($tagfeatures)) {$splitfeatures = array_chunk($tagfeatures, ceil(count($tagfeatures) / max(1, $parallelruns)));// Check if there is any feature file for this process.if (!empty($splitfeatures[$currentrun - 1])) {$allocatedfeatures = array_merge($allocatedfeatures, $splitfeatures[$currentrun - 1]);}}}}}return $allocatedfeatures;}/*** Parse $CFG->behat_profile and return the array with required config structure for behat.yml.** $CFG->behat_profiles = array(* 'profile' = array(* 'browser' => 'firefox',* 'tags' => '@javascript',* 'wd_host' => 'http://127.0.0.1:4444/wd/hub',* 'capabilities' => array(* 'platform' => 'Linux',* 'version' => 44* )* )* );** @param string $profile profile name* @param array $values values for profile.* @return array*/protected function get_behat_profile($profile, $values) {// Values should be an array.if (!is_array($values)) {return array();}// Check suite values.$behatprofilesuites = array();// Automatically set tags information to skip app testing if necessary. We skip app testing// if the browser is not Chrome. (Note: We also skip if it's not configured, but that is// done on the theme/suite level.)if (empty($values['browser']) || $values['browser'] !== 'chrome') {if (!empty($values['tags'])) {$values['tags'] .= ' && ~@app';} else {$values['tags'] = '~@app';}}// Automatically add Chrome command line option to skip the prompt about allowing file// storage - needed for mobile app testing (won't hurt for everything else either).// We also need to disable web security, otherwise it can't make CSS requests to the server// on localhost due to CORS restrictions.if (!empty($values['browser']) && $values['browser'] === 'chrome') {$values = array_merge_recursive(['capabilities' => ['extra_capabilities' => ['goog:chromeOptions' => ['args' => ['unlimited-storage','disable-web-security',],],],],],$values);// Selenium no longer supports non-w3c browser control.// Rename chromeOptions to goog:chromeOptions, which is the W3C variant of this.if (array_key_exists('chromeOptions', $values['capabilities']['extra_capabilities'])) {$values['capabilities']['extra_capabilities']['goog:chromeOptions'] = array_merge_recursive($values['capabilities']['extra_capabilities']['goog:chromeOptions'],$values['capabilities']['extra_capabilities']['chromeOptions'],);unset($values['capabilities']['extra_capabilities']['chromeOptions']);}// If the mobile app is enabled, check its version and add appropriate tags.if ($mobiletags = $this->get_mobile_version_tags()) {if (!empty($values['tags'])) {$values['tags'] .= ' && ' . $mobiletags;} else {$values['tags'] = $mobiletags;}}$values['capabilities']['extra_capabilities']['goog:chromeOptions']['args'] = array_map(function($arg): string {if (substr($arg, 0, 2) === '--') {return substr($arg, 2);}return $arg;}, $values['capabilities']['extra_capabilities']['goog:chromeOptions']['args']);sort($values['capabilities']['extra_capabilities']['goog:chromeOptions']['args']);}// Fill tags information.if (isset($values['tags'])) {$behatprofilesuites = array('suites' => array('default' => array('filters' => array('tags' => $values['tags'],))));}// Selenium2 config values.$behatprofileextension = array();$seleniumconfig = array();if (isset($values['browser'])) {$seleniumconfig['browser'] = $values['browser'];}if (isset($values['wd_host'])) {$seleniumconfig['wd_host'] = $values['wd_host'];}if (isset($values['capabilities'])) {$seleniumconfig['capabilities'] = $values['capabilities'];}if (!empty($seleniumconfig)) {$behatprofileextension = array('extensions' => array('Behat\MinkExtension' => array('webdriver' => $seleniumconfig,)));}return array($profile => array_merge($behatprofilesuites, $behatprofileextension));}/*** Gets version tags to use for the mobile app.** This is based on the current mobile app version (from its package.json) and all known* mobile app versions (based on the list appversions.json in the lib/behat directory).** @param bool $verbose If true, outputs information about installed app version* @return string List of tags or '' if not supporting mobile*/protected function get_mobile_version_tags($verbose = true): string {global $CFG;if (empty($CFG->behat_ionic_wwwroot)) {return '';}// Get app version from env.json inside wwwroot.$jsonurl = $CFG->behat_ionic_wwwroot . '/assets/env.json';$streamcontext = stream_context_create(['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]]);$json = @file_get_contents($jsonurl, false, $streamcontext);if (!$json) {throw new coding_exception('Unable to load app version from ' . $jsonurl);}$env = json_decode($json);if (empty($env->build->version ?? null)) {throw new coding_exception('Invalid app config data in ' . $jsonurl);}$installedversion = $env->build->version;// Read all feature files to check which mobile tags are used. (Note: This could be cached// but ideally, it is the sort of thing that really ought to be refreshed by doing a new// Behat init. Also, at time of coding it only takes 0.3 seconds and only if app enabled.)$usedtags = [];foreach ($this->features as $filepath) {$feature = file_get_contents($filepath);// This may incorrectly detect versions used e.g. in a comment or something, but it// doesn't do much harm if we have extra ones.if (preg_match_all('~@app_(?:from|upto)(?:[0-9]+(?:\.[0-9]+)*)~', $feature, $matches)) {foreach ($matches[0] as $tag) {// Store as key in array so we don't get duplicates.$usedtags[$tag] = true;}}}// Set up relevant tags for each version.$tags = [];foreach ($usedtags as $usedtag => $ignored) {if (!preg_match('~^@app_(from|upto)([0-9]+(?:\.[0-9]+)*)$~', $usedtag, $matches)) {throw new coding_exception('Unexpected tag format');}$direction = $matches[1];$version = $matches[2];switch (version_compare($installedversion, $version)) {case -1:// Installed version OLDER than the one being considered, so do not// include any scenarios that only run from the considered version up.if ($direction === 'from') {$tags[] = '~@app_from' . $version;}break;case 0:// Installed version EQUAL to the one being considered - no tags need// excluding.break;case 1:// Installed version NEWER than the one being considered, so do not// include any scenarios that only run up to that version.if ($direction === 'upto') {$tags[] = '~@app_upto' . $version;}break;}}if ($verbose) {mtrace('Configured app tests for version ' . $installedversion);}return join(' && ', $tags);}/*** Attempt to split feature list into fairish buckets using timing information, if available.* Simply add each one to lightest buckets until all files allocated.* PGA = Profile Guided Allocation. I made it up just now.* CAUTION: workers must agree on allocation, do not be random anywhere!** @param array $features Behat feature files array* @param int $nbuckets Number of buckets to divide into* @param int $instance Index number of this instance* @return array|bool Feature files array, sorted into allocations*/public function profile_guided_allocate($features, $nbuckets, $instance) {// No profile guided allocation is required in phpunit.if (defined('PHPUNIT_TEST') && PHPUNIT_TEST) {return false;}$behattimingfile = defined('BEHAT_FEATURE_TIMING_FILE') &&@filesize(BEHAT_FEATURE_TIMING_FILE) ? BEHAT_FEATURE_TIMING_FILE : false;if (!$behattimingfile || !$behattimingdata = @json_decode(file_get_contents($behattimingfile), true)) {// No data available, fall back to relying on steps data.$stepfile = "";if (defined('BEHAT_FEATURE_STEP_FILE') && BEHAT_FEATURE_STEP_FILE) {$stepfile = BEHAT_FEATURE_STEP_FILE;}// We should never get this. But in case we can't do this then fall back on simple splitting.if (empty($stepfile) || !$behattimingdata = @json_decode(file_get_contents($stepfile), true)) {return false;}}arsort($behattimingdata); // Ensure most expensive is first.$realroot = realpath(__DIR__.'/../../../').'/';$defaultweight = array_sum($behattimingdata) / count($behattimingdata);$weights = array_fill(0, $nbuckets, 0);$buckets = array_fill(0, $nbuckets, array());$totalweight = 0;// Re-key the features list to match timing data.foreach ($features as $k => $file) {$key = str_replace($realroot, '', $file);$features[$key] = $file;unset($features[$k]);if (!isset($behattimingdata[$key])) {$behattimingdata[$key] = $defaultweight;}}// Sort features by known weights; largest ones should be allocated first.$behattimingorder = array();foreach ($features as $key => $file) {$behattimingorder[$key] = $behattimingdata[$key];}arsort($behattimingorder);// Finally, add each feature one by one to the lightest bucket.foreach ($behattimingorder as $key => $weight) {$file = $features[$key];$lightbucket = array_search(min($weights), $weights);$weights[$lightbucket] += $weight;$buckets[$lightbucket][] = $file;$totalweight += $weight;}if ($totalweight && !defined('BEHAT_DISABLE_HISTOGRAM') && $instance == $nbuckets&& (!defined('PHPUNIT_TEST') || !PHPUNIT_TEST)) {echo "Bucket weightings:\n";foreach ($weights as $k => $weight) {echo $k + 1 . ": " . str_repeat('*', (int)(70 * $nbuckets * $weight / $totalweight)) . PHP_EOL;}}// Return the features for this worker.return $buckets[$instance - 1];}/*** Overrides default config with local config values** array_merge does not merge completely the array's values** @param mixed $config The node of the default config* @param mixed $localconfig The node of the local config* @return mixed The merge result*/public function merge_config($config, $localconfig) {if (!is_array($config) && !is_array($localconfig)) {return $localconfig;}// Local overrides also deeper default values.if (is_array($config) && !is_array($localconfig)) {return $localconfig;}foreach ($localconfig as $key => $value) {// If defaults are not as deep as local values let locals override.if (!is_array($config)) {unset($config);}// Add the param if it doesn't exists or merge branches.if (empty($config[$key])) {$config[$key] = $value;} else {$config[$key] = $this->merge_config($config[$key], $localconfig[$key]);}}return $config;}/*** Merges $CFG->behat_config with the one passed.** @param array $config existing config.* @return array merged config with $CFG->behat_config*/public function merge_behat_config($config) {global $CFG;// In case user defined overrides respect them over our default ones.if (!empty($CFG->behat_config)) {foreach ($CFG->behat_config as $profile => $values) {$values = $this->fix_legacy_profile_data($profile, $values);$config = $this->merge_config($config, $this->get_behat_config_for_profile($profile, $values));}}return $config;}/*** Parse $CFG->behat_config and return the array with required config structure for behat.yml** @param string $profile profile name* @param array $values values for profile* @return array*/public function get_behat_config_for_profile($profile, $values) {// Only add profile which are compatible with Behat 3.x// Just check if any of Bheat 2.5 config is set. Not checking for 3.x as it might have some other configs// Like : rerun_cache etc.if (!isset($values['filters']['tags']) && !isset($values['extensions']['Behat\MinkExtension\Extension'])) {return array($profile => $values);}// Parse 2.5 format and get related values.$oldconfigvalues = array();if (isset($values['extensions']['Behat\MinkExtension\Extension'])) {$extensionvalues = $values['extensions']['Behat\MinkExtension\Extension'];if (isset($extensionvalues['webdriver']['browser'])) {$oldconfigvalues['browser'] = $extensionvalues['webdriver']['browser'];}if (isset($extensionvalues['webdriver']['wd_host'])) {$oldconfigvalues['wd_host'] = $extensionvalues['webdriver']['wd_host'];}if (isset($extensionvalues['capabilities'])) {$oldconfigvalues['capabilities'] = $extensionvalues['capabilities'];}}if (isset($values['filters']['tags'])) {$oldconfigvalues['tags'] = $values['filters']['tags'];}if (!empty($oldconfigvalues)) {behat_config_manager::$autoprofileconversion = true;return $this->get_behat_profile($profile, $oldconfigvalues);}// If nothing set above then return empty array.return array();}/*** Merges $CFG->behat_profiles with the one passed.** @param array $config existing config.* @return array merged config with $CFG->behat_profiles*/public function merge_behat_profiles($config) {global $CFG;// Check for Moodle custom ones.if (!empty($CFG->behat_profiles) && is_array($CFG->behat_profiles)) {foreach ($CFG->behat_profiles as $profile => $values) {$config = $this->merge_config($config, $this->get_behat_profile($profile, $values));}}return $config;}/*** Check for and attempt to fix legacy profile data.** The Mink Driver used for W3C no longer uses the `selenium2` naming but otherwise is backwards compatibly.** Emit a warning that users should update their configuration.** @param string $profilename The name of this profile* @param array $data The profile data for this profile* @return array Th eamended profile data*/protected function fix_legacy_profile_data(string $profilename, array $data): array {// Check for legacy instaclick profiles.if (!array_key_exists('Behat\MinkExtension', $data['extensions'])) {return $data;}if (array_key_exists('selenium2', $data['extensions']['Behat\MinkExtension'])) {echo("\n\n");echo("=> Warning: Legacy selenium2 profileuration was found for {$profilename} profile.\n");echo("=> This has been renamed from 'selenium2' to 'webdriver'.\n");echo("=> You should update your Behat configuration.\n");echo("\n");$data['extensions']['Behat\MinkExtension']['webdriver'] = $data['extensions']['Behat\MinkExtension']['selenium2'];unset($data['extensions']['Behat\MinkExtension']['selenium2']);}return $data;}/*** Cleans the path returned by get_components_with_tests() to standarize it** @see tests_finder::get_all_directories_with_tests() it returns the path including /tests/* @param string $path* @return string The string without the last /tests part*/final public function clean_path($path) {$path = rtrim($path, DIRECTORY_SEPARATOR);$parttoremove = DIRECTORY_SEPARATOR . 'tests';$substr = substr($path, strlen($path) - strlen($parttoremove));if ($substr == $parttoremove) {$path = substr($path, 0, strlen($path) - strlen($parttoremove));}return rtrim($path, DIRECTORY_SEPARATOR);}/*** The relative path where components stores their behat tests** @return string*/final public static function get_behat_tests_path() {return DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'behat';}/*** Return context name of behat_theme selector to use.** @param string $themename name of the theme.* @param string $selectortype The type of selector (partial or exact at this stage)* @param bool $includeclass if class should be included.* @return string*/final public static function get_behat_theme_selector_override_classname($themename, $selectortype, $includeclass = false) {global $CFG;if ($selectortype !== 'named_partial' && $selectortype !== 'named_exact') {throw new coding_exception("Unknown selector override type '{$selectortype}'");}$overridebehatclassname = "behat_theme_{$themename}_behat_{$selectortype}_selectors";if ($includeclass) {$themeoverrideselector = $CFG->dirroot . DIRECTORY_SEPARATOR . 'theme' . DIRECTORY_SEPARATOR . $themename .self::get_behat_tests_path() . DIRECTORY_SEPARATOR . $overridebehatclassname . '.php';if (file_exists($themeoverrideselector)) {require_once($themeoverrideselector);}}return $overridebehatclassname;}/*** List of components which contain behat context or features.** @return array*/protected function get_components_with_tests() {if (empty($this->componentswithtests)) {$this->componentswithtests = tests_finder::get_components_with_tests('behat');}return $this->componentswithtests;}/*** Remove list of blacklisted features from the feature list.** @param array $features list of original features.* @param array|string $blacklist list of features which needs to be removed.* @return array features - blacklisted features.*/protected function remove_blacklisted_features_from_list($features, $blacklist) {// If no blacklist passed then return.if (empty($blacklist)) {return $features;}// If there is no feature in suite then just return what was passed.if (empty($features)) {return $features;}if (!is_array($blacklist)) {$blacklist = array($blacklist);}// Remove blacklisted features.foreach ($blacklist as $blacklistpath) {list($key, $featurepath) = $this->get_clean_feature_key_and_path($blacklistpath);if (isset($features[$key])) {$features[$key] = null;unset($features[$key]);} else {$featurestocheck = $this->get_components_features();if (!isset($featurestocheck[$key]) && (!defined('PHPUNIT_TEST') || !PHPUNIT_TEST)) {behat_error(BEHAT_EXITCODE_REQUIREMENT, 'Blacklisted feature "' . $blacklistpath . '" not found.');}}}return $features;}/*** Return list of behat suites. Multiple suites are returned if theme* overrides default step definitions/features.** @param int $parallelruns number of parallel runs* @param int $currentrun current run.* @return array list of suites.*/protected function get_behat_suites($parallelruns = 0, $currentrun = 0) {$features = $this->get_components_features();// Get number of parallel runs and current run.if (!empty($parallelruns) && !empty($currentrun)) {$this->set_parallel_run($parallelruns, $currentrun);} else {$parallelruns = $this->get_number_of_parallel_run();$currentrun = $this->get_current_run();;}$themefeatures = array();$themecontexts = array();$themes = $this->get_list_of_themes();// Create list of theme suite features and contexts.foreach ($themes as $theme) {// Get theme features and contexts.$themefeatures[$theme] = $this->get_behat_features_for_theme($theme);$themecontexts[$theme] = $this->get_behat_contexts_for_theme($theme);}// Remove list of theme features for default suite, as default suite should not run theme specific features.foreach ($themefeatures as $themename => $removethemefeatures) {if (!empty($removethemefeatures['features'])) {$features = $this->remove_blacklisted_features_from_list($features, $removethemefeatures['features']);}}// Set suite for each theme.$suites = array();foreach ($themes as $theme) {// Get list of features which will be included in theme.// If theme suite with all features or default theme, then we want all core features to be part of theme suite.if ((is_string($this->themesuitewithallfeatures) && ($this->themesuitewithallfeatures === self::ALL_THEMES_TO_RUN)) ||in_array($theme, $this->themesuitewithallfeatures) || ($this->get_default_theme() === $theme)) {// If there is no theme specific feature. Then it's just core features.if (empty($themefeatures[$theme]['features'])) {$themesuitefeatures = $features;} else {$themesuitefeatures = array_merge($features, $themefeatures[$theme]['features']);}} else {$themesuitefeatures = $themefeatures[$theme]['features'];}// Remove blacklisted features.$themesuitefeatures = $this->remove_blacklisted_features_from_list($themesuitefeatures,$themefeatures[$theme]['blacklistfeatures']);// Return sub-set of features if parallel run.$themesuitefeatures = $this->get_features_for_the_run($themesuitefeatures, $parallelruns, $currentrun);// Default theme is part of default suite.if ($this->get_default_theme() === $theme) {$suitename = 'default';} else {$suitename = $theme;}// Add suite no matter what. If there is no feature in suite then it will just exist successfully with no scenarios.// But if we don't set this then the user has to know which run doesn't have suite and which run do.$suites = array_merge($suites, array($suitename => array('paths' => array_values($themesuitefeatures),'contexts' => $themecontexts[$theme],)));}return $suites;}/*** Return name of default theme.** @return string*/protected function get_default_theme() {return theme_config::DEFAULT_THEME;}/*** Return list of themes which can be set in moodle.** @return array list of themes with tests.*/protected function get_list_of_themes() {$selectablethemes = array();// Get all themes installed on site.$themes = core_component::get_plugin_list('theme');ksort($themes);foreach ($themes as $themename => $themedir) {// Load the theme config.try {$theme = $this->get_theme_config($themename);} catch (Exception $e) {// Bad theme, just skip it for now.continue;}if ($themename !== $theme->name) {// Obsoleted or broken theme, just skip for now.continue;}if ($theme->hidefromselector) {// The theme doesn't want to be shown in the theme selector and as theme// designer mode is switched off we will respect that decision.continue;}$selectablethemes[] = $themename;}return $selectablethemes;}/*** Return the theme config for a given theme name.* This is done so we can mock it in PHPUnit.** @param string $themename name of theme* @return theme_config*/public function get_theme_config($themename) {return theme_config::load($themename);}/*** Return theme directory.** @param string $themename name of theme* @return string theme directory*/protected function get_theme_test_directory($themename) {global $CFG;$themetestdir = "/theme/" . $themename;return $CFG->dirroot . $themetestdir . self::get_behat_tests_path();}/*** Returns all the directories having overridden tests.** @param string $theme name of theme* @param string $testtype The kind of test we are looking for* @return array all directories having tests*/protected function get_test_directories_overridden_for_theme($theme, $testtype) {global $CFG;$testtypes = array('contexts' => '|behat_.*\.php$|','features' => '|.*\.feature$|',);$themetestdirfullpath = $this->get_theme_test_directory($theme);// If test directory doesn't exist then return.if (!is_dir($themetestdirfullpath)) {return array();}$directoriestosearch = glob($themetestdirfullpath . DIRECTORY_SEPARATOR . '*' , GLOB_ONLYDIR);// Include theme directory to find tests.$dirs[realpath($themetestdirfullpath)] = trim(str_replace('/', '_', $themetestdirfullpath), '_');// Search for tests in valid directories.foreach ($directoriestosearch as $dir) {$dirite = new RecursiveDirectoryIterator($dir);$iteite = new RecursiveIteratorIterator($dirite);$regexp = $testtypes[$testtype];$regite = new RegexIterator($iteite, $regexp);foreach ($regite as $path => $element) {$key = dirname($path);$value = trim(str_replace(DIRECTORY_SEPARATOR, '_', str_replace($CFG->dirroot, '', $key)), '_');$dirs[$key] = $value;}}ksort($dirs);return array_flip($dirs);}/*** Return blacklisted contexts or features for a theme, as defined in blacklist.json.** @param string $theme themename* @param string $testtype test type (contexts|features)* @return array list of blacklisted contexts or features*/protected function get_blacklisted_tests_for_theme($theme, $testtype) {$themetestpath = $this->get_theme_test_directory($theme);if (file_exists($themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json')) {// Blacklist file exist. Leave it for last to clear the feature and contexts.$blacklisttests = @json_decode(file_get_contents($themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json'), true);if (empty($blacklisttests)) {behat_error(BEHAT_EXITCODE_REQUIREMENT, $themetestpath . DIRECTORY_SEPARATOR . 'blacklist.json is empty');}// If features or contexts not defined then no problem.if (!isset($blacklisttests[$testtype])) {$blacklisttests[$testtype] = array();}return $blacklisttests[$testtype];}return array();}/*** Return list of features and step definitions in theme.** @param string $theme theme name* @param string $testtype test type, either features or contexts* @return array list of contexts $contexts or $features*/protected function get_tests_for_theme($theme, $testtype) {$tests = array();$testtypes = array('contexts' => '|^behat_.*\.php$|','features' => '|.*\.feature$|',);// Get all the directories having overridden tests.$directories = $this->get_test_directories_overridden_for_theme($theme, $testtype);// Get overridden test contexts.foreach ($directories as $dirpath) {// All behat_*.php inside overridden directory.$diriterator = new DirectoryIterator($dirpath);$regite = new RegexIterator($diriterator, $testtypes[$testtype]);// All behat_*.php inside behat_config_manager::get_behat_tests_path() are added as steps definitions files.foreach ($regite as $file) {$key = $file->getBasename('.php');$tests[$key] = $file->getPathname();}}return $tests;}/*** Return list of blacklisted behat features for theme and features defined by theme only.** @param string $theme theme name.* @return array ($blacklistfeatures, $blacklisttags, $features)*/protected function get_behat_features_for_theme($theme) {global $CFG;// Get list of features defined by theme.$themefeatures = $this->get_tests_for_theme($theme, 'features');$themeblacklistfeatures = $this->get_blacklisted_tests_for_theme($theme, 'features');$themeblacklisttags = $this->get_blacklisted_tests_for_theme($theme, 'tags');// Mobile app tests are not theme-specific, so run only for the default theme (and if// configured).if (empty($CFG->behat_ionic_wwwroot) || $theme !== $this->get_default_theme()) {$themeblacklisttags[] = '@app';}// Clean feature key and path.$features = array();$blacklistfeatures = array();foreach ($themefeatures as $themefeature) {list($featurekey, $featurepath) = $this->get_clean_feature_key_and_path($themefeature);$features[$featurekey] = $featurepath;}foreach ($themeblacklistfeatures as $themeblacklistfeature) {list($blacklistfeaturekey, $blacklistfeaturepath) = $this->get_clean_feature_key_and_path($themeblacklistfeature);$blacklistfeatures[$blacklistfeaturekey] = $blacklistfeaturepath;}// If blacklist tags then add those features to list.if (!empty($themeblacklisttags)) {// Remove @ if given, so we are sure we have only tag names.$themeblacklisttags = array_map(function($v) {return ltrim($v, '@');}, $themeblacklisttags);$themeblacklisttags = '@' . implode(',@', $themeblacklisttags);$blacklistedfeatureswithtag = $this->filtered_features_with_tags($this->get_components_features(),$themeblacklisttags);// Add features with blacklisted tags.if (!empty($blacklistedfeatureswithtag)) {foreach ($blacklistedfeatureswithtag as $themeblacklistfeature) {list($key, $path) = $this->get_clean_feature_key_and_path($themeblacklistfeature);$blacklistfeatures[$key] = $path;}}}ksort($features);$retval = array('blacklistfeatures' => $blacklistfeatures,'features' => $features);return $retval;}/*** Return list of behat contexts for theme and update $this->stepdefinitions list.** @param string $theme theme name.* @return List of contexts*/protected function get_behat_contexts_for_theme($theme): array {// If we already have this list then just return. This will not change by run.if (!empty($this->themecontexts[$theme])) {return $this->themecontexts[$theme];}try {$themeconfig = $this->get_theme_config($theme);} catch (Exception $e) {// This theme has no theme config.return [];}// The theme will use all core contexts, except the one overridden by theme or its parent.$parentcontexts = [];if (isset($themeconfig->parents)) {foreach ($themeconfig->parents as $parent) {if ($parentcontexts = $this->get_behat_contexts_for_theme($parent)) {break;}}}if (empty($parentcontexts)) {$parentcontexts = $this->get_components_contexts();}// Remove contexts which have been actively blacklisted.$blacklistedcontexts = $this->get_blacklisted_tests_for_theme($theme, 'contexts');foreach ($blacklistedcontexts as $blacklistpath) {$blacklistcontext = basename($blacklistpath, '.php');unset($parentcontexts[$blacklistcontext]);}// Apply overrides.$contexts = array_merge($parentcontexts, $this->get_tests_for_theme($theme, 'contexts'));// Remove classes which are overridden.foreach ($contexts as $contextclass => $path) {require_once($path);if (!class_exists($contextclass)) {// This may be a Poorly named class.continue;}$rc = new \ReflectionClass($contextclass);while ($rc = $rc->getParentClass()) {if (isset($contexts[$rc->name])) {unset($contexts[$rc->name]);}}}// Sort the list of contexts.$contexts = $this->sort_component_contexts($contexts);// Cache it for subsequent fetches.$this->themecontexts[$theme] = $contexts;return $contexts;}}