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