Skip to content
Snippets Groups Projects
Unverified Commit 7ff9cbf0 authored by Lauri Timmanee's avatar Lauri Timmanee
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
parent d90527ba
No related branches found
No related tags found
No related merge requests found
Showing
with 353 additions and 40 deletions
...@@ -2,10 +2,8 @@ ...@@ -2,10 +2,8 @@
namespace Drupal\Tests\layout_builder\FunctionalJavascript; namespace Drupal\Tests\layout_builder\FunctionalJavascript;
use Behat\Mink\Element\NodeElement;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait;
use WebDriver\Exception\UnknownError;
/** /**
* Tests that messages appear in the off-canvas dialog with configuring blocks. * Tests that messages appear in the off-canvas dialog with configuring blocks.
...@@ -59,14 +57,14 @@ public function testValidationMessage() { ...@@ -59,14 +57,14 @@ public function testValidationMessage() {
// Enable layout builder. // Enable layout builder.
$this->drupalGet($field_ui_prefix . '/display/default'); $this->drupalGet($field_ui_prefix . '/display/default');
$this->submitForm(['layout[enabled]' => TRUE], 'Save'); $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'); $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->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]"]')); $this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-off-canvas [name="settings[label]"]'));
$page->findField('Title')->setValue(''); $page->findField('Title')->setValue('');
$this->clickElementWhenClickable($page->findButton('Add block')); $page->findButton('Add block')->click();
$this->assertMessagesDisplayed(); $this->assertMessagesDisplayed();
$page->findField('Title')->setValue('New title'); $page->findField('Title')->setValue('New title');
$page->pressButton('Add block'); $page->pressButton('Add block');
...@@ -76,7 +74,7 @@ public function testValidationMessage() { ...@@ -76,7 +74,7 @@ public function testValidationMessage() {
$assert_session->assertNoElementAfterWait('css', '#drupal-off-canvas'); $assert_session->assertNoElementAfterWait('css', '#drupal-off-canvas');
$assert_session->assertWaitOnAjaxRequest(); $assert_session->assertWaitOnAjaxRequest();
$this->drupalGet($this->getUrl()); $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")')); $this->assertNotEmpty($assert_session->waitForElement('css', 'div:contains("The layout has been saved")'));
// Ensure that message are displayed when configuring an existing block. // Ensure that message are displayed when configuring an existing block.
...@@ -85,7 +83,7 @@ public function testValidationMessage() { ...@@ -85,7 +83,7 @@ public function testValidationMessage() {
$this->clickContextualLink($block_css_locator, 'Configure', TRUE); $this->clickContextualLink($block_css_locator, 'Configure', TRUE);
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-off-canvas [name="settings[label]"]')); $this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-off-canvas [name="settings[label]"]'));
$page->findField('Title')->setValue(''); $page->findField('Title')->setValue('');
$this->clickElementWhenClickable($page->findButton('Update')); $page->findButton('Update')->click();
$this->assertMessagesDisplayed(); $this->assertMessagesDisplayed();
} }
...@@ -106,34 +104,4 @@ protected function assertMessagesDisplayed() { ...@@ -106,34 +104,4 @@ protected function assertMessagesDisplayed() {
$this->assertGreaterThan(4, count($top_form_elements)); $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 @@ ...@@ -6,6 +6,7 @@
use Drupal\block_content\Entity\BlockContent; use Drupal\block_content\Entity\BlockContent;
use Drupal\block_content\Entity\BlockContentType; use Drupal\block_content\Entity\BlockContentType;
use Drupal\Component\Render\FormattableMarkup; use Drupal\Component\Render\FormattableMarkup;
use Drupal\FunctionalJavascriptTests\JSWebAssert;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait;
...@@ -181,8 +182,7 @@ protected function assertElementUnclickable(NodeElement $element) { ...@@ -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])); $this->fail(new FormattableMarkup("@tag_name was clickable when it shouldn't have been", ['@tag_name' => $tag_name]));
} }
catch (\Exception $e) { catch (\Exception $e) {
// cspell:ignore interactable $this->assertTrue(JSWebAssert::isExceptionNotClickable($e));
$this->assertMatchesRegularExpression('/(is not clickable at point|element not interactable)/', $e->getMessage());
} }
} }
......
.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 @@ ...@@ -4,6 +4,7 @@
use Behat\Mink\Driver\Selenium2Driver; use Behat\Mink\Driver\Selenium2Driver;
use Behat\Mink\Exception\DriverException; use Behat\Mink\Exception\DriverException;
use WebDriver\Exception;
use WebDriver\Exception\UnknownError; use WebDriver\Exception\UnknownError;
use WebDriver\ServiceFactory; use WebDriver\ServiceFactory;
...@@ -134,4 +135,83 @@ public function uploadFileAndGetRemoteFilePath($path) { ...@@ -134,4 +135,83 @@ public function uploadFileAndGetRemoteFilePath($path) {
return $remotePath; 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 @@ ...@@ -8,8 +8,11 @@
use Behat\Mink\Exception\ElementNotFoundException; use Behat\Mink\Exception\ElementNotFoundException;
use Behat\Mink\Exception\UnsupportedDriverActionException; use Behat\Mink\Exception\UnsupportedDriverActionException;
use Drupal\Tests\WebAssert; use Drupal\Tests\WebAssert;
use WebDriver\Exception;
use WebDriver\Exception\CurlExec; use WebDriver\Exception\CurlExec;
// cspell:ignore interactable
/** /**
* Defines a class with methods for asserting presence of elements during tests. * Defines a class with methods for asserting presence of elements during tests.
*/ */
...@@ -506,4 +509,18 @@ public function assertNoElementAfterWait($selector_type, $selector, $timeout = 1 ...@@ -506,4 +509,18 @@ public function assertNoElementAfterWait($selector_type, $selector, $timeout = 1
throw new ElementHtmlException($message, $this->session->getDriver(), $node); 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');
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment