From 7ff9cbf0ffe5ad5e48e2610e9924d0f20f474061 Mon Sep 17 00:00:00 2001 From: Lauri Eskola <lauri.eskola@acquia.com> Date: Fri, 19 Nov 2021 16:53:59 +0200 Subject: [PATCH] Issue #3032275 by alexpott, dww, bendeguz.csirmaz, tedbow: Create a fault-tolerant method for interacting with links and fields in Javascript tests --- .../BlockFormMessagesTest.php | 44 ++-------- .../LayoutBuilderDisableInteractionsTest.php | 4 +- .../js-interaction-test-blocker-element.css | 13 +++ .../js_interaction_test.trigger_link.es6.js | 38 ++++++++ .../js/js_interaction_test.trigger_link.js | 28 ++++++ .../js_interaction_test.info.yml | 5 ++ .../js_interaction_test.libraries.yml | 10 +++ .../js_interaction_test.routing.yml | 6 ++ .../src/Controller/JSInteractionTestForm.php | 88 +++++++++++++++++++ .../DrupalSelenium2Driver.php | 80 +++++++++++++++++ .../FunctionalJavascriptTests/JSWebAssert.php | 17 ++++ .../Tests/JSInteractionTest.php | 60 +++++++++++++ 12 files changed, 353 insertions(+), 40 deletions(-) create mode 100644 core/modules/system/tests/modules/js_interaction_test/css/js-interaction-test-blocker-element.css create mode 100644 core/modules/system/tests/modules/js_interaction_test/js/js_interaction_test.trigger_link.es6.js create mode 100644 core/modules/system/tests/modules/js_interaction_test/js/js_interaction_test.trigger_link.js create mode 100644 core/modules/system/tests/modules/js_interaction_test/js_interaction_test.info.yml create mode 100644 core/modules/system/tests/modules/js_interaction_test/js_interaction_test.libraries.yml create mode 100644 core/modules/system/tests/modules/js_interaction_test/js_interaction_test.routing.yml create mode 100644 core/modules/system/tests/modules/js_interaction_test/src/Controller/JSInteractionTestForm.php create mode 100644 core/tests/Drupal/FunctionalJavascriptTests/Tests/JSInteractionTest.php diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/BlockFormMessagesTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/BlockFormMessagesTest.php index 4fccd1c8427e..82ed4024997b 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/BlockFormMessagesTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/BlockFormMessagesTest.php @@ -2,10 +2,8 @@ namespace Drupal\Tests\layout_builder\FunctionalJavascript; -use Behat\Mink\Element\NodeElement; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; -use WebDriver\Exception\UnknownError; /** * Tests that messages appear in the off-canvas dialog with configuring blocks. @@ -59,14 +57,14 @@ public function testValidationMessage() { // Enable layout builder. $this->drupalGet($field_ui_prefix . '/display/default'); $this->submitForm(['layout[enabled]' => TRUE], 'Save'); - $this->clickElementWhenClickable($page->findLink('Manage layout')); + $page->findLink('Manage layout')->click(); $assert_session->addressEquals($field_ui_prefix . '/display/default/layout'); - $this->clickElementWhenClickable($page->findLink('Add block')); + $page->findLink('Add block')->click(); $this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-off-canvas .block-categories')); - $this->clickElementWhenClickable($page->findLink('Powered by Drupal')); + $page->findLink('Powered by Drupal')->click(); $this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-off-canvas [name="settings[label]"]')); $page->findField('Title')->setValue(''); - $this->clickElementWhenClickable($page->findButton('Add block')); + $page->findButton('Add block')->click(); $this->assertMessagesDisplayed(); $page->findField('Title')->setValue('New title'); $page->pressButton('Add block'); @@ -76,7 +74,7 @@ public function testValidationMessage() { $assert_session->assertNoElementAfterWait('css', '#drupal-off-canvas'); $assert_session->assertWaitOnAjaxRequest(); $this->drupalGet($this->getUrl()); - $this->clickElementWhenClickable($page->findButton('Save layout')); + $page->findButton('Save layout')->click(); $this->assertNotEmpty($assert_session->waitForElement('css', 'div:contains("The layout has been saved")')); // Ensure that message are displayed when configuring an existing block. @@ -85,7 +83,7 @@ public function testValidationMessage() { $this->clickContextualLink($block_css_locator, 'Configure', TRUE); $this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-off-canvas [name="settings[label]"]')); $page->findField('Title')->setValue(''); - $this->clickElementWhenClickable($page->findButton('Update')); + $page->findButton('Update')->click(); $this->assertMessagesDisplayed(); } @@ -106,34 +104,4 @@ protected function assertMessagesDisplayed() { $this->assertGreaterThan(4, count($top_form_elements)); } - /** - * Attempts to click an element until it is in a clickable state. - * - * @param \Behat\Mink\Element\NodeElement $element - * The element to click. - * @param int $timeout - * (Optional) Timeout in milliseconds, defaults to 10000. - * - * @todo Replace this method with general solution for random click() test - * failures in https://www.drupal.org/node/3032275. - */ - protected function clickElementWhenClickable(NodeElement $element, $timeout = 10000) { - $page = $this->getSession()->getPage(); - - $result = $page->waitFor($timeout / 1000, function () use ($element) { - try { - $element->click(); - return TRUE; - } - catch (UnknownError $exception) { - if (strstr($exception->getMessage(), 'not clickable') === FALSE) { - // Rethrow any unexpected UnknownError exceptions. - throw $exception; - } - return NULL; - } - }); - $this->assertTrue($result); - } - } diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php index 773715c932e3..dcdc7be6cc99 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php @@ -6,6 +6,7 @@ use Drupal\block_content\Entity\BlockContent; use Drupal\block_content\Entity\BlockContentType; use Drupal\Component\Render\FormattableMarkup; +use Drupal\FunctionalJavascriptTests\JSWebAssert; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; @@ -181,8 +182,7 @@ protected function assertElementUnclickable(NodeElement $element) { $this->fail(new FormattableMarkup("@tag_name was clickable when it shouldn't have been", ['@tag_name' => $tag_name])); } catch (\Exception $e) { - // cspell:ignore interactable - $this->assertMatchesRegularExpression('/(is not clickable at point|element not interactable)/', $e->getMessage()); + $this->assertTrue(JSWebAssert::isExceptionNotClickable($e)); } } diff --git a/core/modules/system/tests/modules/js_interaction_test/css/js-interaction-test-blocker-element.css b/core/modules/system/tests/modules/js_interaction_test/css/js-interaction-test-blocker-element.css new file mode 100644 index 000000000000..ac0e3e4e3845 --- /dev/null +++ b/core/modules/system/tests/modules/js_interaction_test/css/js-interaction-test-blocker-element.css @@ -0,0 +1,13 @@ +.blocker-element { + /* Position the box over the target. */ + position: relative; + z-index: 1; + top: -30px; + left: -5px; + /* Size the box to cover the target. */ + width: 500px; + height: 40px; + opacity: 0.5; + /* Make the blocker element visible. */ + background-color: black; +} diff --git a/core/modules/system/tests/modules/js_interaction_test/js/js_interaction_test.trigger_link.es6.js b/core/modules/system/tests/modules/js_interaction_test/js/js_interaction_test.trigger_link.es6.js new file mode 100644 index 000000000000..bd47c33c00b6 --- /dev/null +++ b/core/modules/system/tests/modules/js_interaction_test/js/js_interaction_test.trigger_link.es6.js @@ -0,0 +1,38 @@ +/** + * @file + * Testing behavior for JSInteractionTest. + */ + +(({ behaviors }) => { + /** + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the click listener on the trigger link. + */ + behaviors.js_interaction_test_trigger_link = { + attach() { + const removeBlockerTrigger = once( + 'remove-blocker-trigger', + '.remove-blocker-trigger', + ).shift(); + removeBlockerTrigger.addEventListener('click', (event) => { + event.preventDefault(); + setTimeout(() => { + document.querySelector('.blocker-element').remove(); + }, 100); + }); + + const enableFieldTrigger = once( + 'enable-field-trigger', + '.enable-field-trigger', + ).shift(); + enableFieldTrigger.addEventListener('click', (event) => { + event.preventDefault(); + setTimeout(() => { + document.querySelector('input[name="target_field"]').disabled = false; + }, 100); + }); + }, + }; +})(Drupal); diff --git a/core/modules/system/tests/modules/js_interaction_test/js/js_interaction_test.trigger_link.js b/core/modules/system/tests/modules/js_interaction_test/js/js_interaction_test.trigger_link.js new file mode 100644 index 000000000000..c6b1618bb300 --- /dev/null +++ b/core/modules/system/tests/modules/js_interaction_test/js/js_interaction_test.trigger_link.js @@ -0,0 +1,28 @@ +/** +* DO NOT EDIT THIS FILE. +* See the following change record for more information, +* https://www.drupal.org/node/2815083 +* @preserve +**/ + +(function (_ref) { + var behaviors = _ref.behaviors; + behaviors.js_interaction_test_trigger_link = { + attach: function attach() { + var removeBlockerTrigger = once('remove-blocker-trigger', '.remove-blocker-trigger').shift(); + removeBlockerTrigger.addEventListener('click', function (event) { + event.preventDefault(); + setTimeout(function () { + document.querySelector('.blocker-element').remove(); + }, 100); + }); + var enableFieldTrigger = once('enable-field-trigger', '.enable-field-trigger').shift(); + enableFieldTrigger.addEventListener('click', function (event) { + event.preventDefault(); + setTimeout(function () { + document.querySelector('input[name="target_field"]').disabled = false; + }, 100); + }); + } + }; +})(Drupal); \ No newline at end of file diff --git a/core/modules/system/tests/modules/js_interaction_test/js_interaction_test.info.yml b/core/modules/system/tests/modules/js_interaction_test/js_interaction_test.info.yml new file mode 100644 index 000000000000..406dee9d6258 --- /dev/null +++ b/core/modules/system/tests/modules/js_interaction_test/js_interaction_test.info.yml @@ -0,0 +1,5 @@ +name: 'JS Interaction Test' +type: module +description: 'Module for testing fault-tolerant interactions in JavaScript tests.' +package: Testing +version: VERSION diff --git a/core/modules/system/tests/modules/js_interaction_test/js_interaction_test.libraries.yml b/core/modules/system/tests/modules/js_interaction_test/js_interaction_test.libraries.yml new file mode 100644 index 000000000000..c6d526a36f50 --- /dev/null +++ b/core/modules/system/tests/modules/js_interaction_test/js_interaction_test.libraries.yml @@ -0,0 +1,10 @@ +js_interaction_test: + version: VERSION + js: + js/js_interaction_test.trigger_link.js: {} + css: + theme: + css/js-interaction-test-blocker-element.css: {} + dependencies: + - core/drupal + - core/once diff --git a/core/modules/system/tests/modules/js_interaction_test/js_interaction_test.routing.yml b/core/modules/system/tests/modules/js_interaction_test/js_interaction_test.routing.yml new file mode 100644 index 000000000000..36f7c119c494 --- /dev/null +++ b/core/modules/system/tests/modules/js_interaction_test/js_interaction_test.routing.yml @@ -0,0 +1,6 @@ +js_interaction_test.js_interaction_test: + path: '/js_interaction_test' + defaults: + _form: '\Drupal\js_interaction_test\Controller\JSInteractionTestForm' + requirements: + _access: 'TRUE' diff --git a/core/modules/system/tests/modules/js_interaction_test/src/Controller/JSInteractionTestForm.php b/core/modules/system/tests/modules/js_interaction_test/src/Controller/JSInteractionTestForm.php new file mode 100644 index 000000000000..1439ec16b4ed --- /dev/null +++ b/core/modules/system/tests/modules/js_interaction_test/src/Controller/JSInteractionTestForm.php @@ -0,0 +1,88 @@ +<?php + +namespace Drupal\js_interaction_test\Controller; + +use Drupal\Core\Form\FormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Url; + +/** + * Controller for testing fault tolerant JavaScript interactions. + */ +class JSInteractionTestForm extends FormBase { + + /** + * @inheritDoc + */ + public function getFormId() { + return __CLASS__; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + // No-op. + } + + /** + * Creates the test form. + * + * The form provides: + * - A link that is obstructed (blocked) by another element. + * - A link that, when clicked, removes the blocking element after some time. + * - A field that is disabled. + * - A link that, when clicked, enables the field after some time. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return array + * The form structure. + */ + public function buildForm(array $form, FormStateInterface $form_state) { + return [ + 'target_link' => [ + '#type' => 'link', + '#url' => Url::fromRoute('<current>'), + '#title' => $this->t('Target link'), + ], + 'blocker_element' => [ + '#type' => 'html_tag', + '#tag' => 'div', + '#attributes' => [ + 'class' => ['blocker-element'], + ], + ], + 'remove_blocker_trigger' => [ + '#type' => 'link', + '#url' => Url::fromRoute('<current>'), + '#title' => $this->t('Remove Blocker Trigger'), + '#attributes' => [ + 'class' => ['remove-blocker-trigger'], + ], + ], + 'target_field' => [ + '#type' => 'textfield', + '#maxlength' => 20, + '#disabled' => TRUE, + ], + 'enable_field_trigger' => [ + '#type' => 'link', + '#url' => Url::fromRoute('<current>'), + '#title' => $this->t('Enable Field Trigger'), + '#attributes' => [ + 'class' => ['enable-field-trigger'], + ], + ], + '#attached' => [ + 'library' => [ + 'js_interaction_test/js_interaction_test', + ], + ], + ]; + } + +} diff --git a/core/tests/Drupal/FunctionalJavascriptTests/DrupalSelenium2Driver.php b/core/tests/Drupal/FunctionalJavascriptTests/DrupalSelenium2Driver.php index 76d1416056a9..a8110451d82f 100644 --- a/core/tests/Drupal/FunctionalJavascriptTests/DrupalSelenium2Driver.php +++ b/core/tests/Drupal/FunctionalJavascriptTests/DrupalSelenium2Driver.php @@ -4,6 +4,7 @@ use Behat\Mink\Driver\Selenium2Driver; use Behat\Mink\Exception\DriverException; +use WebDriver\Exception; use WebDriver\Exception\UnknownError; use WebDriver\ServiceFactory; @@ -134,4 +135,83 @@ public function uploadFileAndGetRemoteFilePath($path) { return $remotePath; } + /** + * {@inheritdoc} + */ + public function click($xpath) { + /** @var \Exception $not_clickable_exception */ + $not_clickable_exception = NULL; + $result = $this->waitFor(10, function () use (&$not_clickable_exception, $xpath) { + try { + parent::click($xpath); + return TRUE; + } + catch (Exception $exception) { + if (!JSWebAssert::isExceptionNotClickable($exception)) { + // Rethrow any unexpected exceptions. + throw $exception; + } + $not_clickable_exception = $exception; + return NULL; + } + }); + if ($result !== TRUE) { + throw $not_clickable_exception; + } + } + + /** + * {@inheritdoc} + */ + public function setValue($xpath, $value) { + /** @var \Exception $not_clickable_exception */ + $not_clickable_exception = NULL; + $result = $this->waitFor(10, function () use (&$not_clickable_exception, $xpath, $value) { + try { + parent::setValue($xpath, $value); + return TRUE; + } + catch (Exception $exception) { + if (!JSWebAssert::isExceptionNotClickable($exception) && !str_contains($exception->getMessage(), 'invalid element state')) { + // Rethrow any unexpected exceptions. + throw $exception; + } + $not_clickable_exception = $exception; + return NULL; + } + }); + if ($result !== TRUE) { + throw $not_clickable_exception; + } + } + + /** + * Waits for a callback to return a truthy result and returns it. + * + * @param int|float $timeout + * Maximal allowed waiting time in seconds. + * @param callable $callback + * Callback, which result is both used as waiting condition and returned. + * Will receive reference to `this driver` as first argument. + * + * @return mixed + * The result of the callback. + */ + private function waitFor($timeout, callable $callback) { + $start = microtime(TRUE); + $end = $start + $timeout; + + do { + $result = call_user_func($callback, $this); + + if ($result) { + break; + } + + usleep(10000); + } while (microtime(TRUE) < $end); + + return $result; + } + } diff --git a/core/tests/Drupal/FunctionalJavascriptTests/JSWebAssert.php b/core/tests/Drupal/FunctionalJavascriptTests/JSWebAssert.php index 3cbb7f1ae39b..52b48aaf0b00 100644 --- a/core/tests/Drupal/FunctionalJavascriptTests/JSWebAssert.php +++ b/core/tests/Drupal/FunctionalJavascriptTests/JSWebAssert.php @@ -8,8 +8,11 @@ use Behat\Mink\Exception\ElementNotFoundException; use Behat\Mink\Exception\UnsupportedDriverActionException; use Drupal\Tests\WebAssert; +use WebDriver\Exception; use WebDriver\Exception\CurlExec; +// cspell:ignore interactable + /** * Defines a class with methods for asserting presence of elements during tests. */ @@ -506,4 +509,18 @@ public function assertNoElementAfterWait($selector_type, $selector, $timeout = 1 throw new ElementHtmlException($message, $this->session->getDriver(), $node); } + /** + * Determines if an exception is due to an element not being clickable. + * + * @param \WebDriver\Exception $exception + * The exception to check. + * + * @return bool + * TRUE if the exception is due to an element not being clickable, + * interactable or visible. + */ + public static function isExceptionNotClickable(Exception $exception): bool { + return (bool) preg_match('/not (clickable|interactable|visible)/', $exception->getMessage()); + } + } diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Tests/JSInteractionTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Tests/JSInteractionTest.php new file mode 100644 index 000000000000..80d2286a6df0 --- /dev/null +++ b/core/tests/Drupal/FunctionalJavascriptTests/Tests/JSInteractionTest.php @@ -0,0 +1,60 @@ +<?php + +namespace Drupal\FunctionalJavascriptTests\Tests; + +use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use WebDriver\Exception; + +/** + * Tests fault tolerant interactions. + * + * @group javascript + */ +class JSInteractionTest extends WebDriverTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'js_interaction_test', + ]; + + /** + * Assert an exception is thrown when the blocker element is never removed. + */ + public function testNotClickable() { + $this->expectException(Exception::class); + $this->drupalGet('/js_interaction_test'); + $this->assertSession()->elementExists('named', ['link', 'Target link'])->click(); + } + + /** + * Assert an exception is thrown when the field is never enabled. + */ + public function testFieldValueNotSettable() { + $this->expectException(Exception::class); + $this->drupalGet('/js_interaction_test'); + $this->assertSession()->fieldExists('target_field')->setValue('Test'); + } + + /** + * Assert no exception is thrown when elements become interactive. + */ + public function testElementsInteraction() { + $this->drupalGet('/js_interaction_test'); + // Remove blocking element after 100 ms. + $this->clickLink('Remove Blocker Trigger'); + $this->clickLink('Target link'); + + // Enable field after 100 ms. + $this->clickLink('Enable Field Trigger'); + $this->assertSession()->fieldExists('target_field')->setValue('Test'); + $this->assertSession()->fieldValueEquals('target_field', 'Test'); + } + +} -- GitLab