Unverified Commit 88adf9c1 authored by lauriii's avatar lauriii
Browse files

Issue #3032275 by alexpott, dww, bendeguz.csirmaz, tedbow: Create a...

Issue #3032275 by alexpott, dww, bendeguz.csirmaz, tedbow: Create a fault-tolerant method for interacting with links and fields in Javascript tests

(cherry picked from commit 7ff9cbf0)
parent f5154362
......@@ -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();
}
......@@ -108,34 +106,4 @@ protected function assertMessagesDisplayed(): void {
$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);
}
}
......@@ -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;
......@@ -183,8 +184,7 @@ protected function assertElementUnclickable(NodeElement $element): void {
$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));
}
}
......
.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;
}
/**
* @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);
/**
* 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
name: 'JS Interaction Test'
type: module
description: 'Module for testing fault-tolerant interactions in JavaScript tests.'
package: Testing
version: VERSION
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
js_interaction_test.js_interaction_test:
path: '/js_interaction_test'
defaults:
_form: '\Drupal\js_interaction_test\Controller\JSInteractionTestForm'
requirements:
_access: 'TRUE'
<?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',
],
],
];
}
}
......@@ -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;
}
}
......@@ -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());
}
}
<?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');
}
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment