From 50cd119caaaba406591c42c72ed2cd5f3ebf3533 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Fri, 30 Aug 2019 11:12:05 +0800 Subject: [PATCH] MDL-66559 behat: Allow per-component selectors and replacements --- lib/behat/behat_base.php | 46 +++++++- lib/behat/classes/behat_context_helper.php | 18 ++- lib/behat/classes/behat_selectors.php | 1 + .../classes/component_named_replacement.php | 70 ++++++++++++ .../classes/component_named_selector.php | 96 ++++++++++++++++ lib/behat/classes/exact_named_selector.php | 6 + lib/behat/classes/named_selector.php | 107 ++++++++++++++++++ lib/behat/classes/partial_named_selector.php | 7 ++ lib/tests/behat/behat_hooks.php | 67 ++++++++++- 9 files changed, 408 insertions(+), 10 deletions(-) create mode 100644 lib/behat/classes/component_named_replacement.php create mode 100644 lib/behat/classes/component_named_selector.php create mode 100644 lib/behat/classes/named_selector.php diff --git a/lib/behat/behat_base.php b/lib/behat/behat_base.php index c6053b9106b..5510ae6ef28 100644 --- a/lib/behat/behat_base.php +++ b/lib/behat/behat_base.php @@ -35,6 +35,9 @@ use Behat\Mink\Element\NodeElement; use Behat\Mink\Element\Element; use Behat\Mink\Session; +require_once(__DIR__ . '/classes/component_named_selector.php'); +require_once(__DIR__ . '/classes/component_named_replacement.php'); + /** * Steps definitions base class. * @@ -216,10 +219,22 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext { $selector = 'xpath'; } - // Convert to named_partial where the selector type is not named_partial, named_exact, xpath, or css. + // Convert to a named selector where the selector type is not a known selector. $converttonamed = !$this->getSession()->getSelectorsHandler()->isSelectorRegistered($selector); $converttonamed = $converttonamed && 'xpath' !== $selector; if ($converttonamed) { + if (behat_partial_named_selector::is_deprecated_selector($selector)) { + if ($replacement = behat_partial_named_selector::get_deprecated_replacement($selector)) { + error_log("The '{$selector}' selector has been replaced with {$replacement}"); + $selector = $replacement; + } + } else if (behat_exact_named_selector::is_deprecated_selector($selector)) { + if ($replacement = behat_exact_named_selector::get_deprecated_replacement($selector)) { + error_log("The '{$selector}' selector has been replaced with {$replacement}"); + $selector = $replacement; + } + } + $allowedpartialselectors = behat_partial_named_selector::get_allowed_selectors(); $allowedexactselectors = behat_exact_named_selector::get_allowed_selectors(); if (isset($allowedpartialselectors[$selector])) { @@ -229,7 +244,7 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext { $locator = behat_selectors::normalise_named_selector($allowedexactselectors[$selector], $locator); $selector = 'named_exact'; } else { - throw new ExpectationException("The '{$selector}' selector type is not registered.", $this); + throw new ExpectationException("The '{$selector}' selector type is not registered.", $this->getSession()->getDriver()); } } @@ -1092,4 +1107,31 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext { public static function get_extended_timeout() : int { return self::get_real_timeout(10); } + + /** + * Return a list of the exact named selectors for the component. + * + * @return behat_component_named_selector[] + */ + public static function get_exact_named_selectors(): array { + return []; + } + + /** + * Return a list of the partial named selectors for the component. + * + * @return behat_component_named_selector[] + */ + public static function get_partial_named_selectors(): array { + return []; + } + + /** + * Return a list of the named replacements for the component. + * + * @return behat_component_named_replacement[] + */ + public static function get_named_replacements(): array { + return []; + } } diff --git a/lib/behat/classes/behat_context_helper.php b/lib/behat/classes/behat_context_helper.php index 8b050ccaa8f..0a70011a9f2 100644 --- a/lib/behat/classes/behat_context_helper.php +++ b/lib/behat/classes/behat_context_helper.php @@ -90,12 +90,8 @@ class behat_context_helper { * @return behat_base */ public static function get($classname) { - $contexts = self::$environment->getContexts(); - - foreach ($contexts as $context) { - if (is_a($context, $classname)) { - return $context; - } + if (self::$environment->hasContextClass($classname)) { + return self::$environment->getContext($classname); } $suitename = self::$environment->getSuite()->getName(); @@ -121,6 +117,16 @@ class behat_context_helper { return self::$environment->getContext($classname); } + /** + * Return whether there is a context of the specified classnme. + * + * @param string $classname + * @return bool + */ + public static function has_context(string $classname): bool { + return self::$environment->hasContextClass($classname); + } + /** * Translates string to XPath literal. * diff --git a/lib/behat/classes/behat_selectors.php b/lib/behat/classes/behat_selectors.php index f093e759d94..e90ec563c1e 100644 --- a/lib/behat/classes/behat_selectors.php +++ b/lib/behat/classes/behat_selectors.php @@ -23,6 +23,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +require_once(__DIR__ . '/named_selector.php'); require_once(__DIR__ . '/exact_named_selector.php'); require_once(__DIR__ . '/partial_named_selector.php'); diff --git a/lib/behat/classes/component_named_replacement.php b/lib/behat/classes/component_named_replacement.php new file mode 100644 index 00000000000..a771c32749d --- /dev/null +++ b/lib/behat/classes/component_named_replacement.php @@ -0,0 +1,70 @@ +. + +/** + * Moodle-specific Mink replacements. + * + * @package core + * @category test + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Moodle-specific Mink replacements. + * + * @package core + * @category test + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_component_named_replacement { + /** @var string */ + protected $from; + + /** @var string */ + protected $to; + + /** + * Create the replacement. + * + * @param string $from + * @param string $to + */ + public function __construct(string $from, string $to) { + $this->from = $from; + $this->to = $to; + } + + /** + * Get the 'from' part of the replacement, formatted for the component. + * + * @param string $component + * @return string + */ + public function get_from(string $component): string { + return "%{$component}/{$this->from}%"; + } + + /** + * Get the 'to' part of the replacement. + * + * @return string Target xpath + */ + public function get_to(): string { + return $this->to; + } +} diff --git a/lib/behat/classes/component_named_selector.php b/lib/behat/classes/component_named_selector.php new file mode 100644 index 00000000000..70820ac88b1 --- /dev/null +++ b/lib/behat/classes/component_named_selector.php @@ -0,0 +1,96 @@ +. + +/** + * Moodle-specific selectors. + * + * @package core + * @category test + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Moodle-specific selectors. + * + * @package core + * @category test + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_component_named_selector { + /** @var string */ + protected $alias; + + /** @var array List of xpaths */ + protected $xpaths; + + /** @var string */ + protected $istextselector; + + /** + * Create the selector definition. + * + * @param string $alias The 'friendly' name of the thing. This will be prefixed with the component name. + * @param array $xpaths A list of xpaths + * @param bool $istextselector Whether this selector can also be used as a text selector. + */ + public function __construct(string $alias, array $xpaths, bool $istextselector = true) { + $this->alias = $alias; + $this->xpaths = $xpaths; + $this->istextselector = $istextselector; + } + + /** + * Whether this is a text selector. + * + * @return bool + */ + public function is_text_selector(): bool { + return $this->istextselector; + } + + /** + * Get the name of the selector. + * This is a back-end feature and contains a namespaced md5 of the human-readable name. + * + * @param string $component + * @return string + */ + public function get_name(string $component): string { + return implode('_', [$component, md5($this->alias)]); + } + + /** + * Get the alias of the selector. + * This is the human-readable name that you would typically interact with. + * + * @param string $component + * @return string + */ + public function get_alias(string $component): string { + return implode(" > ", [$component, $this->alias]);; + } + + /** + * Get the list of combined xpaths. + * + * @return string The list of xpaths combined with the xpath | (OR) operator + */ + public function get_combined_xpath(): string { + return implode(' | ', $this->xpaths); + } +} diff --git a/lib/behat/classes/exact_named_selector.php b/lib/behat/classes/exact_named_selector.php index 4902075c275..e178daed7bd 100644 --- a/lib/behat/classes/exact_named_selector.php +++ b/lib/behat/classes/exact_named_selector.php @@ -32,6 +32,9 @@ */ class behat_exact_named_selector extends \Behat\Mink\Selector\ExactNamedSelector { + // Use the named selector trait. + use behat_named_selector; + /** * Creates selector instance. */ @@ -63,6 +66,9 @@ class behat_exact_named_selector extends \Behat\Mink\Selector\ExactNamedSelector 'text_exact' => 'text', ); + /** @var List of deprecated selectors */ + protected static $deprecatedselectors = []; + /** * Allowed selectors getter. * diff --git a/lib/behat/classes/named_selector.php b/lib/behat/classes/named_selector.php new file mode 100644 index 00000000000..087b6ec1161 --- /dev/null +++ b/lib/behat/classes/named_selector.php @@ -0,0 +1,107 @@ +. + +/** + * Moodle-specific common functions for named selectors. + * + * @package core + * @category test + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Common functions for named selectors. + * + * @package core + * @category test + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +trait behat_named_selector { + + /** + * Registers new XPath selector with specified name. + * + * @param string $component + * @param behat_component_named_selector $selector + */ + public function register_component_selector(string $component, behat_component_named_selector $selector) { + $alias = $selector->get_alias($component); + $name = $selector->get_name($component); + static::$allowedselectors[$alias] = $name; + + if ($selector->is_text_selector()) { + static::$allowedtextselectors[$alias] = $name; + } + + // We must use Reflection here. The replacements property is private and cannot be accessed otherwise. + // This is due to an API limitation in Mink. + $rc = new \ReflectionClass(\Behat\Mink\Selector\NamedSelector::class); + $r = $rc->getProperty('replacements'); + $r->setAccessible(true); + $replacements = $r->getValue($this); + + $selectorxpath = strtr($selector->get_combined_xpath(), $replacements); + + parent::registerNamedXpath($name, $selectorxpath); + } + + /** + * Registers new XPath selector with specified name. + * + * @param string $component + * @param behat_component_named_replacement $replacement + */ + public function register_replacement(string $component, behat_component_named_replacement $replacement) { + // We must use Reflection here. The replacements property is private and cannot be accessed otherwise. + // This is due to an API limitation in Mink. + $rc = new \ReflectionClass(\Behat\Mink\Selector\NamedSelector::class); + $r = $rc->getProperty('replacements'); + $r->setAccessible(true); + $existing = $r->getValue($this); + + $from = $replacement->get_from($component); + + if (isset($existing[$from])) { + throw new \coding_exception("A named replacement already exists in the partial named selector for '{$from}'. " . + "Replacement names must be unique, and should be namespaced to the component"); + } + + $translatedto = strtr($replacement->get_to(), $existing); + $this->registerReplacement($from, $translatedto); + } + + /** + * Check whether the specified selector has been deprecated and marked for replacement. + * + * @param string $selector + * @return bool + */ + public static function is_deprecated_selector(string $selector): bool { + return array_key_exists($selector, static::$deprecatedselectors); + } + + /** + * Fetch the replacement name of a deprecated selector. + * + * @param string $selector + * @return bool + */ + public static function get_deprecated_replacement(string $selector): ?string { + return static::$deprecatedselectors[$selector]; + } +} diff --git a/lib/behat/classes/partial_named_selector.php b/lib/behat/classes/partial_named_selector.php index 8503da8eaf7..9823226ec47 100644 --- a/lib/behat/classes/partial_named_selector.php +++ b/lib/behat/classes/partial_named_selector.php @@ -33,6 +33,9 @@ */ class behat_partial_named_selector extends \Behat\Mink\Selector\PartialNamedSelector { + // Use the named selector trait. + use behat_named_selector; + /** * Creates selector instance. */ @@ -271,6 +274,10 @@ XPATH ], ]; + /** @var List of deprecated selectors */ + protected static $deprecatedselectors = [ + ]; + /** * Allowed selectors getter. * diff --git a/lib/tests/behat/behat_hooks.php b/lib/tests/behat/behat_hooks.php index 46b3c87afe1..bbf18b7f13b 100644 --- a/lib/tests/behat/behat_hooks.php +++ b/lib/tests/behat/behat_hooks.php @@ -396,7 +396,7 @@ class behat_hooks extends behat_base { * Yes, this is in a strange location and should be in the BeforeScenario hook, but failures in the test setUp lead * to the test being incorrectly marked as skipped with no way to force the test to be failed. * - * @param BeforeStepScope $scope + * @param BeforeStepScope $scope * @BeforeStep */ public function before_step(BeforeStepScope $scope) { @@ -425,7 +425,6 @@ class behat_hooks extends behat_base { new ExpectationException($message, $session) ); - self::$initprocessesfinished = true; } $this->scenariorunning = true; } @@ -709,6 +708,70 @@ class behat_hooks extends behat_base { protected static function is_first_scenario() { return !(self::$initprocessesfinished); } + + /** + * Register component selectors. + * + * @param BeforeScenarioScope $scope scope passed by event fired before scenario. + * @BeforeScenario + */ + public function register_component_selectors(BeforeScenarioScope $scope) { + foreach (\core_component::get_component_names() as $component) { + $this->register_component_selectors_for_component($component); + } + } + + /** + * Register a set of component selectors. + * + * @param string $component + */ + public function register_component_selectors_for_component(string $component): void { + $componentclassname = "behat_{$component}"; + + if (!behat_context_helper::has_context($componentclassname)) { + if ("core_" === substr($component, 0, 5)) { + $componentclassname = "behat_" . substr($component, 5); + if (!behat_context_helper::has_context($componentclassname)) { + return; + } + } else { + return; + } + } + + $context = behat_context_helper::get($componentclassname); + $namedpartial = $this->getSession()->getSelectorsHandler()->getSelector('named_partial'); + $namedexact = $this->getSession()->getSelectorsHandler()->getSelector('named_exact'); + + // Replacements must come before selectors as they are used in the selectors. + foreach ($context->get_named_replacements() as $replacement) { + $namedpartial->register_replacement($component, $replacement); + $namedexact->register_replacement($component, $replacement); + } + + foreach ($context->get_partial_named_selectors() as $selector) { + $namedpartial->register_component_selector($component, $selector); + } + + foreach ($context->get_exact_named_selectors() as $selector) { + $namedexact->register_component_selector($component, $selector); + } + + } + + /** + * Mark the first step as having been completed. + * + * This must be the last BeforeStep hook in the setup. + * + * @param BeforeStepScope $scope + * @BeforeStep + */ + public function first_step_setup_complete(BeforeStepScope $scope) { + self::$initprocessesfinished = true; + } + } /**