first commit

This commit is contained in:
2024-07-15 12:33:27 +02:00
commit ce50ae282b
22084 changed files with 2623791 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Ajax;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests Ajax callbacks on FAPI elements.
*
* @group Ajax
*/
class AjaxCallbacksTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['ajax_forms_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests if Ajax callback works on date element.
*/
public function testDateAjaxCallback(): void {
// Test Ajax callback when date changes.
$this->drupalGet('ajax_forms_test_ajax_element_form');
$this->assertNotEmpty($this->getSession()->getPage()->find('xpath', '//div[@id="ajax_date_value"][text()="No date yet selected"]'));
$this->getSession()->executeScript('jQuery("[data-drupal-selector=edit-date]").val("2016-01-01").trigger("change");');
$this->assertNotEmpty($this->assertSession()->waitForElement('xpath', '//div[@id="ajax_date_value"]/div[text()="2016-01-01"]'));
}
/**
* Tests if Ajax callback works on datetime element.
*/
public function testDateTimeAjaxCallback(): void {
// Test Ajax callback when datetime changes.
$this->drupalGet('ajax_forms_test_ajax_element_form');
$this->assertNotEmpty($this->getSession()->getPage()->find('xpath', '//div[@id="ajax_datetime_value"][text()="No datetime selected."]'));
$this->getSession()->executeScript('jQuery("[data-drupal-selector=edit-datetime-date]").val("2016-01-01");');
$this->getSession()->executeScript('jQuery("[data-drupal-selector=edit-datetime-time]").val("12:00:00").trigger("change");');
$this->assertNotEmpty($this->assertSession()->waitForElement('xpath', '//div[@id="ajax_datetime_value"]/div[text()="2016-01-01 12:00:00"]'));
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Ajax;
use Drupal\Core\Url;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests the usage of form caching for AJAX forms.
*
* @group Ajax
*/
class AjaxFormCacheTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['ajax_test', 'ajax_forms_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests the usage of form cache for AJAX forms.
*/
public function testFormCacheUsage(): void {
/** @var \Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface $key_value_expirable */
$key_value_expirable = \Drupal::service('keyvalue.expirable')->get('form');
$this->drupalLogin($this->rootUser);
// Ensure that the cache is empty.
$this->assertCount(0, $key_value_expirable->getAll());
// Visit an AJAX form that is not cached, 3 times.
$uncached_form_url = Url::fromRoute('ajax_forms_test.commands_form');
$this->drupalGet($uncached_form_url);
$this->drupalGet($uncached_form_url);
$this->drupalGet($uncached_form_url);
// The number of cache entries should not have changed.
$this->assertCount(0, $key_value_expirable->getAll());
}
/**
* Tests AJAX forms in blocks.
*/
public function testBlockForms(): void {
$this->container->get('module_installer')->install(['block', 'search']);
$this->rebuildContainer();
$this->drupalLogin($this->rootUser);
$this->drupalPlaceBlock('search_form_block', ['weight' => -5]);
$this->drupalPlaceBlock('ajax_forms_test_block');
$this->drupalGet('');
$session = $this->getSession();
// Select first option and trigger ajax update.
$session->getPage()->selectFieldOption('edit-test1', 'option1');
// DOM update: The InsertCommand in the AJAX response changes the text
// in the option element to 'Option1!!!'.
$opt1_selector = $this->assertSession()->waitForElement('css', "select[data-drupal-selector='edit-test1'] option:contains('Option 1!!!')");
$this->assertNotEmpty($opt1_selector);
$this->assertTrue($opt1_selector->isSelected());
// Confirm option 3 exists.
$page = $session->getPage();
$opt3_selector = $page->find('xpath', '//select[@data-drupal-selector="edit-test1"]//option[@value="option3"]');
$this->assertNotEmpty($opt3_selector);
// Confirm success message appears after a submit.
$page->findButton('edit-submit')->click();
$this->assertSession()->waitForButton('edit-submit');
$updated_page = $session->getPage();
$updated_page->hasContent('Submission successful.');
}
/**
* Tests AJAX forms on pages with a query string.
*/
public function testQueryString(): void {
$this->container->get('module_installer')->install(['block']);
$this->drupalLogin($this->rootUser);
$this->drupalPlaceBlock('ajax_forms_test_block');
$url = Url::fromRoute('entity.user.canonical', ['user' => $this->rootUser->id()], ['query' => ['foo' => 'bar']]);
$this->drupalGet($url);
$session = $this->getSession();
// Select first option and trigger ajax update.
$session->getPage()->selectFieldOption('edit-test1', 'option1');
// DOM update: The InsertCommand in the AJAX response changes the text
// in the option element to 'Option1!!!'.
$opt1_selector = $this->assertSession()->waitForElement('css', "option:contains('Option 1!!!')");
$this->assertNotEmpty($opt1_selector);
$url->setOption('query', [
'foo' => 'bar',
]);
$this->assertSession()->addressEquals($url);
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Ajax;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests the Ajax image buttons work with key press events.
*
* @group Ajax
*/
class AjaxFormImageButtonTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['ajax_forms_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests image buttons can be operated with the keyboard ENTER key.
*/
public function testAjaxImageButtonKeypressEnter(): void {
// Get a Field UI manage-display page.
$this->drupalGet('ajax_forms_image_button_form');
$assertSession = $this->assertSession();
$session = $this->getSession();
$button = $session->getPage()->findButton('Edit');
$button->keyPress(13);
$this->assertNotEmpty($assertSession->waitForElementVisible('css', '#ajax-1-more-div'), 'Page updated after image button pressed');
}
/**
* Tests image buttons can be operated with the keyboard SPACE key.
*/
public function testAjaxImageButtonKeypressSpace(): void {
// Get a Field UI manage-display page.
$this->drupalGet('ajax_forms_image_button_form');
$assertSession = $this->assertSession();
$session = $this->getSession();
$button = $session->getPage()->findButton('Edit');
$button->keyPress(32);
$this->assertNotEmpty($assertSession->waitForElementVisible('css', '#ajax-1-more-div'), 'Page updated after image button pressed');
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Ajax;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Performs tests on AJAX forms in cached pages.
*
* @group Ajax
*/
class AjaxFormPageCacheTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['ajax_test', 'ajax_forms_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$config = $this->config('system.performance');
$config->set('cache.page.max_age', 300);
$config->save();
}
/**
* Return the build id of the current form.
*/
protected function getFormBuildId() {
// Ensure the hidden 'form_build_id' field is unique.
$this->assertSession()->elementsCount('xpath', '//input[@name="form_build_id"]', 1);
return $this->assertSession()->hiddenFieldExists('form_build_id')->getValue();
}
/**
* Create a simple form, then submit the form via AJAX to change to it.
*/
public function testSimpleAJAXFormValue(): void {
$this->drupalGet('ajax_forms_test_get_form');
$build_id_initial = $this->getFormBuildId();
// Changing the value of a select input element, triggers an AJAX
// request/response. The callback on the form responds with three AJAX
// commands:
// - UpdateBuildIdCommand
// - HtmlCommand
// - DataCommand
$session = $this->getSession();
$session->getPage()->selectFieldOption('select', 'green');
// Wait for the DOM to update. The HtmlCommand will update
// #ajax_selected_color to reflect the color change.
$green_span = $this->assertSession()->waitForElement('css', "#ajax_selected_color:contains('green')");
$this->assertNotNull($green_span, 'DOM update: The selected color SPAN is green.');
// Confirm the operation of the UpdateBuildIdCommand.
$build_id_first_ajax = $this->getFormBuildId();
$this->assertNotEquals($build_id_initial, $build_id_first_ajax, 'Build id is changed in the form_build_id element on first AJAX submission');
// Changing the value of a select input element, triggers an AJAX
// request/response.
$session->getPage()->selectFieldOption('select', 'red');
// Wait for the DOM to update.
$red_span = $this->assertSession()->waitForElement('css', "#ajax_selected_color:contains('red')");
$this->assertNotNull($red_span, 'DOM update: The selected color SPAN is red.');
// Confirm the operation of the UpdateBuildIdCommand.
$build_id_second_ajax = $this->getFormBuildId();
$this->assertNotEquals($build_id_first_ajax, $build_id_second_ajax, 'Build id changes on subsequent AJAX submissions');
// Emulate a push of the reload button and then repeat the test sequence
// this time with a page loaded from the cache.
$session->reload();
$build_id_from_cache_initial = $this->getFormBuildId();
$this->assertEquals($build_id_initial, $build_id_from_cache_initial, 'Build id is the same as on the first request');
// Changing the value of a select input element, triggers an AJAX
// request/response.
$session->getPage()->selectFieldOption('select', 'green');
// Wait for the DOM to update.
$green_span2 = $this->assertSession()->waitForElement('css', "#ajax_selected_color:contains('green')");
$this->assertNotNull($green_span2, 'DOM update: After reload - the selected color SPAN is green.');
$build_id_from_cache_first_ajax = $this->getFormBuildId();
$this->assertNotEquals($build_id_from_cache_initial, $build_id_from_cache_first_ajax, 'Build id is changed in the DOM on first AJAX submission');
$this->assertNotEquals($build_id_first_ajax, $build_id_from_cache_first_ajax, 'Build id from first user is not reused');
// Changing the value of a select input element, triggers an AJAX
// request/response.
$session->getPage()->selectFieldOption('select', 'red');
// Wait for the DOM to update.
$red_span2 = $this->assertSession()->waitForElement('css', "#ajax_selected_color:contains('red')");
$this->assertNotNull($red_span2, 'DOM update: After reload - the selected color SPAN is red.');
$build_id_from_cache_second_ajax = $this->getFormBuildId();
$this->assertNotEquals($build_id_from_cache_first_ajax, $build_id_from_cache_second_ajax, 'Build id changes on subsequent AJAX submissions');
}
/**
* Tests that updating the text field trigger an AJAX request/response.
*
* @see \Drupal\system\Tests\Ajax\ElementValidationTest::testAjaxElementValidation()
*/
public function testAjaxElementValidation(): void {
$this->drupalGet('ajax_validation_test');
// Changing the value of the textfield will trigger an AJAX
// request/response.
$field = $this->getSession()->getPage()->findField('driver_text');
$field->setValue('some dumb text');
$field->blur();
// When the AJAX command updates the DOM a <ul> unsorted list
// "message__list" structure will appear on the page echoing back the
// "some dumb text" message.
$placeholder = $this->assertSession()->waitForElement('css', "[aria-label='Status message'] > ul > li > em:contains('some dumb text')");
$this->assertNotNull($placeholder, 'Message structure containing input data located.');
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Ajax;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests that form elements in groups work correctly with AJAX.
*
* @group Ajax
*/
class AjaxInGroupTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['ajax_forms_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalLogin($this->drupalCreateUser(['access content']));
}
/**
* Submits forms with select and checkbox elements via Ajax.
*/
public function testSimpleAjaxFormValue(): void {
$this->drupalGet('/ajax_forms_test_get_form');
$assert_session = $this->assertSession();
$assert_session->responseContains('Test group');
$assert_session->responseContains('AJAX checkbox in a group');
$session = $this->getSession();
$checkbox_original = $session->getPage()->findField('checkbox_in_group');
$this->assertNotNull($checkbox_original, 'The checkbox_in_group is on the page.');
$original_id = $checkbox_original->getAttribute('id');
// Triggers an AJAX request/response.
$checkbox_original->check();
// The response contains a new nested "test group" form element, similar
// to the one already in the DOM except for a change in the form build id.
$checkbox_new = $assert_session->waitForElement('xpath', "//input[@name='checkbox_in_group' and not(@id='$original_id')]");
$this->assertNotNull($checkbox_new, 'DOM update: clicking the checkbox refreshed the checkbox_in_group structure');
$assert_session->responseContains('Test group');
$assert_session->responseContains('AJAX checkbox in a group');
$assert_session->responseContains('AJAX checkbox in a nested group');
$assert_session->responseContains('Another AJAX checkbox in a nested group');
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Ajax;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\Tests\field_ui\Traits\FieldUiTestTrait;
use Drupal\Tests\file\Functional\FileFieldCreationTrait;
use Drupal\Tests\TestFileCreationTrait;
/**
* Tests maintenance message during an AJAX call.
*
* @group Ajax
*/
class AjaxMaintenanceModeTest extends WebDriverTestBase {
use FieldUiTestTrait;
use FileFieldCreationTrait;
use TestFileCreationTrait;
/**
* An user with administration permissions.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* {@inheritdoc}
*/
protected static $modules = ['ajax_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->adminUser = $this->drupalCreateUser([
'access administration pages',
'administer site configuration',
'access site in maintenance mode',
]);
$this->drupalLogin($this->adminUser);
}
/**
* Tests maintenance message only appears once on an AJAX call.
*/
public function testAjaxCallMaintenanceMode(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
\Drupal::state()->set('system.maintenance_mode', TRUE);
$this->drupalGet('ajax-test/insert-inline-wrapper');
$assert_session->pageTextContains('Target inline');
$page->clickLink('Link html pre-wrapped-div');
$this->assertSession()->assertWaitOnAjaxRequest();
$this->assertSession()->pageTextContainsOnce('Operating in maintenance mode');
}
}

View File

@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Ajax;
use Drupal\Component\Utility\UrlHelper;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests AJAX responses.
*
* @group Ajax
*/
class AjaxTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['ajax_test', 'ajax_forms_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
public function testAjaxWithAdminRoute(): void {
\Drupal::service('theme_installer')->install(['stable9', 'claro']);
$theme_config = \Drupal::configFactory()->getEditable('system.theme');
$theme_config->set('admin', 'claro');
$theme_config->set('default', 'stable9');
$theme_config->save();
$account = $this->drupalCreateUser(['view the administration theme']);
$this->drupalLogin($account);
// First visit the site directly via the URL. This should render it in the
// admin theme.
$this->drupalGet('admin/ajax-test/theme');
$assert = $this->assertSession();
$assert->pageTextContains('Current theme: claro');
// Now click the modal, which should use the front-end theme.
$this->drupalGet('ajax-test/dialog');
$assert->pageTextNotContains('Current theme: stable9');
$this->clickLink('Link 8 (ajax)');
$assert->assertWaitOnAjaxRequest();
$assert->pageTextContains('Current theme: stable9');
$assert->pageTextNotContains('Current theme: claro');
}
/**
* Tests that AJAX loaded libraries are not retained between requests.
*
* @see https://www.drupal.org/node/2647916
*/
public function testDrupalSettingsCachingRegression(): void {
$this->drupalGet('ajax-test/dialog');
$assert = $this->assertSession();
$session = $this->getSession();
// Insert a fake library into the already loaded library settings.
$fake_library = 'fakeLibrary/fakeLibrary';
$libraries = $session->evaluateScript("drupalSettings.ajaxPageState.libraries");
$libraries = UrlHelper::compressQueryParameter(UrlHelper::uncompressQueryParameter($libraries) . ',' . $fake_library);
$session->evaluateScript("drupalSettings.ajaxPageState.libraries = '$libraries';");
$ajax_page_state = $session->evaluateScript("drupalSettings.ajaxPageState");
$libraries = UrlHelper::uncompressQueryParameter($ajax_page_state['libraries']);
// Test that the fake library is set.
$this->assertStringContainsString($fake_library, $libraries);
// Click on the AJAX link.
$this->clickLink('Link 8 (ajax)');
$assert->assertWaitOnAjaxRequest();
// Test that the fake library is still set after the AJAX call.
$ajax_page_state = $session->evaluateScript("drupalSettings.ajaxPageState");
// Test that the fake library is set.
$this->assertStringContainsString($fake_library, UrlHelper::uncompressQueryParameter($ajax_page_state['libraries']));
// Reload the page, this should reset the loaded libraries and remove the
// fake library.
$this->drupalGet('ajax-test/dialog');
$ajax_page_state = $session->evaluateScript("drupalSettings.ajaxPageState");
$this->assertStringNotContainsString($fake_library, UrlHelper::uncompressQueryParameter($ajax_page_state['libraries']));
// Click on the AJAX link again, and the libraries should still not contain
// the fake library.
$this->clickLink('Link 8 (ajax)');
$assert->assertWaitOnAjaxRequest();
$ajax_page_state = $session->evaluateScript("drupalSettings.ajaxPageState");
$this->assertStringNotContainsString($fake_library, UrlHelper::uncompressQueryParameter($ajax_page_state['libraries']));
}
/**
* Tests that various AJAX responses with DOM elements are correctly inserted.
*
* After inserting DOM elements, Drupal JavaScript behaviors should be
* reattached and all top-level elements of type Node.ELEMENT_NODE need to be
* part of the context.
*/
public function testInsertAjaxResponse(): void {
$render_single_root = [
'pre-wrapped-div' => '<div class="pre-wrapped">pre-wrapped<script> var test;</script></div>',
'pre-wrapped-span' => '<span class="pre-wrapped">pre-wrapped<script> var test;</script></span>',
'pre-wrapped-whitespace' => ' <div class="pre-wrapped-whitespace">pre-wrapped-whitespace</div>' . "\n",
'not-wrapped' => 'not-wrapped',
'comment-string-not-wrapped' => '<!-- COMMENT -->comment-string-not-wrapped',
'comment-not-wrapped' => '<!-- COMMENT --><div class="comment-not-wrapped">comment-not-wrapped</div>',
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect x="0" y="0" height="10" width="10" fill="green"></rect></svg>',
'empty' => '',
];
$render_multiple_root_unwrap = [
'mixed' => ' foo <!-- COMMENT --> foo bar<div class="a class"><p>some string</p></div> additional not wrapped strings, <!-- ANOTHER COMMENT --> <p>final string</p>',
'top-level-only' => '<div>element #1</div><div>element #2</div>',
'top-level-only-pre-whitespace' => ' <div>element #1</div><div>element #2</div> ',
'top-level-only-middle-whitespace-span' => '<span>element #1</span> <span>element #2</span>',
'top-level-only-middle-whitespace-div' => '<div>element #1</div> <div>element #2</div>',
];
// This is temporary behavior for BC reason.
$render_multiple_root_wrapper = [];
foreach ($render_multiple_root_unwrap as $key => $render) {
$render_multiple_root_wrapper["$key--effect"] = '<div>' . $render . '</div>';
}
$expected_renders = array_merge(
$render_single_root,
$render_multiple_root_wrapper,
$render_multiple_root_unwrap
);
// Checking default process of wrapping Ajax content.
foreach ($expected_renders as $render_type => $expected) {
$this->assertInsert($render_type, $expected);
}
// Checking custom ajaxWrapperMultipleRootElements wrapping.
$custom_wrapper_multiple_root = <<<JS
(function($, Drupal){
Drupal.theme.ajaxWrapperMultipleRootElements = function (elements) {
return $('<div class="my-favorite-div"></div>').append(elements);
};
}(jQuery, Drupal));
JS;
$expected = '<div class="my-favorite-div"><span>element #1</span> <span>element #2</span></div>';
$this->assertInsert('top-level-only-middle-whitespace-span--effect', $expected, $custom_wrapper_multiple_root);
// Checking custom ajaxWrapperNewContent wrapping.
$custom_wrapper_new_content = <<<JS
(function($, Drupal){
Drupal.theme.ajaxWrapperNewContent = function (elements) {
return $('<div class="div-wrapper-forever"></div>').append(elements);
};
}(jQuery, Drupal));
JS;
$expected = '<div class="div-wrapper-forever"></div>';
$this->assertInsert('empty', $expected, $custom_wrapper_new_content);
}
/**
* Tests that jQuery's global Ajax events are triggered at the correct time.
*/
public function testGlobalEvents(): void {
$session = $this->getSession();
$assert = $this->assertSession();
$expected_event_order = implode('', ['ajaxSuccess', 'ajaxComplete', 'ajaxStop']);
$this->drupalGet('ajax-test/global-events');
// Ensure that a non-Drupal Ajax request triggers the expected events, in
// the correct order, a single time.
$session->executeScript('jQuery.get(Drupal.url("core/COPYRIGHT.txt"))');
$assert->assertWaitOnAjaxRequest();
$assert->elementTextEquals('css', '#test_global_events_log', $expected_event_order);
$assert->elementTextEquals('css', '#test_global_events_log2', $expected_event_order);
// Ensure that an Ajax request to a Drupal Ajax response, but that was not
// initiated with Drupal.Ajax(), triggers the expected events, in the
// correct order, a single time. We expect $expected_event_order to appear
// twice in each log element, because Drupal Ajax response commands (such
// as the one to clear the log element) are only executed for requests
// initiated with Drupal.Ajax(), and these elements already contain the
// text that was added above.
$session->executeScript('jQuery.get(Drupal.url("ajax-test/global-events/clear-log"))');
$assert->assertWaitOnAjaxRequest();
$assert->elementTextEquals('css', '#test_global_events_log', str_repeat($expected_event_order, 2));
$assert->elementTextEquals('css', '#test_global_events_log2', str_repeat($expected_event_order, 2));
// Ensure that a Drupal Ajax request triggers the expected events, in the
// correct order, a single time.
// - We expect the first log element to list the events exactly once,
// because the Ajax response clears it, and we expect the events to be
// triggered after the commands are executed.
// - We expect the second log element to list the events exactly three
// times, because it already contains the two from the code that was
// already executed above. This additional log element that isn't cleared
// by the response's command ensures that the events weren't triggered
// additional times before the response commands were executed.
$this->click('#test_global_events_drupal_ajax_link');
$assert->assertWaitOnAjaxRequest();
$assert->elementTextEquals('css', '#test_global_events_log', $expected_event_order);
$assert->elementTextEquals('css', '#test_global_events_log2', str_repeat($expected_event_order, 3));
}
/**
* Assert insert.
*
* @param string $render_type
* Render type.
* @param string $expected
* Expected result.
* @param string $script
* Script for additional theming.
*
* @internal
*/
public function assertInsert(string $render_type, string $expected, string $script = ''): void {
// Check insert to block element.
$this->drupalGet('ajax-test/insert-block-wrapper');
$this->getSession()->executeScript($script);
$this->clickLink("Link html $render_type");
$this->assertWaitPageContains('<div class="ajax-target-wrapper"><div id="ajax-target">' . $expected . '</div></div>');
$this->drupalGet('ajax-test/insert-block-wrapper');
$this->getSession()->executeScript($script);
$this->clickLink("Link replaceWith $render_type");
$this->assertWaitPageContains('<div class="ajax-target-wrapper">' . $expected . '</div>');
// Check insert to inline element.
$this->drupalGet('ajax-test/insert-inline-wrapper');
$this->getSession()->executeScript($script);
$this->clickLink("Link html $render_type");
$this->assertWaitPageContains('<div class="ajax-target-wrapper"><span id="ajax-target-inline">' . $expected . '</span></div>');
$this->drupalGet('ajax-test/insert-inline-wrapper');
$this->getSession()->executeScript($script);
$this->clickLink("Link replaceWith $render_type");
$this->assertWaitPageContains('<div class="ajax-target-wrapper">' . $expected . '</div>');
}
/**
* Asserts that page contains an expected value after waiting.
*
* @param string $expected
* A needle text.
*
* @internal
*/
protected function assertWaitPageContains(string $expected): void {
$page = $this->getSession()->getPage();
$this->assertTrue($page->waitFor(10, function () use ($page, $expected) {
// Clear content from empty styles and "processed" classes after effect.
$content = str_replace([' class="processed"', ' processed', ' style=""'], '', $page->getContent());
return stripos($content, $expected) !== FALSE;
}), "Page contains expected value: $expected");
}
/**
* Tests that Ajax errors are visible in the UI.
*/
public function testUiAjaxException(): void {
$themes = [
'olivero',
'claro',
'stark',
];
\Drupal::service('theme_installer')->install($themes);
foreach ($themes as $theme) {
$theme_config = \Drupal::configFactory()->getEditable('system.theme');
$theme_config->set('default', $theme);
$theme_config->save();
\Drupal::service('router.builder')->rebuildIfNeeded();
$this->drupalGet('ajax-test/exception-link');
$page = $this->getSession()->getPage();
// We don't want the test to error out because of an expected Javascript
// console error.
$this->failOnJavascriptConsoleErrors = FALSE;
// Click on the AJAX link.
$this->clickLink('Ajax Exception');
$this->assertSession()
->statusMessageContainsAfterWait("Oops, something went wrong. Check your browser's developer console for more details.", 'error');
if ($theme === 'olivero') {
// Check that the message can be closed.
$this->click('.messages__close');
$this->assertTrue($page->find('css', '.messages--error')
->hasClass('hidden'));
}
}
// This is needed to avoid an unfinished AJAX request error from tearDown()
// because this test intentionally does not complete all AJAX requests.
$this->getSession()->executeScript("delete window.drupalActiveXhrCount");
}
/**
* Tests ajax focus handling.
*/
public function testAjaxFocus(): void {
$this->markTestSkipped("Skipped due to frequent random test failures. See https://www.drupal.org/project/drupal/issues/3396536");
$this->drupalGet('/ajax_forms_test_get_form');
$this->assertNotNull($select = $this->assertSession()->elementExists('css', '#edit-select'));
$select->setValue('green');
$this->assertSession()->assertWaitOnAjaxRequest();
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
$this->assertEquals('edit-select', $has_focus_id);
$this->assertNotNull($checkbox = $this->assertSession()->elementExists('css', '#edit-checkbox'));
$checkbox->check();
$this->assertSession()->assertWaitOnAjaxRequest();
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
$this->assertEquals('edit-checkbox', $has_focus_id);
$this->assertNotNull($textfield1 = $this->assertSession()->elementExists('css', '#edit-textfield'));
$this->assertNotNull($textfield2 = $this->assertSession()->elementExists('css', '#edit-textfield-2'));
$this->assertNotNull($textfield3 = $this->assertSession()->elementExists('css', '#edit-textfield-3'));
// Test textfield with 'blur' event listener.
$textfield1->setValue('Kittens say purr');
$textfield2->focus();
$this->assertSession()->assertWaitOnAjaxRequest();
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
$this->assertEquals('edit-textfield-2', $has_focus_id);
// Test textfield with 'change' event listener with refocus-blur set to
// FALSE.
$textfield2->setValue('Llamas say hi');
$textfield3->focus();
$this->assertSession()->assertWaitOnAjaxRequest();
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
$this->assertEquals('edit-textfield-2', $has_focus_id);
// Test textfield with 'change' event.
$textfield3->focus();
$textfield3->setValue('Wasps buzz');
$textfield3->blur();
$this->assertSession()->assertWaitOnAjaxRequest();
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
$this->assertEquals('edit-textfield-3', $has_focus_id);
}
}

View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Ajax;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Performs tests on AJAX framework commands.
*
* @group Ajax
*/
class CommandsTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'ajax_test', 'ajax_forms_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests the various Ajax Commands.
*/
public function testAjaxCommands(): void {
$session = $this->getSession();
$page = $this->getSession()->getPage();
$form_path = 'ajax_forms_test_ajax_commands_form';
$web_user = $this->drupalCreateUser(['access content']);
$this->drupalLogin($web_user);
$this->drupalGet($form_path);
// Tests the 'add_css' command.
$page->pressButton("AJAX 'add_css' command");
$this->assertWaitPageContains('my/file.css');
$this->assertSession()->elementExists('css', 'link[href="my/file.css"]');
$this->assertSession()->elementExists('css', 'link[href="https://example.com/css?family=Open+Sans"]');
// Tests the 'after' command.
$page->pressButton("AJAX 'After': Click to put something after the div");
$this->assertWaitPageContains('<div id="after_div">Something can be inserted after this</div>This will be placed after');
// Tests the 'alert' command.
$page->pressButton("AJAX 'Alert': Click to alert");
// Wait for the alert to appear.
$page->waitFor(10, function () use ($session) {
try {
$session->getDriver()->getWebDriverSession()->getAlert_text();
return TRUE;
}
catch (\Exception $e) {
return FALSE;
}
});
$alert_text = $this->getSession()->getDriver()->getWebDriverSession()->getAlert_text();
$this->assertEquals('Alert', $alert_text);
$this->getSession()->getDriver()->getWebDriverSession()->accept_alert();
$this->drupalGet($form_path);
$page->pressButton("AJAX 'Announce': Click to announce");
$this->assertWaitPageContains('<div id="drupal-live-announce" class="visually-hidden" aria-live="polite" aria-busy="false">Default announcement.</div>');
$this->drupalGet($form_path);
$page->pressButton("AJAX 'Announce': Click to announce with 'polite' priority");
$this->assertWaitPageContains('<div id="drupal-live-announce" class="visually-hidden" aria-live="polite" aria-busy="false">Polite announcement.</div>');
$this->drupalGet($form_path);
$page->pressButton("AJAX 'Announce': Click to announce with 'assertive' priority");
$this->assertWaitPageContains('<div id="drupal-live-announce" class="visually-hidden" aria-live="assertive" aria-busy="false">Assertive announcement.</div>');
$this->drupalGet($form_path);
$page->pressButton("AJAX 'Announce': Click to announce twice");
$this->assertWaitPageContains('<div id="drupal-live-announce" class="visually-hidden" aria-live="assertive" aria-busy="false">Assertive announcement.' . "\nAnother announcement.</div>");
// Tests the 'append' command.
$page->pressButton("AJAX 'Append': Click to append something");
$this->assertWaitPageContains('<div id="append_div">Append inside this divAppended text</div>');
// Tests the 'before' command.
$page->pressButton("AJAX 'before': Click to put something before the div");
$this->assertWaitPageContains('Before text<div id="before_div">Insert something before this.</div>');
// Tests the 'changed' command.
$page->pressButton("AJAX changed: Click to mark div changed.");
$this->assertWaitPageContains('<div id="changed_div" class="ajax-changed">');
// Tests the 'changed' command using the second argument.
// Refresh page for testing 'changed' command to same element again.
$this->drupalGet($form_path);
$page->pressButton("AJAX changed: Click to mark div changed with asterisk.");
$this->assertWaitPageContains('<div id="changed_div" class="ajax-changed"> <div id="changed_div_mark_this">This div can be marked as changed or not. <abbr class="ajax-changed" title="Changed">*</abbr> </div></div>');
// Tests the 'css' command.
$page->pressButton("Set the '#box' div to be blue.");
$this->assertWaitPageContains('<div id="css_div" style="background-color: blue;">');
// Tests the 'data' command.
$page->pressButton("AJAX data command: Issue command.");
$this->assertTrue($page->waitFor(10, function () use ($session) {
return 'test_value' === $session->evaluateScript('window.jQuery("#data_div").data("test_key")');
}));
// Tests the 'html' command.
$page->pressButton("AJAX html: Replace the HTML in a selector.");
$this->assertWaitPageContains('<div id="html_div">replacement text</div>');
// Tests the 'insert' command.
$page->pressButton("AJAX insert: Let client insert based on #ajax['method'].");
$this->assertWaitPageContains('<div id="insert_div">insert replacement textOriginal contents</div>');
// Tests the 'invoke' command.
$page->pressButton("AJAX invoke command: Invoke addClass() method.");
$this->assertWaitPageContains('<div id="invoke_div" class="error">Original contents</div>');
// Tests the 'prepend' command.
$page->pressButton("AJAX 'prepend': Click to prepend something");
$this->assertWaitPageContains('<div id="prepend_div">prepended textSomething will be prepended to this div. </div>');
// Tests the 'remove' command.
$page->pressButton("AJAX 'remove': Click to remove text");
$this->assertWaitPageContains('<div id="remove_div"></div>');
// Tests the 'restripe' command.
$page->pressButton("AJAX 'restripe' command");
$this->assertWaitPageContains('<tr id="table-first" class="odd"><td>first row</td></tr>');
$this->assertWaitPageContains('<tr class="even"><td>second row</td></tr>');
// Tests the 'settings' command.
$test_settings_command = <<<JS
Drupal.behaviors.testSettingsCommand = {
attach: function (context, settings) {
window.jQuery('body').append('<div class="test-settings-command">' + settings.ajax_forms_test.foo + '</div>');
}
};
JS;
$session->executeScript($test_settings_command);
// @todo Replace after https://www.drupal.org/project/drupal/issues/2616184
$session->executeScript('window.jQuery("#edit-settings-command-example").mousedown();');
$this->assertWaitPageContains('<div class="test-settings-command">42</div>');
}
/**
* Tests the various Ajax Commands with legacy parameters.
* @group legacy
*/
public function testLegacyAjaxCommands(): void {
$session = $this->getSession();
$page = $this->getSession()->getPage();
$form_path = 'ajax_forms_test_ajax_commands_form';
$web_user = $this->drupalCreateUser(['access content']);
$this->drupalLogin($web_user);
$this->drupalGet($form_path);
// Tests the 'add_css' command with legacy string value.
$this->expectDeprecation('Javascript Deprecation: Passing a string to the Drupal.ajax.add_css() method is deprecated in 10.1.0 and is removed from drupal:11.0.0. See https://www.drupal.org/node/3154948.');
$page->pressButton("AJAX 'add_css' legacy command");
$this->assertWaitPageContains('my/file.css');
}
/**
* Asserts that page contains a text after waiting.
*
* @param string $text
* A needle text.
*
* @internal
*/
protected function assertWaitPageContains(string $text): void {
$page = $this->getSession()->getPage();
$page->waitFor(10, function () use ($page, $text) {
return stripos($page->getContent(), $text) !== FALSE;
});
$this->assertStringContainsString($text, $page->getContent());
}
}

View File

@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Ajax;
use Drupal\ajax_test\Controller\AjaxTestController;
use Drupal\Core\Ajax\OpenModalDialogWithUrl;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
// cspell:ignore testdialog
/**
* Performs tests on opening and manipulating dialogs via AJAX commands.
*
* @group Ajax
*/
class DialogTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['ajax_test', 'ajax_forms_test', 'contact'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests sending non-JS and AJAX requests to open and manipulate modals.
*/
public function testDialog(): void {
$this->drupalLogin($this->drupalCreateUser(['administer contact forms']));
// Ensure the elements render without notices or exceptions.
$this->drupalGet('ajax-test/dialog');
// Set up variables for this test.
$dialog_renderable = AjaxTestController::dialogContents();
$dialog_contents = \Drupal::service('renderer')->renderRoot($dialog_renderable);
// Check that requesting a modal dialog without JS goes to a page.
$this->drupalGet('ajax-test/dialog-contents');
$this->assertSession()->responseContains($dialog_contents);
// Visit the page containing the many test dialog links.
$this->drupalGet('ajax-test/dialog');
// Tests a basic modal dialog by verifying the contents of the dialog are as
// expected.
$this->getSession()->getPage()->clickLink('Link 1 (modal)');
// Clicking the link triggers an AJAX request/response.
// Opens a Dialog panel.
$link1_dialog_div = $this->assertSession()->waitForElementVisible('css', 'div.ui-dialog');
$this->assertNotNull($link1_dialog_div, 'Link was used to open a dialog ( modal )');
$link1_modal = $link1_dialog_div->find('css', '#drupal-modal');
$this->assertNotNull($link1_modal, 'Link was used to open a dialog ( non-modal )');
$this->assertSession()->responseContains($dialog_contents);
$dialog_title = $link1_dialog_div->find('css', "span.ui-dialog-title:contains('AJAX Dialog & contents')");
$this->assertNotNull($dialog_title);
$dialog_title_amp = $link1_dialog_div->find('css', "span.ui-dialog-title:contains('AJAX Dialog &amp; contents')");
$this->assertNull($dialog_title_amp);
// Close open dialog, return to the dialog links page.
$close_button = $link1_dialog_div->findButton('Close');
$this->assertNotNull($close_button);
$close_button->press();
// Tests a modal with a dialog-option.
// Link 2 is similar to Link 1, except it submits additional width
// information which must be echoed in the resulting DOM update.
$this->getSession()->getPage()->clickLink('Link 2 (modal)');
$dialog = $this->assertSession()->waitForElementVisible('css', 'div.ui-dialog');
$this->assertNotNull($dialog, 'Link was used to open a dialog ( non-modal, with options )');
$style = $dialog->getAttribute('style');
$this->assertStringContainsString('width: 400px;', $style, "Modal respected the dialog-options width parameter. Style = $style");
// Reset: Return to the dialog links page.
$this->drupalGet('ajax-test/dialog');
// Test a non-modal dialog ( with target ).
$this->clickLink('Link 3 (non-modal)');
$non_modal_dialog = $this->assertSession()->waitForElementVisible('css', 'div.ui-dialog');
$this->assertNotNull($non_modal_dialog, 'Link opens a non-modal dialog.');
// Tests the dialog contains a target element specified in the AJAX request.
$non_modal_dialog->find('css', 'div#ajax-test-dialog-wrapper-1');
$this->assertSession()->responseContains($dialog_contents);
// Reset: Return to the dialog links page.
$this->drupalGet('ajax-test/dialog');
// Tests a non-modal dialog ( without target ).
$this->clickLink('Link 7 (non-modal, no target)');
$no_target_dialog = $this->assertSession()->waitForElementVisible('css', 'div.ui-dialog');
$this->assertNotNull($no_target_dialog, 'Link opens a non-modal dialog.');
$contents_no_target = $no_target_dialog->find('css', 'div.ui-dialog-content');
$this->assertNotNull($contents_no_target, 'non-modal dialog opens ( no target ). ');
$id = $contents_no_target->getAttribute('id');
$partial_match = str_starts_with($id, 'drupal-dialog-ajax-testdialog-contents');
$this->assertTrue($partial_match, 'The non-modal ID has the expected prefix.');
$no_target_button = $no_target_dialog->findButton('Close');
$this->assertNotNull($no_target_button, 'Link dialog has a close button');
$no_target_button->press();
$this->getSession()->getPage()->findButton('Button 1 (modal)')->press();
$button1_dialog = $this->assertSession()->waitForElementVisible('css', 'div.ui-dialog');
$this->assertNotNull($button1_dialog, 'Button opens a modal dialog.');
$button1_dialog_content = $button1_dialog->find('css', 'div.ui-dialog-content');
$this->assertNotNull($button1_dialog_content, 'Button opens a modal dialog.');
// Test the HTML escaping of & character.
$button1_dialog_title = $button1_dialog->find('css', "span.ui-dialog-title:contains('AJAX Dialog & contents')");
$this->assertNotNull($button1_dialog_title);
$button1_dialog_title_amp = $button1_dialog->find('css', "span.ui-dialog-title:contains('AJAX Dialog &amp; contents')");
$this->assertNull($button1_dialog_title_amp);
// Reset: Close the dialog.
$button1_dialog->findButton('Close')->press();
// Abbreviated test for "normal" dialogs, testing only the difference.
$this->getSession()->getPage()->findButton('Button 2 (non-modal)')->press();
$button2_dialog = $this->assertSession()->waitForElementVisible('css', 'div.ui-dialog-content');
$this->assertNotNull($button2_dialog, 'Non-modal content displays as expected.');
// Use a link to close the panel opened by button 2.
$this->getSession()->getPage()->clickLink('Link 4 (close non-modal if open)');
// Test dialogs opened using OpenModalDialogWithUrl.
$this->getSession()->getPage()->findButton('Button 3 (modal from url)')->press();
// Check that title was fetched properly.
// @see \Drupal\ajax_test\Form\AjaxTestDialogForm::dialog.
$form_dialog_title = $this->assertSession()->waitForElement('css', "span.ui-dialog-title:contains('Ajax Form contents')");
$this->assertNotNull($form_dialog_title, 'Dialog form has the expected title.');
$button1_dialog->findButton('Close')->press();
// Test external URL.
$dialog_obj = new OpenModalDialogWithUrl('http://example.com', []);
try {
$dialog_obj->render();
}
catch (\LogicException $e) {
$this->assertEquals('External URLs are not allowed.', $e->getMessage());
}
// Form modal.
$this->clickLink('Link 5 (form)');
// Two links have been clicked in succession - This time wait for a change
// in the title as the previous closing dialog may temporarily be open.
$form_dialog_title = $this->assertSession()->waitForElementVisible('css', "span.ui-dialog-title:contains('Ajax Form contents')");
$this->assertNotNull($form_dialog_title, 'Dialog form has the expected title.');
// Locate the newly opened dialog.
$form_dialog = $this->getSession()->getPage()->find('css', 'div.ui-dialog');
$this->assertNotNull($form_dialog, 'Form dialog is visible');
$form_contents = $form_dialog->find('css', "p:contains('Ajax Form contents description.')");
$this->assertNotNull($form_contents, 'For has the expected text.');
$do_it = $form_dialog->findButton('Do it');
$this->assertNotNull($do_it, 'The dialog has a "Do it" button.');
$preview = $form_dialog->findButton('Preview');
$this->assertNotNull($preview, 'The dialog contains a "Preview" button.');
// Form submit inputs, link buttons, and buttons in dialog are copied to the
// dialog buttonpane as buttons. The originals should have their styles set
// to display: none.
$hidden_buttons = $this->getSession()->getPage()->findAll('css', '.ajax-test-form .button');
$this->assertCount(3, $hidden_buttons);
$hidden_button_text = [];
foreach ($hidden_buttons as $button) {
$styles = $button->getAttribute('style');
$this->assertStringContainsStringIgnoringCase('display: none;', $styles);
$hidden_button_text[] = $button->hasAttribute('value') ? $button->getAttribute('value') : $button->getHtml();
}
// The copied buttons should have the same text as the submit inputs they
// were copied from.
$moved_to_buttonpane_buttons = $this->getSession()->getPage()->findAll('css', '.ui-dialog-buttonpane button');
$this->assertCount(3, $moved_to_buttonpane_buttons);
foreach ($moved_to_buttonpane_buttons as $key => $button) {
$this->assertEquals($hidden_button_text[$key], $button->getText());
}
// Press buttons in the dialog to ensure there are no AJAX errors.
$this->assertSession()->elementExists('css', '.ui-dialog-buttonpane')->pressButton('Hello world');
$this->assertSession()->assertWaitOnAjaxRequest();
$has_focus_text = $this->getSession()->evaluateScript('document.activeElement.textContent');
$this->assertEquals('Do it', $has_focus_text);
$this->assertSession()->elementExists('css', '.ui-dialog-buttonpane')->pressButton('Preview');
$this->assertSession()->assertWaitOnAjaxRequest();
$has_focus_text = $this->getSession()->evaluateScript('document.activeElement.textContent');
$this->assertEquals('Do it', $has_focus_text);
// Reset: close the form.
$form_dialog->findButton('Close')->press();
// Non AJAX version of Link 6.
$this->drupalGet('admin/structure/contact/add');
// Check we get a chunk of the code, we can't test the whole form as form
// build id and token with be different.
$this->assertSession()->elementExists('xpath', "//form[@id='contact-form-add-form']");
// Reset: Return to the dialog links page.
$this->drupalGet('ajax-test/dialog');
$this->clickLink('Link 6 (entity form)');
$dialog_add = $this->assertSession()->waitForElementVisible('css', 'div.ui-dialog');
$this->assertNotNull($dialog_add, 'Form dialog is visible');
$form_add = $dialog_add->find('css', 'form.contact-form-add-form');
$this->assertNotNull($form_add, 'Modal dialog JSON contains entity form.');
$form_title = $dialog_add->find('css', "span.ui-dialog-title:contains('Add contact form')");
$this->assertNotNull($form_title, 'The add form title is as expected.');
}
/**
* Tests dialog link opener with different HTTP methods.
*/
public function testHttpMethod(): void {
$assert = $this->assertSession();
$script = <<<SCRIPT
(function() {
return document.querySelector('div[aria-describedby="drupal-modal"]').offsetWidth;
}())
SCRIPT;
// Open the modal dialog with POST HTTP method.
$this->drupalGet('/ajax-test/http-methods');
$this->clickLink('Link');
$assert->assertWaitOnAjaxRequest();
$assert->pageTextContains('Modal dialog contents');
$width = $this->getSession()->getDriver()->evaluateScript($script);
// The theme is adding 4px as padding and border on each side.
$this->assertSame(808, $width);
// Switch to GET HTTP method.
// @see \Drupal\ajax_test\Controller\AjaxTestController::httpMethods()
\Drupal::state()->set('ajax_test.http_method', 'GET');
// Open the modal dialog with GET HTTP method.
$this->drupalGet('/ajax-test/http-methods');
$this->clickLink('Link');
$assert->assertWaitOnAjaxRequest();
$assert->pageTextContains('Modal dialog contents');
$width = $this->getSession()->getDriver()->evaluateScript($script);
// The theme is adding 4px as padding and border on each side.
$this->assertSame(808, $width);
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Ajax;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Various tests of AJAX behavior.
*
* @group Ajax
*/
class ElementValidationTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['ajax_test', 'ajax_forms_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tries to post an Ajax change to a form that has a validated element.
*
* Drupal AJAX commands update the DOM echoing back the validated values in
* the form of messages that appear on the page.
*/
public function testAjaxElementValidation(): void {
$this->drupalGet('ajax_validation_test');
$page = $this->getSession()->getPage();
$assert = $this->assertSession();
// Partially complete the form with a string.
$page->fillField('driver_text', 'some dumb text');
// Move focus away from this field to trigger AJAX.
$page->findField('spare_required_field')->focus();
// When the AJAX command updates the DOM a <ul> unsorted list
// "message__list" structure will appear on the page echoing back the
// "some dumb text" message.
$placeholder_text = $assert->waitForElement('css', "[aria-label='Status message'] > ul > li > em:contains('some dumb text')");
$this->assertNotNull($placeholder_text, 'A callback successfully echoed back a string.');
$this->drupalGet('ajax_validation_test');
// Partially complete the form with a number.
$page->fillField('driver_number', '12345');
$page->findField('spare_required_field')->focus();
// The AJAX request/response will complete successfully when an
// InsertCommand injects a message with a placeholder element into the DOM
// with the submitted number.
$placeholder_number = $assert->waitForElement('css', "[aria-label='Status message'] > ul > li > em:contains('12345')");
$this->assertNotNull($placeholder_number, 'A callback successfully echoed back a number.');
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Ajax;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests setting focus via AJAX command.
*
* @group Ajax
*/
class FocusFirstCommandTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['ajax_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests AjaxFocusFirstCommand on a page.
*/
public function testFocusFirst(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->drupalGet('ajax-test/focus-first');
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
$this->assertNotContains($has_focus_id, ['edit-first-input', 'edit-first-container-input']);
// Confirm that focus does not change if the selector targets a
// non-focusable container containing no tabbable elements.
$page->pressButton('SelectorNothingTabbable');
$this->assertNotNull($assert_session->waitForElementVisible('css', '#edit-selector-has-nothing-tabbable[data-has-focus]'));
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
$this->assertEquals('edit-selector-has-nothing-tabbable', $has_focus_id);
// Confirm that focus does not change if the page has no match for the
// provided selector.
$page->pressButton('SelectorNotExist');
$this->assertNotNull($assert_session->waitForElementVisible('css', '#edit-selector-does-not-exist[data-has-focus]'));
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
$this->assertEquals('edit-selector-does-not-exist', $has_focus_id);
// Confirm focus is moved to first tabbable element in a container.
$page->pressButton('focusFirstContainer');
$this->assertNotNull($assert_session->waitForElementVisible('css', '#edit-first-container-input[data-has-focus]'));
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
$this->assertEquals('edit-first-container-input', $has_focus_id);
// Confirm focus is moved to first tabbable element in a form.
$page->pressButton('focusFirstForm');
$this->assertNotNull($assert_session->waitForElementVisible('css', '#ajax-test-focus-first-command-form #edit-first-input[data-has-focus]'));
// Confirm the form has more than one input to confirm that focus is moved
// to the first tabbable element in the container.
$this->assertNotNull($page->find('css', '#ajax-test-focus-first-command-form #edit-second-input'));
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
$this->assertEquals('edit-first-input', $has_focus_id);
// Confirm that the selector provided will use the first match in the DOM as
// the container.
$page->pressButton('SelectorMultipleMatches');
$this->assertNotNull($assert_session->waitForElementVisible('css', '#edit-inside-same-selector-container-1[data-has-focus]'));
$this->assertNotNull($page->findById('edit-inside-same-selector-container-2'));
$this->assertNull($assert_session->waitForElementVisible('css', '#edit-inside-same-selector-container-2[data-has-focus]'));
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
$this->assertEquals('edit-inside-same-selector-container-1', $has_focus_id);
// Confirm that if a container has no tabbable children, but is itself
// focusable, then that container receives focus.
$page->pressButton('focusableContainerNotTabbableChildren');
$this->assertNotNull($assert_session->waitForElementVisible('css', '#focusable-container-without-tabbable-children[data-has-focus]'));
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
$this->assertEquals('focusable-container-without-tabbable-children', $has_focus_id);
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Ajax;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests that form values are properly delivered to AJAX callbacks.
*
* @group Ajax
*/
class FormValuesTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'ajax_test', 'ajax_forms_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalLogin($this->drupalCreateUser(['access content']));
}
/**
* Submits forms with select and checkbox elements via Ajax.
*
* @dataProvider formModeProvider
*/
public function testSimpleAjaxFormValue($form_mode): void {
$this->drupalGet('ajax_forms_test_get_form');
$session = $this->getSession();
$assertSession = $this->assertSession();
// Run the test both in a dialog and not in a dialog.
if ($form_mode === 'direct') {
$this->drupalGet('ajax_forms_test_get_form');
}
else {
$this->drupalGet('ajax_forms_test_dialog_form_link');
$assertSession->waitForElementVisible('css', '[data-once="ajax"]');
$this->clickLink("Open form in $form_mode");
$this->assertNotEmpty($assertSession->waitForElementVisible('css', '.ui-dialog [data-drupal-selector="edit-select"]'));
}
// Verify form values of a select element.
foreach (['green', 'blue', 'red'] as $item) {
// Updating the field will trigger an AJAX request/response.
$session->getPage()->selectFieldOption('select', $item);
// The AJAX command in the response will update the DOM.
$select = $assertSession->waitForElement('css', "div#ajax_selected_color:contains('$item')");
$this->assertNotNull($select, "DataCommand has updated the page with a value of $item.");
$condition = "(typeof jQuery !== 'undefined' && jQuery('[data-drupal-selector=\"edit-select\"]').is(':focus'))";
$this->assertJsCondition($condition, 5000);
}
// Verify form values of a checkbox element.
$session->getPage()->checkField('checkbox');
$div0 = $this->assertSession()->waitForElement('css', "div#ajax_checkbox_value:contains('checked')");
$this->assertNotNull($div0, 'DataCommand updates the DOM as expected when a checkbox is selected');
$session->getPage()->uncheckField('checkbox');
$div1 = $this->assertSession()->waitForElement('css', "div#ajax_checkbox_value:contains('unchecked')");
$this->assertNotNull($div1, 'DataCommand updates the DOM as expected when a checkbox is de-selected');
}
/**
* Tests that AJAX elements with invalid callbacks return error code 500.
*/
public function testSimpleInvalidCallbacksAjaxFormValue(): void {
$this->drupalGet('ajax_forms_test_get_form');
$session = $this->getSession();
// Ensure the test error log is empty before these tests.
$this->assertFileDoesNotExist(DRUPAL_ROOT . '/' . $this->siteDirectory . '/error.log');
// We're going to do some invalid requests. The JavaScript errors thrown
// whilst doing so are expected. Do not interpret them as a test failure.
$this->failOnJavascriptConsoleErrors = FALSE;
// We don't need to check for the X-Drupal-Ajax-Token header with these
// invalid requests.
foreach (['null', 'empty', 'nonexistent'] as $key) {
$element_name = 'select_' . $key . '_callback';
// Updating the field will trigger an AJAX request/response.
$session->getPage()->selectFieldOption($element_name, 'green');
// The select element is disabled as the AJAX request is issued.
$this->assertSession()->waitForElement('css', "select[name=\"$element_name\"]:disabled");
// The select element is enabled as the response is received.
$this->assertSession()->waitForElement('css', "select[name=\"$element_name\"]:enabled");
// Not using File API, a potential error must trigger a PHP warning, which
// should be logged in the error.log.
$this->assertFileExists(DRUPAL_ROOT . '/' . $this->siteDirectory . '/error.log');
$this->assertStringContainsString('"The specified #ajax callback is empty or not callable."', file_get_contents(DRUPAL_ROOT . '/' . $this->siteDirectory . '/error.log'));
// Remove error.log, so we have a clean slate for the next request.
unlink(\Drupal::root() . '/' . $this->siteDirectory . '/error.log');
}
// We need to reload the page to kill any unfinished AJAX calls before
// tearDown() is called.
$this->drupalGet('ajax_forms_test_get_form');
}
/**
* Data provider for testSimpleAjaxFormValue.
*/
public static function formModeProvider() {
return [
['direct'],
['dialog'],
['off canvas dialog'],
];
}
}

View File

@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Ajax;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use PHPUnit\Framework\ExpectationFailedException;
/**
* Tests adding messages via AJAX command.
*
* @group Ajax
*/
class MessageCommandTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['ajax_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests AJAX MessageCommand use in a form.
*/
public function testMessageCommand(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->drupalGet('ajax-test/message');
$page->pressButton('Make Message In Default Location');
$this->waitForMessageVisible('I am a message in the default location.');
$this->assertAnnounceContains('I am a message in the default location.');
$assert_session->elementsCount('css', '.messages__wrapper .messages', 1);
$page->pressButton('Make Message In Alternate Location');
$this->waitForMessageVisible('I am a message in an alternate location.', '#alternate-message-container');
$assert_session->pageTextContains('I am a message in the default location.');
$this->assertAnnounceContains('I am a message in an alternate location.');
$assert_session->elementsCount('css', '.messages__wrapper .messages', 1);
$assert_session->elementsCount('css', '#alternate-message-container .messages', 1);
$page->pressButton('Make Warning Message');
$this->waitForMessageVisible('I am a warning message in the default location.', NULL, 'warning');
$assert_session->pageTextNotContains('I am a message in the default location.');
$assert_session->elementsCount('css', '.messages__wrapper .messages', 1);
$assert_session->elementsCount('css', '#alternate-message-container .messages', 1);
$this->drupalGet('ajax-test/message');
// Test that by default, previous messages in a location are removed.
for ($i = 0; $i < 6; $i++) {
$page->pressButton('Make Message In Default Location');
$this->waitForMessageVisible('I am a message in the default location.');
$assert_session->elementsCount('css', '.messages__wrapper .messages', 1);
$page->pressButton('Make Warning Message');
$this->waitForMessageVisible('I am a warning message in the default location.', NULL, 'warning');
// Test that setting MessageCommand::$option['announce'] => '' suppresses
// screen reader announcement.
$this->assertAnnounceNotContains('I am a warning message in the default location.');
$this->waitForMessageRemoved('I am a message in the default location.');
$assert_session->elementsCount('css', '.messages__wrapper .messages', 1);
}
// Test that if MessageCommand::clearPrevious is FALSE, messages will not
// be cleared.
$this->drupalGet('ajax-test/message');
for ($i = 1; $i < 7; $i++) {
$page->pressButton('Make Message In Alternate Location');
$expected_count = $page->waitFor(10, function () use ($i, $page) {
return count($page->findAll('css', '#alternate-message-container .messages')) === $i;
});
$this->assertTrue($expected_count);
$this->assertAnnounceContains('I am a message in an alternate location.');
}
}
/**
* Tests methods in JsWebAssert related to status messages.
*/
public function testJsStatusMessageAssertions(): void {
$page = $this->getSession()->getPage();
$this->drupalGet('ajax-test/message');
$page->pressButton('Make Message In Default Location');
$this->assertSession()->statusMessageContainsAfterWait('I am a message in the default location.');
$page->pressButton('Make Message In Alternate Location');
$this->assertSession()->statusMessageContainsAfterWait('I am a message in an alternate location.', 'status');
$page->pressButton('Make Warning Message');
$this->assertSession()->statusMessageContainsAfterWait('I am a warning message in the default location.', 'warning');
// Reload and test some negative assertions.
$this->drupalGet('ajax-test/message');
$page->pressButton('Make Message In Default Location');
// Use message that is not on page.
$this->assertSession()->statusMessageNotContainsAfterWait('This is not a real message');
$page->pressButton('Make Message In Alternate Location');
// Use message that exists but has the wrong type.
$this->assertSession()->statusMessageNotContainsAfterWait('I am a message in an alternate location.', 'warning');
// Test partial match.
$page->pressButton('Make Warning Message');
$this->assertSession()->statusMessageContainsAfterWait('I am a warning');
// One more reload to try with different arg combinations.
$this->drupalGet('ajax-test/message');
$page->pressButton('Make Message In Default Location');
$this->assertSession()->statusMessageExistsAfterWait();
$page->pressButton('Make Message In Alternate Location');
$this->assertSession()->statusMessageNotExistsAfterWait('error');
$page->pressButton('Make Warning Message');
$this->assertSession()->statusMessageExistsAfterWait('warning');
// Perform a few assertions that should fail. We can only call
// TestCase::expectException() once per test, so we make a few
// try/catch blocks. We pass a relatively short timeout because
// it is a waste of time to wait 10 seconds in these assertions
// that we fully expect to fail.
$expected_failure_occurred = FALSE;
try {
$this->assertSession()->statusMessageContainsAfterWait('Not a real message', NULL, 1000);
}
catch (ExpectationFailedException $e) {
$expected_failure_occurred = TRUE;
}
$this->assertTrue($expected_failure_occurred, 'JsWebAssert::statusMessageContainsAfterWait() did not fail when it should have failed.');
$expected_failure_occurred = FALSE;
try {
$this->assertSession()->statusMessageNotContainsAfterWait('I am a warning', NULL, 1000);
}
catch (ExpectationFailedException $e) {
$expected_failure_occurred = TRUE;
}
$this->assertTrue($expected_failure_occurred, 'JsWebAssert::statusMessageNotContainsAfterWait() did not fail when it should have failed.');
$expected_failure_occurred = FALSE;
try {
$this->assertSession()->statusMessageExistsAfterWait('error', 1000);
}
catch (ExpectationFailedException $e) {
$expected_failure_occurred = TRUE;
}
$this->assertTrue($expected_failure_occurred, 'JsWebAssert::statusMessageExistsAfterWait() did not fail when it should have failed.');
$expected_failure_occurred = FALSE;
try {
$this->assertSession()->statusMessageNotExistsAfterWait('warning', 1000);
}
catch (ExpectationFailedException $e) {
$expected_failure_occurred = TRUE;
}
$this->assertTrue($expected_failure_occurred, 'JsWebAssert::statusMessageNotExistsAfterWait() did not fail when it should have failed.');
// Tests passing a bad status type.
$this->expectException(\InvalidArgumentException::class);
$this->assertSession()->statusMessageExistsAfterWait('not a valid type');
}
/**
* Asserts that a message of the expected type appears.
*
* @param string $message
* The expected message.
* @param string $selector
* The selector for the element in which to check for the expected message.
* @param string $type
* The expected type.
*/
protected function waitForMessageVisible($message, $selector = '[data-drupal-messages]', $type = 'status') {
$this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', $selector . ' .messages--' . $type . ':contains("' . $message . '")'));
}
/**
* Asserts that a message of the expected type is removed.
*
* @param string $message
* The expected message.
* @param string $selector
* The selector for the element in which to check for the expected message.
* @param string $type
* The expected type.
*/
protected function waitForMessageRemoved($message, $selector = '[data-drupal-messages]', $type = 'status') {
$this->assertNotEmpty($this->assertSession()->waitForElementRemoved('css', $selector . ' .messages--' . $type . ':contains("' . $message . '")'));
}
/**
* Checks for inclusion of text in #drupal-live-announce.
*
* @param string $expected_message
* The text expected to be present in #drupal-live-announce.
*
* @internal
*/
protected function assertAnnounceContains(string $expected_message): void {
$assert_session = $this->assertSession();
$this->assertNotEmpty($assert_session->waitForElement('css', "#drupal-live-announce:contains('$expected_message')"));
}
/**
* Checks for absence of the given text from #drupal-live-announce.
*
* @param string $expected_message
* The text expected to be absent from #drupal-live-announce.
*
* @internal
*/
protected function assertAnnounceNotContains(string $expected_message): void {
$assert_session = $this->assertSession();
$this->assertEmpty($assert_session->waitForElement('css', "#drupal-live-announce:contains('$expected_message')", 1000));
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Ajax;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests AJAX-enabled forms when multiple instances of the form are on a page.
*
* @group Ajax
*/
class MultiFormTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'form_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Page']);
// Create a multi-valued field for 'page' nodes to use for Ajax testing.
$field_name = 'field_ajax_test';
FieldStorageConfig::create([
'entity_type' => 'node',
'field_name' => $field_name,
'type' => 'text',
'cardinality' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED,
])->save();
FieldConfig::create([
'field_name' => $field_name,
'entity_type' => 'node',
'bundle' => 'page',
])->save();
\Drupal::service('entity_display.repository')->getFormDisplay('node', 'page', 'default')
->setComponent($field_name, ['type' => 'text_textfield'])
->save();
// Log in a user who can create 'page' nodes.
$this->drupalLogin($this->drupalCreateUser(['create page content']));
}
/**
* Tests that pages with the 'node_page_form' included twice work correctly.
*/
public function testMultiForm(): void {
// HTML IDs for elements within the field are potentially modified with
// each Ajax submission, but these variables are stable and help target the
// desired elements.
$field_name = 'field_ajax_test';
$form_xpath = '//form[starts-with(@id, "node-page-form")]';
$field_xpath = '//div[contains(@class, "field--name-field-ajax-test")]';
$button_name = $field_name . '_add_more';
$button_xpath_suffix = '//input[@name="' . $button_name . '"]';
$field_items_xpath_suffix = '//input[@type="text"]';
// Ensure the initial page contains both node forms and the correct number
// of field items and "add more" button for the multi-valued field within
// each form.
$this->drupalGet('form-test/two-instances-of-same-form');
$session = $this->getSession();
$page = $session->getPage();
$fields = $page->findAll('xpath', $form_xpath . $field_xpath);
$this->assertCount(2, $fields);
foreach ($fields as $field) {
$this->assertCount(1, $field->findAll('xpath', '.' . $field_items_xpath_suffix), 'Found the correct number of field items on the initial page.');
$this->assertNotNull($field->find('xpath', '.' . $button_xpath_suffix), 'Found the "add more" button on the initial page.');
}
$this->assertSession()->pageContainsNoDuplicateId();
// Submit the "add more" button of each form twice. After each corresponding
// page update, ensure the same as above.
for ($i = 0; $i < 2; $i++) {
$forms = $page->findAll('xpath', $form_xpath);
foreach ($forms as $offset => $form) {
$button = $form->findButton('Add another item');
$this->assertNotNull($button, 'Add Another Item button exists');
$button->press();
// Wait for field to be added with ajax.
$this->assertNotEmpty($page->waitFor(10, function () use ($form, $i) {
return $form->findField('field_ajax_test[' . ($i + 1) . '][value]');
}));
// After AJAX request and response verify the correct number of text
// fields (including title), as well as the "Add another item" button.
$this->assertCount($i + 3, $form->findAll('css', 'input[type="text"]'), 'Found the correct number of field items after an AJAX submission.');
$this->assertNotEmpty($form->findButton('Add another item'), 'Found the "add more" button after an AJAX submission.');
$this->assertSession()->pageContainsNoDuplicateId();
}
}
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Ajax;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests the throbber.
*
* @group Ajax
*/
class ThrobberTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'views',
'views_ui',
'views_ui_test_field',
'hold_test',
'block',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests theming throbber element.
*/
public function testThemingThrobberElement(): void {
$session = $this->getSession();
$web_assert = $this->assertSession();
$page = $session->getPage();
$admin_user = $this->drupalCreateUser([
'administer views',
'administer blocks',
]);
$this->drupalLogin($admin_user);
$custom_ajax_progress_indicator_fullscreen = <<<JS
Drupal.theme.ajaxProgressIndicatorFullscreen = function () {
return '<div class="custom-ajax-progress-fullscreen"></div>';
};
JS;
$custom_ajax_progress_throbber = <<<JS
Drupal.theme.ajaxProgressThrobber = function (message) {
return '<div class="custom-ajax-progress-throbber"></div>';
};
JS;
$custom_ajax_progress_message = <<<JS
Drupal.theme.ajaxProgressMessage = function (message) {
return '<div class="custom-ajax-progress-message">Hold door!</div>';
};
JS;
$this->drupalGet('admin/structure/views/view/content');
$web_assert->assertNoElementAfterWait('css', '.ajax-progress-fullscreen');
// Test theming fullscreen throbber.
$session->executeScript($custom_ajax_progress_indicator_fullscreen);
hold_test_response(TRUE);
$page->clickLink('Content: Published (grouped)');
$this->assertNotNull($web_assert->waitForElement('css', '.custom-ajax-progress-fullscreen'), 'Custom ajaxProgressIndicatorFullscreen.');
hold_test_response(FALSE);
$web_assert->assertNoElementAfterWait('css', '.custom-ajax-progress-fullscreen');
// Test theming throbber message.
$web_assert->waitForElementVisible('css', '[data-drupal-selector="edit-options-group-info-add-group"]');
$session->executeScript($custom_ajax_progress_message);
hold_test_response(TRUE);
$page->pressButton('Add another item');
$this->assertNotNull($web_assert->waitForElement('css', '.ajax-progress-throbber .custom-ajax-progress-message'), 'Custom ajaxProgressMessage.');
hold_test_response(FALSE);
$web_assert->assertNoElementAfterWait('css', '.ajax-progress-throbber');
// Test theming throbber.
$web_assert->waitForElementVisible('css', '[data-drupal-selector="edit-options-group-info-group-items-3-title"]');
$session->executeScript($custom_ajax_progress_throbber);
hold_test_response(TRUE);
$page->pressButton('Add another item');
$this->assertNotNull($web_assert->waitForElement('css', '.custom-ajax-progress-throbber'), 'Custom ajaxProgressThrobber.');
hold_test_response(FALSE);
$web_assert->assertNoElementAfterWait('css', '.custom-ajax-progress-throbber');
// Test progress throbber position on a dropbutton in a table display.
$this->drupalGet('/admin/structure/block');
$this->clickLink('Place block');
$web_assert->assertWaitOnAjaxRequest();
$this->assertNotEmpty($web_assert->waitForElementVisible('css', '#drupal-modal'));
hold_test_response(TRUE);
$this->clickLink('Place block');
$this->assertNotNull($web_assert->waitForElement('xpath', '//div[contains(@class, "dropbutton-wrapper")]/following-sibling::div[contains(@class, "ajax-progress-throbber")]'));
hold_test_response(FALSE);
$web_assert->assertNoElementAfterWait('css', '.ajax-progress-throbber');
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests;
/**
* Tests that unnecessary or untracked XHRs will cause a test failure.
*
* @group javascript
* @group legacy
*/
class AjaxWaitTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'starterkit_theme';
/**
* Tests that an unnecessary wait triggers a deprecation error.
*/
public function testUnnecessaryWait(): void {
$this->drupalGet('user');
$this->expectDeprecation("Drupal\FunctionalJavascriptTests\JSWebAssert::assertExpectedAjaxRequest called unnecessarily in a test is deprecated in drupal:10.2.0 and will throw an exception in drupal:11.0.0. See https://www.drupal.org/node/3401201");
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Unable to complete AJAX request.');
$this->assertSession()->assertWaitOnAjaxRequest(500);
}
/**
* Tests that an untracked XHR triggers a deprecation error.
*/
public function testUntrackedXhr(): void {
$this->getSession()->executeScript(<<<JS
let xhr = new XMLHttpRequest();
xhr.open('GET', '/foobar');
xhr.send();
JS);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('0 XHR requests through jQuery, but 1 observed in the browser — this requires js_testing_ajax_request_test.js to be updated.');
$this->assertSession()->assertExpectedAjaxRequest(1, 500);
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests;
use PHPUnit\Framework\AssertionFailedError;
/**
* Tests if we can execute JavaScript in the browser.
*
* @group javascript
*/
class BrowserWithJavascriptTest extends WebDriverTestBase {
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['test_page_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
public function testJavascript(): void {
$this->drupalGet('<front>');
$session = $this->getSession();
$session->resizeWindow(400, 300);
$javascript = <<<JS
(function(){
var w = window,
d = document,
e = d.documentElement,
g = d.getElementsByTagName('body')[0],
x = w.innerWidth || e.clientWidth || g.clientWidth,
y = w.innerHeight || e.clientHeight|| g.clientHeight;
return x == 400 && y == 300;
}())
JS;
$this->assertJsCondition($javascript);
// Ensure that \Drupal\Tests\UiHelperTrait::isTestUsingGuzzleClient() works
// as expected.
$this->assertFalse($this->isTestUsingGuzzleClient());
}
public function testAssertJsCondition(): void {
$this->drupalGet('<front>');
$session = $this->getSession();
$session->resizeWindow(500, 300);
$javascript = <<<JS
(function(){
var w = window,
d = document,
e = d.documentElement,
g = d.getElementsByTagName('body')[0],
x = w.innerWidth || e.clientWidth || g.clientWidth,
y = w.innerHeight || e.clientHeight|| g.clientHeight;
return x == 400 && y == 300;
}())
JS;
// We expected the following assertion to fail because the window has been
// re-sized to have a width of 500 not 400.
$this->expectException(AssertionFailedError::class);
$this->assertJsCondition($javascript, 100);
}
/**
* Tests creating screenshots.
*/
public function testCreateScreenshot(): void {
$this->drupalGet('<front>');
$this->createScreenshot('public://screenshot.jpg');
$this->assertFileExists('public://screenshot.jpg');
}
/**
* Tests assertEscaped() and assertUnescaped().
*
* @see \Drupal\Tests\WebAssert::assertNoEscaped()
* @see \Drupal\Tests\WebAssert::assertEscaped()
*/
public function testEscapingAssertions(): void {
$assert = $this->assertSession();
$this->drupalGet('test-escaped-characters');
$assert->assertNoEscaped('<div class="escaped">');
$assert->responseContains('<div class="escaped">');
$assert->assertEscaped('Escaped: <"\'&>');
$this->drupalGet('test-escaped-script');
$assert->assertNoEscaped('<div class="escaped">');
$assert->responseContains('<div class="escaped">');
$assert->assertEscaped("<script>alert('XSS');alert(\"XSS\");</script>");
$this->drupalGetWithAlert('test-unescaped-script');
$assert->assertNoEscaped('<div class="unescaped">');
$assert->responseContains('<div class="unescaped">');
$assert->responseContains("<script>alert('Marked safe');alert(\"Marked safe\");</script>");
$assert->assertNoEscaped("<script>alert('Marked safe');alert(\"Marked safe\");</script>");
}
/**
* Retrieves a Drupal path or an absolute path.
*
* @param string|\Drupal\Core\Url $path
* Drupal path or URL to load into Mink controlled browser.
* @param array $options
* (optional) Options to be forwarded to the URL generator.
* @param string[] $headers
* An array containing additional HTTP request headers, the array keys are
* the header names and the array values the header values. This is useful
* to set for example the "Accept-Language" header for requesting the page
* in a different language. Note that not all headers are supported, for
* example the "Accept" header is always overridden by the browser. For
* testing REST APIs it is recommended to obtain a separate HTTP client
* using getHttpClient() and performing requests that way.
*
* @return string
* The retrieved HTML string, also available as $this->getRawContent()
*
* @see \Drupal\Tests\BrowserTestBase::getHttpClient()
*/
protected function drupalGetWithAlert($path, array $options = [], array $headers = []) {
$options['absolute'] = TRUE;
$url = $this->buildUrl($path, $options);
$session = $this->getSession();
$this->prepareRequest();
foreach ($headers as $header_name => $header_value) {
$session->setRequestHeader($header_name, $header_value);
}
$session->visit($url);
// There are 2 alerts to accept before we can get the content of the page.
$session->getDriver()->getWebdriverSession()->accept_alert();
$session->getDriver()->getWebdriverSession()->accept_alert();
$out = $session->getPage()->getContent();
// Ensure that any changes to variables in the other thread are picked up.
$this->refreshVariables();
// Replace original page output with new output from redirected page(s).
if ($new = $this->checkForMetaRefresh()) {
$out = $new;
// We are finished with all meta refresh redirects, so reset the counter.
$this->metaRefreshCount = 0;
}
// Log only for WebDriverTestBase tests because for DrupalTestBrowser we log
// with ::getResponseLogHandler.
if ($this->htmlOutputEnabled && !$this->isTestUsingGuzzleClient()) {
$html_output = 'GET request to: ' . $url .
'<hr />Ending URL: ' . $this->getSession()->getCurrentUrl();
$html_output .= '<hr />' . $out;
$html_output .= $this->getHtmlOutputHeaders();
$this->htmlOutput($html_output);
}
return $out;
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Components;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests the correct rendering of components.
*
* @group sdc
*/
class ComponentRenderTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['system', 'sdc_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'sdc_theme_test';
/**
* Tests that the correct libraries are put on the page using CSS.
*
* This also covers all the path translations necessary to produce the correct
* path to the assets.
*/
public function testCssLibraryAttachesCorrectly(): void {
$build = [
'#type' => 'inline_template',
'#template' => "{{ include('sdc_theme_test:lib-overrides') }}",
];
\Drupal::state()->set('sdc_test_component', $build);
$this->drupalGet('sdc-test-component');
$wrapper = $this->getSession()->getPage()->find('css', '#sdc-wrapper');
// Opacity is set to 0 in the CSS file (see another-stylesheet.css).
$this->assertFalse($wrapper->isVisible());
}
/**
* Tests that the correct libraries are put on the page using JS.
*
* This also covers all the path translations necessary to produce the correct
* path to the assets.
*/
public function testJsLibraryAttachesCorrectly(): void {
$build = [
'#type' => 'inline_template',
'#template' => "{{ include('sdc_test:my-button', {
text: 'Click'
}, with_context = false) }}",
];
\Drupal::state()->set('sdc_test_component', $build);
$this->drupalGet('sdc-test-component');
$page = $this->getSession()->getPage();
$page->find('css', '[data-component-id="sdc_test:my-button"]')
->click();
$this->assertSame(
'Click power (1)',
$page->find('css', '[data-component-id="sdc_test:my-button"]')->getText(),
);
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Core;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Test race condition for CSRF tokens for simultaneous requests.
*
* @group Session
*/
class CsrfTokenRaceTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['csrf_race_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests race condition for CSRF tokens for simultaneous requests.
*/
public function testCsrfRace(): void {
$user = $this->createUser(['access content']);
$this->drupalLogin($user);
$this->drupalGet('/csrf_race/test');
$script = '';
// Delay the request processing of the first request by one second through
// the request parameter, which will simulate the concurrent processing
// of both requests.
foreach ([1, 0] as $i) {
$script .= <<<EOT
jQuery.ajax({
url: "$this->baseUrl/csrf_race/get_csrf_token/$i",
method: "GET",
headers: {
"Content-Type": "application/json"
},
success: function(response) {
jQuery('body').append("<p class='csrf$i'></p>");
jQuery('.csrf$i').html(response);
},
error: function() {
jQuery('body').append('Nothing');
}
});
EOT;
}
$this->getSession()->getDriver()->executeScript($script);
$token0 = $this->assertSession()->waitForElement('css', '.csrf0')->getHtml();
$token1 = $this->assertSession()->waitForElement('css', '.csrf1')->getHtml();
$this->assertNotNull($token0);
$this->assertNotNull($token1);
$this->assertEquals($token0, $token1);
}
}

View File

@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Core\Field;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Entity\Entity\EntityViewDisplay;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests the 'timestamp' formatter when is used with time difference setting.
*
* @group Field
*/
class TimestampFormatterWithTimeDiffTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['entity_test', 'field'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Testing entity.
*
* @var \Drupal\Core\Entity\ContentEntityInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
FieldStorageConfig::create([
'entity_type' => 'entity_test',
'field_name' => 'time_field',
'type' => 'timestamp',
])->save();
FieldConfig::create([
'entity_type' => 'entity_test',
'bundle' => 'entity_test',
'field_name' => 'time_field',
'label' => $this->randomString(),
])->save();
$display = EntityViewDisplay::create([
'targetEntityType' => 'entity_test',
'bundle' => 'entity_test',
'mode' => 'default',
]);
$display->setComponent('time_field', [
'type' => 'timestamp',
'settings' => [
'time_diff' => [
'enabled' => TRUE,
'future_format' => '@interval hence',
'past_format' => '@interval ago',
'granularity' => 2,
'refresh' => 1,
],
],
])->setStatus(TRUE)->save();
$account = $this->createUser([
'view test entity',
'administer entity_test content',
]);
$this->drupalLogin($account);
$this->entity = EntityTest::create([
'type' => 'entity_test',
'name' => $this->randomString(),
'time_field' => $this->container->get('datetime.time')->getRequestTime(),
]);
$this->entity->save();
}
/**
* Tests the 'timestamp' formatter when is used with time difference setting.
*/
public function testTimestampFormatterWithTimeDiff(): void {
$this->markTestSkipped("Skipped due to frequent random test failures. See https://www.drupal.org/project/drupal/issues/3400150");
$this->drupalGet($this->entity->toUrl());
// Unit testing Drupal.timeDiff.format(). Not using @dataProvider mechanism
// here in order to avoid installing the site for each case.
foreach ($this->getFormatDiffTestCases() as $case) {
$from = \DateTime::createFromFormat(\DateTimeInterface::RFC3339, $case['from'])->getTimestamp();
$to = \DateTime::createFromFormat(\DateTimeInterface::RFC3339, $case['to'])->getTimestamp();
$diff = $to - $from;
$options = json_encode($case['options']);
$expected_value = json_encode($case['expected_value']);
$expected_formatted_value = $case['expected_formatted_value'];
// Test the returned value.
$this->assertJsCondition("JSON.stringify(Drupal.timeDiff.format($diff, $options).value) === '$expected_value'");
// Test the returned formatted value.
$this->assertJsCondition("Drupal.timeDiff.format($diff, $options).formatted === '$expected_formatted_value'");
}
// Unit testing Drupal.timeDiff.refreshInterval(). Not using @dataProvider
// mechanism here in order to avoid reinstalling the site for each case.
foreach ($this->getRefreshIntervalTestCases() as $case) {
$interval = json_encode($case['time_diff']);
$this->assertJsCondition("Drupal.timeDiff.refreshInterval($interval, {$case['configured_refresh_interval']}, {$case['granularity']}) === {$case['computed_refresh_interval']}");
}
// Test the UI.
$time_element = $this->getSession()->getPage()->find('css', 'time');
$time_diff = $time_element->getText();
[$seconds_value] = explode(' ', $time_diff, 2);
// Wait at least 1 second + 1 millisecond to make sure that the last time
// difference value has been refreshed.
$this->assertJsCondition("document.getElementsByTagName('time')[0].textContent != '$time_diff'", 1001);
$time_diff = $time_element->getText();
[$new_seconds_value] = explode(' ', $time_diff, 2);
$this->assertGreaterThan($seconds_value, $new_seconds_value);
// Once again.
$this->assertJsCondition("document.getElementsByTagName('time')[0].textContent != '$time_diff'", 1001);
$time_diff = $time_element->getText();
$seconds_value = $new_seconds_value;
[$new_seconds_value] = explode(' ', $time_diff, 2);
$this->assertGreaterThan($seconds_value, $new_seconds_value);
}
/**
* Tests the 'timestamp' formatter without refresh interval.
*/
public function testNoRefreshInterval(): void {
$this->markTestSkipped("Skipped due to frequent random test failures. See https://www.drupal.org/project/drupal/issues/3400150");
// Set the refresh interval to zero, meaning "no refresh".
$display = EntityViewDisplay::load('entity_test.entity_test.default');
$component = $display->getComponent('time_field');
$component['settings']['time_diff']['refresh'] = 0;
$display->setComponent('time_field', $component)->save();
$this->drupalGet($this->entity->toUrl());
$time_element = $this->getSession()->getPage()->find('css', 'time');
$time_diff_text = $time_element->getText();
$time_diff_settings = Json::decode($time_element->getAttribute('data-drupal-time-diff'));
// Check that the timestamp is represented as a time difference.
$this->assertMatchesRegularExpression('/^\d+ seconds? ago$/', $time_diff_text);
// Check that the refresh is zero (no refresh).
$this->assertSame(0, $time_diff_settings['refresh']);
}
/**
* Provides test cases for unit testing Drupal.timeDiff.format().
*
* @return array[]
* A list of test cases, each representing parameters to be passed to the
* JavaScript function.
*/
protected function getFormatDiffTestCases(): array {
return [
'normal, granularity: 2' => [
'from' => '2010-02-11T10:00:00+00:00',
'to' => '2010-02-16T14:00:00+00:00',
'options' => [
'granularity' => 2,
'strict' => TRUE,
],
'expected_value' => [
'day' => 5,
'hour' => 4,
],
'expected_formatted_value' => '5 days 4 hours',
],
'inverted, strict' => [
'from' => '2010-02-16T14:00:00+00:00',
'to' => '2010-02-11T10:00:00+00:00',
'options' => [
'granularity' => 2,
'strict' => TRUE,
],
'expected_value' => [
'second' => 0,
],
'expected_formatted_value' => '0 seconds',
],
'inverted, strict (strict passed explicitly)' => [
'from' => '2010-02-16T14:00:00+00:00',
'to' => '2010-02-11T10:00:00+00:00',
'options' => [
'granularity' => 2,
'strict' => TRUE,
],
'expected_value' => [
'second' => 0,
],
'expected_formatted_value' => '0 seconds',
],
'inverted, non-strict' => [
'from' => '2010-02-16T14:00:00+00:00',
'to' => '2010-02-11T10:00:00+00:00',
'options' => [
'granularity' => 2,
],
'expected_value' => [
'day' => 5,
'hour' => 4,
],
'expected_formatted_value' => '5 days 4 hours',
],
'normal, max granularity' => [
'from' => '2010-02-02T10:30:45+00:00',
'to' => '2011-06-24T11:37:02+00:00',
'options' => [
'granularity' => 7,
'strict' => TRUE,
],
'expected_value' => [
'year' => 1,
'month' => 4,
'week' => 3,
'day' => 1,
'hour' => 1,
'minute' => 6,
'second' => 17,
],
'expected_formatted_value' => '1 year 4 months 3 weeks 1 day 1 hour 6 minutes 17 seconds',
],
"'1 hour 0 minutes 1 second' is '1 hour'" => [
'from' => '2010-02-02T10:30:45+00:00',
'to' => '2010-02-02T11:30:46+00:00',
'options' => [
'granularity' => 3,
'strict' => TRUE,
],
'expected_value' => [
'hour' => 1,
],
'expected_formatted_value' => '1 hour',
],
"'1 hour 0 minutes' is '1 hour'" => [
'from' => '2010-02-02T10:30:45+00:00',
'to' => '2010-02-02T11:30:45+00:00',
'options' => [
'granularity' => 2,
'strict' => TRUE,
],
'expected_value' => [
'hour' => 1,
],
'expected_formatted_value' => '1 hour',
],
];
}
/**
* Provides test cases for unit testing Drupal.timeDiff.refreshInterval().
*
* @return array[]
* A list of test cases, each representing parameters to be passed to the
* javascript function.
*/
protected function getRefreshIntervalTestCases(): array {
return [
'passed timeout is not altered' => [
'time_diff' => [
'hour' => 11,
'minute' => 10,
'second' => 30,
],
'configured_refresh_interval' => 10,
'granularity' => 3,
'computed_refresh_interval' => 10,
],
'timeout lower than the lowest interval part' => [
'time_diff' => [
'hour' => 11,
'minute' => 10,
],
'configured_refresh_interval' => 59,
'granularity' => 2,
'computed_refresh_interval' => 60,
],
'timeout with number of parts lower than the granularity' => [
'time_diff' => [
'hour' => 1,
'minute' => 0,
],
'configured_refresh_interval' => 10,
'granularity' => 2,
'computed_refresh_interval' => 60,
],
'big refresh interval' => [
'time_diff' => [
'minute' => 3,
'second' => 30,
],
'configured_refresh_interval' => 1000,
'granularity' => 1,
'computed_refresh_interval' => 1000,
],
];
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Core\Field;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\views\Tests\ViewTestData;
/**
* Tests the timestamp formatter used with time difference setting in views.
*
* @group Field
*/
class TimestampFormatterWithTimeDiffViewsTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['entity_test', 'views_test_formatter'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Views used in test.
*
* @var string[]
*/
public static $testViews = ['formatter_timestamp_as_time_diff'];
/**
* Tests the timestamp formatter used with time difference setting in views.
*/
public function testTimestampFormatterWithTimeDiff(): void {
$this->markTestSkipped("Skipped due to frequent random test failures. See https://www.drupal.org/project/drupal/issues/3400150");
ViewTestData::createTestViews(self::class, ['views_test_formatter']);
$data = $this->getRowData();
// PHPStan requires non-empty data. Without this check complains, later,
// that $delta and $time_diff might not be defined.
\assert(!empty($data));
// Create the entities.
foreach ($data as $delta => $row) {
EntityTest::create([
'type' => 'test',
// Using this also as field class.
'name' => "entity-$delta",
'created' => $row['timestamp'],
])->save();
}
$this->drupalGet('formatter_timestamp_as_time_diff');
$page = $this->getSession()->getPage();
foreach ($data as $delta => $row) {
$time_diff = $page->find('css', ".entity-$delta")->getText();
$regex_pattern = "#{$row['pattern']}#";
// Test that the correct time difference is displayed. Note that we are
// able to check an exact match for rows that have a creation date more
// distant, but we use regexp to check the entities that are only few
// seconds away because of the latency introduced by the test run.
$this->assertMatchesRegularExpression($regex_pattern, $time_diff);
}
// Wait at least 1 second + 1 millisecond to make sure the 'right now' time
// difference was refreshed.
$this->assertJsCondition("document.querySelector('.entity-$delta time').textContent >= '$time_diff'", 1001);
}
/**
* Provides data for view rows.
*
* @return array[]
* A list of row data.
*/
protected function getRowData(): array {
$now = \Drupal::time()->getRequestTime();
return [
// One year ago.
[
'pattern' => '1 year ago',
'timestamp' => $now - (60 * 60 * 24 * 365),
],
// One month ago.
[
'pattern' => '1 month ago',
'timestamp' => $now - (60 * 60 * 24 * 30),
],
// One week ago.
[
'pattern' => '1 week ago',
'timestamp' => $now - (60 * 60 * 24 * 7),
],
// One day ago.
[
'pattern' => '1 day ago',
'timestamp' => $now - (60 * 60 * 24),
],
// One hour ago.
[
'pattern' => '1 hour ago',
'timestamp' => $now - (60 * 60),
],
// One minute ago.
[
'pattern' => '\d+ minute[s]?(?: \d+ second[s]?)? ago',
'timestamp' => $now - 60,
],
// One minute hence.
[
'pattern' => '\d+ second[s]?[ hence]?',
'timestamp' => $now + 60,
],
// One hour hence.
[
'pattern' => '59 minutes \d+ second[s]? hence',
'timestamp' => $now + (60 * 60),
],
// One day hence.
[
'pattern' => '23 hours 59 minutes hence',
'timestamp' => $now + (60 * 60 * 24),
],
// One week hence.
[
'pattern' => '6 days 23 hours hence',
'timestamp' => $now + (60 * 60 * 24 * 7),
],
// A little more than 1 year hence (one year + 1 hour).
[
'pattern' => '1 year hence',
'timestamp' => $now + (60 * 60 * 24 * 365) + (60 * 60),
],
// Right now.
[
'pattern' => '\d+ second[s]?[ ago]?',
'timestamp' => $now,
],
];
}
}

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Core\Form;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests for form grouping elements.
*
* @group form
*/
class FormGroupingElementsTest extends WebDriverTestBase {
/**
* Required modules.
*
* @var array
*/
protected static $modules = ['form_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$account = $this->drupalCreateUser();
$this->drupalLogin($account);
}
/**
* Tests that vertical tab children become visible.
*
* Makes sure that a child element of a vertical tab that is not visible,
* becomes visible when the tab is clicked, a fragment link to the child is
* clicked or when the URI fragment pointing to that child changes.
*/
public function testVerticalTabChildVisibility(): void {
$session = $this->getSession();
$web_assert = $this->assertSession();
// Request the group vertical tabs testing page with a fragment identifier
// to the second element.
$this->drupalGet('form-test/group-vertical-tabs', ['fragment' => 'edit-element-2']);
$page = $session->getPage();
$tab_link_1 = $page->find('css', '.vertical-tabs__menu-item > a');
$child_1_selector = '#edit-element';
$child_1 = $page->find('css', $child_1_selector);
$child_2_selector = '#edit-element-2';
$child_2 = $page->find('css', $child_2_selector);
// Assert that the child in the second vertical tab becomes visible.
// It should be visible after initial load due to the fragment in the URI.
$this->assertTrue($child_2->isVisible(), 'Child 2 is visible due to a URI fragment');
// Click on a fragment link pointing to an invisible child inside an
// inactive vertical tab.
$session->executeScript("jQuery('<a href=\"$child_1_selector\"></a>').insertAfter('h1')[0].click()");
// Assert that the child in the first vertical tab becomes visible.
$web_assert->waitForElementVisible('css', $child_1_selector, 50);
// Trigger a URI fragment change (hashchange) to show the second vertical
// tab again.
$session->executeScript("location.replace('$child_2_selector')");
// Assert that the child in the second vertical tab becomes visible again.
$web_assert->waitForElementVisible('css', $child_2_selector, 50);
$tab_link_1->click();
// Assert that the child in the first vertical tab is visible again after
// a click on the first tab.
$this->assertTrue($child_1->isVisible(), 'Child 1 is visible after clicking the parent tab');
}
/**
* Tests that details element children become visible.
*
* Makes sure that a child element of a details element that is not visible,
* becomes visible when a fragment link to the child is clicked or when the
* URI fragment pointing to that child changes.
*/
public function testDetailsChildVisibility(): void {
$session = $this->getSession();
$web_assert = $this->assertSession();
// Store reusable JavaScript code to remove the current URI fragment and
// close all details.
$reset_js = "location.replace('#'); jQuery('details').removeAttr('open')";
// Request the group details testing page.
$this->drupalGet('form-test/group-details');
$page = $session->getPage();
$session->executeScript($reset_js);
$child_selector = '#edit-element';
$child = $page->find('css', $child_selector);
// Assert that the child is not visible.
$this->assertFalse($child->isVisible(), 'Child is not visible');
// Trigger a URI fragment change (hashchange) to open all parent details
// elements of the child.
$session->executeScript("location.replace('$child_selector')");
// Assert that the child becomes visible again after a hash change.
$web_assert->waitForElementVisible('css', $child_selector, 50);
$session->executeScript($reset_js);
// Click on a fragment link pointing to an invisible child inside a closed
// details element.
$session->executeScript("jQuery('<a href=\"$child_selector\"></a>').insertAfter('h1')[0].click()");
// Assert that the child is visible again after a fragment link click.
$web_assert->waitForElementVisible('css', $child_selector, 50);
// Find the summary belonging to the closest details element.
$summary = $page->find('css', '#edit-meta > summary');
// Assert that both aria-expanded and aria-pressed are true.
$this->assertEquals('true', $summary->getAttribute('aria-expanded'));
}
/**
* Confirms tabs containing a field with a validation error are open.
*/
public function testVerticalTabValidationVisibility(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->drupalGet('form-test/group-vertical-tabs');
$page->clickLink('Second group element');
$input_field = $assert_session->waitForField('element_2');
$this->assertNotNull($input_field);
// Enter a value that will trigger a validation error.
$input_field->setValue('bad');
// Switch to a tab that does not have the error-causing field.
$page->clickLink('First group element');
$this->assertNotNull($assert_session->waitForElementVisible('css', '#edit-meta'));
// Submit the form.
$page->pressButton('Save');
// Confirm there is an error.
$assert_session->waitForText('there was an error');
// Confirm the tab containing the field with error is open.
$this->assertNotNull($assert_session->waitForElementVisible('css', '[name="element_2"].error'));
}
/**
* Tests form submit with a required field in closed details element.
*/
public function testDetailsContainsRequiredTextfield(): void {
$this->drupalGet('form_test/details-contains-required-textfield');
$details = $this->assertSession()->elementExists('css', 'details[data-drupal-selector="edit-meta"]');
// Make sure details element is not open at the beginning.
$this->assertFalse($details->hasAttribute('open'));
$textfield = $this->assertSession()->elementExists('css', 'input[name="required_textfield_in_details"]');
// The text field inside the details element is not visible too.
$this->assertFalse($textfield->isVisible(), 'Text field is not visible');
// Submit the form with invalid data in the required fields.
$this->assertSession()
->elementExists('css', 'input[data-drupal-selector="edit-submit"]')
->click();
// Confirm the required field is visible.
$this->assertTrue($textfield->isVisible(), 'Text field is visible');
}
/**
* Tests required field in closed details element with ajax form.
*/
public function testDetailsContainsRequiredTextfieldAjaxForm(): void {
$this->drupalGet('form_test/details-contains-required-textfield/true');
$assert_session = $this->assertSession();
$textfield = $assert_session->elementExists('css', 'input[name="required_textfield_in_details"]');
// Submit the ajax form to open the details element at the first time.
$assert_session->elementExists('css', 'input[value="Submit Ajax"]')
->click();
$assert_session->waitForElementVisible('css', 'input[name="required_textfield_in_details"]');
// Close the details element.
$assert_session->elementExists('css', 'form summary')
->click();
// Submit the form with invalid data in the required fields without ajax.
$assert_session->elementExists('css', 'input[data-drupal-selector="edit-submit"]')
->click();
// Confirm the required field is visible.
$this->assertTrue($textfield->isVisible(), 'Text field is visible');
}
}

View File

@@ -0,0 +1,607 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Core\Form;
use Drupal\filter\Entity\FilterFormat;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests the state of elements based on another elements.
*
* The form being tested is JavascriptStatesForm provided by the 'form_test'
* module under 'system' (core/modules/system/tests/module/form_test).
*
* @see Drupal\form_test\Form\JavascriptStatesForm
*
* @group javascript
*/
class JavascriptStatesTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $modules = ['form_test'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Add text formats.
$filtered_html_format = FilterFormat::create([
'format' => 'filtered_html',
'name' => 'Filtered HTML',
'weight' => 0,
'filters' => [],
]);
$filtered_html_format->save();
$full_html_format = FilterFormat::create([
'format' => 'full_html',
'name' => 'Full HTML',
'weight' => 1,
'filters' => [],
]);
$full_html_format->save();
$normal_user = $this->drupalCreateUser([
'use text format filtered_html',
'use text format full_html',
]);
$this->drupalLogin($normal_user);
}
/**
* Tests the JavaScript #states functionality of form elements.
*
* To avoid the large cost of a dataProvider in FunctionalJavascript tests,
* this is a single public test method that invokes a series of protected
* methods to do assertions on specific kinds of triggering elements.
*/
public function testJavascriptStates(): void {
$this->doCheckboxTriggerTests();
$this->doCheckboxesTriggerTests();
$this->doTextfieldTriggerTests();
$this->doRadiosTriggerTests();
$this->doSelectTriggerTests();
$this->doMultipleSelectTriggerTests();
$this->doMultipleTriggerTests();
$this->doNestedTriggerTests();
$this->doElementsDisabledStateTests();
}
/**
* Tests states of elements triggered by a checkbox element.
*/
protected function doCheckboxTriggerTests() {
$this->drupalGet('form-test/javascript-states-form');
$page = $this->getSession()->getPage();
// Find trigger and target elements.
$trigger = $page->findField('checkbox_trigger');
$this->assertNotEmpty($trigger);
$textfield_invisible_element = $page->findField('textfield_invisible_when_checkbox_trigger_checked');
$this->assertNotEmpty($textfield_invisible_element);
$textfield_required_element = $page->findField('textfield_required_when_checkbox_trigger_checked');
$this->assertNotEmpty($textfield_required_element);
$textfield_readonly_element = $page->findField('textfield_readonly_when_checkbox_trigger_checked');
$this->assertNotEmpty($textfield_readonly_element);
$textarea_readonly_element = $page->findField('textarea_readonly_when_checkbox_trigger_checked');
$this->assertNotEmpty($textarea_readonly_element);
$details = $this->assertSession()->elementExists('css', '#edit-details-expanded-when-checkbox-trigger-checked');
$textfield_in_details = $details->findField('textfield_in_details');
$this->assertNotEmpty($textfield_in_details);
$checkbox_checked_element = $page->findField('checkbox_checked_when_checkbox_trigger_checked');
$this->assertNotEmpty($checkbox_checked_element);
$checkbox_unchecked_element = $page->findField('checkbox_unchecked_when_checkbox_trigger_checked');
$this->assertNotEmpty($checkbox_unchecked_element);
$checkbox_visible_element = $page->findField('checkbox_visible_when_checkbox_trigger_checked');
$this->assertNotEmpty($checkbox_visible_element);
$text_format_invisible_value = $page->findField('text_format_invisible_when_checkbox_trigger_checked[value]');
$this->assertNotEmpty($text_format_invisible_value);
$text_format_invisible_format = $page->findField('text_format_invisible_when_checkbox_trigger_checked[format]');
$this->assertNotEmpty($text_format_invisible_format);
$link = $page->findLink('Link states test');
$checkboxes_all_checked_element_value1 = $page->findField('checkboxes_all_checked_when_checkbox_trigger_checked[value1]');
$this->assertNotEmpty($checkboxes_all_checked_element_value1);
$checkboxes_all_checked_element_value2 = $page->findField('checkboxes_all_checked_when_checkbox_trigger_checked[value2]');
$this->assertNotEmpty($checkboxes_all_checked_element_value2);
$checkboxes_all_checked_element_value3 = $page->findField('checkboxes_all_checked_when_checkbox_trigger_checked[value3]');
$this->assertNotEmpty($checkboxes_all_checked_element_value3);
$checkboxes_some_checked_element_value1 = $page->findField('checkboxes_some_checked_when_checkbox_trigger_checked[value1]');
$this->assertNotEmpty($checkboxes_some_checked_element_value1);
$checkboxes_some_checked_element_value2 = $page->findField('checkboxes_some_checked_when_checkbox_trigger_checked[value2]');
$this->assertNotEmpty($checkboxes_some_checked_element_value2);
$checkboxes_some_checked_element_value3 = $page->findField('checkboxes_some_checked_when_checkbox_trigger_checked[value3]');
$this->assertNotEmpty($checkboxes_some_checked_element_value3);
$checkboxes_all_disabled_element_value1 = $page->findField('checkboxes_all_disabled_when_checkbox_trigger_checked[value1]');
$this->assertNotEmpty($checkboxes_all_disabled_element_value1);
$checkboxes_all_disabled_element_value2 = $page->findField('checkboxes_all_disabled_when_checkbox_trigger_checked[value2]');
$this->assertNotEmpty($checkboxes_all_disabled_element_value2);
$checkboxes_all_disabled_element_value3 = $page->findField('checkboxes_all_disabled_when_checkbox_trigger_checked[value3]');
$this->assertNotEmpty($checkboxes_all_disabled_element_value3);
$checkboxes_some_disabled_element_value1 = $page->findField('checkboxes_some_disabled_when_checkbox_trigger_checked[value1]');
$this->assertNotEmpty($checkboxes_some_disabled_element_value1);
$checkboxes_some_disabled_element_value2 = $page->findField('checkboxes_some_disabled_when_checkbox_trigger_checked[value2]');
$this->assertNotEmpty($checkboxes_some_disabled_element_value2);
$checkboxes_some_disabled_element_value3 = $page->findField('checkboxes_some_disabled_when_checkbox_trigger_checked[value3]');
$radios_checked_element = $page->findField('radios_checked_when_checkbox_trigger_checked');
$this->assertNotEmpty($radios_checked_element);
// We want to select the specific radio buttons, not the whole radios field itself.
$radios_all_disabled_value1 = $this->xpath('//input[@name=:name][@value=:value]', [':name' => 'radios_all_disabled_when_checkbox_trigger_checked', ':value' => 'value1']);
$this->assertCount(1, $radios_all_disabled_value1);
// We want to access the radio button directly for the rest of the test, so
// take it out of the array we got back from xpath().
$radios_all_disabled_value1 = reset($radios_all_disabled_value1);
$radios_all_disabled_value2 = $this->xpath('//input[@name=:name][@value=:value]', [':name' => 'radios_all_disabled_when_checkbox_trigger_checked', ':value' => 'value2']);
$this->assertCount(1, $radios_all_disabled_value2);
$radios_all_disabled_value2 = reset($radios_all_disabled_value2);
$radios_some_disabled_value1 = $this->xpath('//input[@name=:name][@value=:value]', [':name' => 'radios_some_disabled_when_checkbox_trigger_checked', ':value' => 'value1']);
$this->assertCount(1, $radios_some_disabled_value1);
$radios_some_disabled_value1 = reset($radios_some_disabled_value1);
$radios_some_disabled_value2 = $this->xpath('//input[@name=:name][@value=:value]', [':name' => 'radios_some_disabled_when_checkbox_trigger_checked', ':value' => 'value2']);
$this->assertCount(1, $radios_some_disabled_value2);
$radios_some_disabled_value2 = reset($radios_some_disabled_value2);
// Verify initial state.
$this->assertTrue($textfield_invisible_element->isVisible());
$this->assertFalse($details->hasAttribute('open'));
$this->assertFalse($textfield_in_details->isVisible());
$this->assertFalse($textfield_required_element->hasAttribute('required'));
$this->assertFalse($textfield_readonly_element->hasAttribute('readonly'));
$this->assertFalse($textarea_readonly_element->hasAttribute('readonly'));
$this->assertFalse($checkbox_checked_element->isChecked());
$this->assertTrue($checkbox_unchecked_element->isChecked());
$this->assertFalse($checkbox_visible_element->isVisible());
$this->assertTrue($text_format_invisible_value->isVisible());
$this->assertTrue($text_format_invisible_format->isVisible());
$this->assertFalse($checkboxes_all_checked_element_value1->isChecked());
$this->assertFalse($checkboxes_all_checked_element_value2->isChecked());
$this->assertFalse($checkboxes_all_checked_element_value3->isChecked());
$this->assertFalse($checkboxes_some_checked_element_value1->isChecked());
$this->assertFalse($checkboxes_some_checked_element_value2->isChecked());
$this->assertFalse($checkboxes_some_checked_element_value3->isChecked());
$this->assertFalse($checkboxes_all_disabled_element_value1->hasAttribute('disabled'));
$this->assertFalse($checkboxes_all_disabled_element_value2->hasAttribute('disabled'));
$this->assertFalse($checkboxes_all_disabled_element_value3->hasAttribute('disabled'));
$this->assertFalse($checkboxes_some_disabled_element_value1->hasAttribute('disabled'));
$this->assertFalse($checkboxes_some_disabled_element_value2->hasAttribute('disabled'));
$this->assertFalse($checkboxes_some_disabled_element_value3->hasAttribute('disabled'));
$this->assertFalse($radios_checked_element->isChecked());
$this->assertEquals(NULL, $radios_checked_element->getValue());
$this->assertFalse($radios_all_disabled_value1->hasAttribute('disabled'));
$this->assertFalse($radios_all_disabled_value2->hasAttribute('disabled'));
$this->assertFalse($radios_some_disabled_value1->hasAttribute('disabled'));
$this->assertFalse($radios_some_disabled_value2->hasAttribute('disabled'));
// Check if the link is visible.
$this->assertTrue($link->isVisible());
// Change state: check the checkbox.
$trigger->check();
// Verify triggered state.
$this->assertFalse($textfield_invisible_element->isVisible());
$this->assertEquals('required', $textfield_required_element->getAttribute('required'));
$this->assertTrue($textfield_readonly_element->hasAttribute('readonly'));
$this->assertTrue($textarea_readonly_element->hasAttribute('readonly'));
$this->assertTrue($details->hasAttribute('open'));
$this->assertTrue($textfield_in_details->isVisible());
$this->assertTrue($checkbox_checked_element->isChecked());
$this->assertFalse($checkbox_unchecked_element->isChecked());
$this->assertTrue($checkbox_visible_element->isVisible());
$this->assertFalse($text_format_invisible_value->isVisible());
$this->assertFalse($text_format_invisible_format->isVisible());
// All 3 of the other set should be checked.
$this->assertTrue($checkboxes_all_checked_element_value1->isChecked());
$this->assertTrue($checkboxes_all_checked_element_value2->isChecked());
$this->assertTrue($checkboxes_all_checked_element_value3->isChecked());
// Value 1 and 3 should now be checked.
$this->assertTrue($checkboxes_some_checked_element_value1->isChecked());
$this->assertTrue($checkboxes_some_checked_element_value3->isChecked());
// Only value 2 should remain unchecked.
$this->assertFalse($checkboxes_some_checked_element_value2->isChecked());
// All 3 of these should be disabled.
$this->assertTrue($checkboxes_all_disabled_element_value1->hasAttribute('disabled'));
$this->assertTrue($checkboxes_all_disabled_element_value2->hasAttribute('disabled'));
$this->assertTrue($checkboxes_all_disabled_element_value3->hasAttribute('disabled'));
// Only values 1 and 3 should be disabled, 2 should still be enabled.
$this->assertTrue($checkboxes_some_disabled_element_value1->hasAttribute('disabled'));
$this->assertFalse($checkboxes_some_disabled_element_value2->hasAttribute('disabled'));
$this->assertTrue($checkboxes_some_disabled_element_value3->hasAttribute('disabled'));
$this->assertEquals('value1', $radios_checked_element->getValue());
// Both of these should now be disabled.
$this->assertTrue($radios_all_disabled_value1->hasAttribute('disabled'));
$this->assertTrue($radios_all_disabled_value2->hasAttribute('disabled'));
// Only value1 should be disabled, value 2 should remain enabled.
$this->assertTrue($radios_some_disabled_value1->hasAttribute('disabled'));
$this->assertFalse($radios_some_disabled_value2->hasAttribute('disabled'));
// The link shouldn't be visible.
$this->assertFalse($link->isVisible());
// Change state: uncheck the checkbox.
$trigger->uncheck();
// Verify triggered state, which should match the initial state.
$this->assertTrue($textfield_invisible_element->isVisible());
$this->assertFalse($details->hasAttribute('open'));
$this->assertFalse($textfield_in_details->isVisible());
$this->assertFalse($textfield_required_element->hasAttribute('required'));
$this->assertFalse($textfield_readonly_element->hasAttribute('readonly'));
$this->assertFalse($textarea_readonly_element->hasAttribute('readonly'));
$this->assertFalse($checkbox_checked_element->isChecked());
$this->assertTrue($checkbox_unchecked_element->isChecked());
$this->assertFalse($checkbox_visible_element->isVisible());
$this->assertTrue($text_format_invisible_value->isVisible());
$this->assertTrue($text_format_invisible_format->isVisible());
$this->assertFalse($checkboxes_all_checked_element_value1->isChecked());
$this->assertFalse($checkboxes_all_checked_element_value2->isChecked());
$this->assertFalse($checkboxes_all_checked_element_value3->isChecked());
$this->assertFalse($checkboxes_some_checked_element_value1->isChecked());
$this->assertFalse($checkboxes_some_checked_element_value2->isChecked());
$this->assertFalse($checkboxes_some_checked_element_value3->isChecked());
$this->assertFalse($checkboxes_all_disabled_element_value1->hasAttribute('disabled'));
$this->assertFalse($checkboxes_all_disabled_element_value2->hasAttribute('disabled'));
$this->assertFalse($checkboxes_all_disabled_element_value3->hasAttribute('disabled'));
$this->assertFalse($checkboxes_some_disabled_element_value1->hasAttribute('disabled'));
$this->assertFalse($checkboxes_some_disabled_element_value2->hasAttribute('disabled'));
$this->assertFalse($checkboxes_some_disabled_element_value3->hasAttribute('disabled'));
$this->assertFalse($radios_checked_element->isChecked());
$this->assertEquals(NULL, $radios_checked_element->getValue());
$this->assertFalse($radios_all_disabled_value1->hasAttribute('disabled'));
$this->assertFalse($radios_all_disabled_value2->hasAttribute('disabled'));
$this->assertFalse($radios_some_disabled_value1->hasAttribute('disabled'));
$this->assertFalse($radios_some_disabled_value2->hasAttribute('disabled'));
// Check if the link is turned back to visible state.
$this->assertTrue($link->isVisible());
}
/**
* Tests states of elements triggered by a checkboxes element.
*/
protected function doCheckboxesTriggerTests() {
$this->drupalGet('form-test/javascript-states-form');
$page = $this->getSession()->getPage();
// Find trigger and target elements.
$trigger_value1 = $page->findField('checkboxes_trigger[value1]');
$this->assertNotEmpty($trigger_value1);
$trigger_value2 = $page->findField('checkboxes_trigger[value2]');
$this->assertNotEmpty($trigger_value2);
$trigger_value3 = $page->findField('checkboxes_trigger[value3]');
$this->assertNotEmpty($trigger_value3);
$textfield_visible_value2 = $page->findField('textfield_visible_when_checkboxes_trigger_value2_checked');
$this->assertNotEmpty($textfield_visible_value2);
$textfield_visible_value3 = $page->findField('textfield_visible_when_checkboxes_trigger_value3_checked');
$this->assertNotEmpty($textfield_visible_value3);
// Verify initial state.
$this->assertFalse($textfield_visible_value2->isVisible());
$this->assertFalse($textfield_visible_value3->isVisible());
// Change state: check the 'Value 1' checkbox.
$trigger_value1->check();
$this->assertFalse($textfield_visible_value2->isVisible());
$this->assertFalse($textfield_visible_value3->isVisible());
// Change state: check the 'Value 2' checkbox.
$trigger_value2->check();
$this->assertTrue($textfield_visible_value2->isVisible());
$this->assertFalse($textfield_visible_value3->isVisible());
// Change state: check the 'Value 3' checkbox.
$trigger_value3->check();
$this->assertTrue($textfield_visible_value2->isVisible());
$this->assertTrue($textfield_visible_value3->isVisible());
// Change state: uncheck the 'Value 2' checkbox.
$trigger_value2->uncheck();
$this->assertFalse($textfield_visible_value2->isVisible());
$this->assertTrue($textfield_visible_value3->isVisible());
}
/**
* Tests states of elements triggered by a textfield element.
*/
protected function doTextfieldTriggerTests() {
$this->drupalGet('form-test/javascript-states-form');
$page = $this->getSession()->getPage();
// Find trigger and target elements.
$trigger = $page->findField('textfield_trigger');
$this->assertNotEmpty($trigger);
$checkbox_checked_target = $page->findField('checkbox_checked_when_textfield_trigger_filled');
$this->assertNotEmpty($checkbox_checked_target);
$checkbox_unchecked_target = $page->findField('checkbox_unchecked_when_textfield_trigger_filled');
$this->assertNotEmpty($checkbox_unchecked_target);
$select_invisible_target = $page->findField('select_invisible_when_textfield_trigger_filled');
$this->assertNotEmpty($select_invisible_target);
$select_visible_target = $page->findField('select_visible_when_textfield_trigger_filled');
$this->assertNotEmpty($select_visible_target);
$textfield_required_target = $page->findField('textfield_required_when_textfield_trigger_filled');
$this->assertNotEmpty($textfield_required_target);
$details = $this->assertSession()->elementExists('css', '#edit-details-expanded-when-textfield-trigger-filled');
$textfield_in_details = $details->findField('textfield_in_details');
$this->assertNotEmpty($textfield_in_details);
// Verify initial state.
$this->assertFalse($checkbox_checked_target->isChecked());
$this->assertTrue($checkbox_unchecked_target->isChecked());
$this->assertTrue($select_invisible_target->isVisible());
$this->assertFalse($select_visible_target->isVisible());
$this->assertFalse($textfield_required_target->hasAttribute('required'));
$this->assertFalse($details->hasAttribute('open'));
$this->assertFalse($textfield_in_details->isVisible());
// Change state: fill the textfield.
$trigger->setValue('filled');
// Verify triggered state.
$this->assertTrue($checkbox_checked_target->isChecked());
$this->assertFalse($checkbox_unchecked_target->isChecked());
$this->assertFalse($select_invisible_target->isVisible());
$this->assertTrue($select_visible_target->isVisible());
$this->assertEquals('required', $textfield_required_target->getAttribute('required'));
$this->assertTrue($details->hasAttribute('open'));
$this->assertTrue($textfield_in_details->isVisible());
}
/**
* Tests states of elements triggered by a radios element.
*/
protected function doRadiosTriggerTests() {
$this->drupalGet('form-test/javascript-states-form');
$page = $this->getSession()->getPage();
// Find trigger and target elements.
$trigger = $page->findField('radios_trigger');
$this->assertNotEmpty($trigger);
$fieldset_visible_when_value2 = $this->assertSession()->elementExists('css', '#edit-fieldset-visible-when-radios-trigger-has-value2');
$textfield_in_fieldset = $fieldset_visible_when_value2->findField('textfield_in_fieldset');
$this->assertNotEmpty($textfield_in_fieldset);
$checkbox_checked_target = $page->findField('checkbox_checked_when_radios_trigger_has_value3');
$this->assertNotEmpty($checkbox_checked_target);
$checkbox_unchecked_target = $page->findField('checkbox_unchecked_when_radios_trigger_has_value3');
$this->assertNotEmpty($checkbox_unchecked_target);
$textfield_invisible_target = $page->findField('textfield_invisible_when_radios_trigger_has_value2');
$this->assertNotEmpty($textfield_invisible_target);
$select_required_target = $page->findField('select_required_when_radios_trigger_has_value2');
$this->assertNotEmpty($select_required_target);
$details = $this->assertSession()->elementExists('css', '#edit-details-expanded-when-radios-trigger-has-value3');
$textfield_in_details = $details->findField('textfield_in_details');
$this->assertNotEmpty($textfield_in_details);
// Verify initial state, both the fieldset and something inside it.
$this->assertFalse($fieldset_visible_when_value2->isVisible());
$this->assertFalse($textfield_in_fieldset->isVisible());
$this->assertFalse($checkbox_checked_target->isChecked());
$this->assertTrue($checkbox_unchecked_target->isChecked());
$this->assertTrue($textfield_invisible_target->isVisible());
$this->assertFalse($select_required_target->hasAttribute('required'));
$this->assertFalse($details->hasAttribute('open'));
$this->assertFalse($textfield_in_details->isVisible());
// Change state: select the value2 radios option.
$trigger->selectOption('value2');
// Verify triggered state.
$this->assertTrue($fieldset_visible_when_value2->isVisible());
$this->assertTrue($textfield_in_fieldset->isVisible());
$this->assertFalse($textfield_invisible_target->isVisible());
$this->assertTrue($select_required_target->hasAttribute('required'));
// Checkboxes and details should not have changed state, yet.
$this->assertFalse($checkbox_checked_target->isChecked());
$this->assertTrue($checkbox_unchecked_target->isChecked());
$this->assertFalse($details->hasAttribute('open'));
$this->assertFalse($textfield_in_details->isVisible());
// Change state: select the value3 radios option.
$trigger->selectOption('value3');
// Fieldset and contents should re-disappear.
$this->assertFalse($fieldset_visible_when_value2->isVisible());
$this->assertFalse($textfield_in_fieldset->isVisible());
// Textfield and select should revert to initial state.
$this->assertTrue($textfield_invisible_target->isVisible());
$this->assertFalse($select_required_target->hasAttribute('required'));
// Checkbox states should now change.
$this->assertTrue($checkbox_checked_target->isChecked());
$this->assertFalse($checkbox_unchecked_target->isChecked());
// Details should now be expanded.
$this->assertTrue($details->hasAttribute('open'));
$this->assertTrue($textfield_in_details->isVisible());
}
/**
* Tests states of elements triggered by a select element.
*/
protected function doSelectTriggerTests() {
$this->drupalGet('form-test/javascript-states-form');
$page = $this->getSession()->getPage();
// Find trigger and target elements.
$trigger = $page->findField('select_trigger');
$this->assertNotEmpty($trigger);
$item_visible_value2 = $this->assertSession()->elementExists('css', '#edit-item-visible-when-select-trigger-has-value2');
$textfield_visible_value3 = $page->findField('textfield_visible_when_select_trigger_has_value3');
$this->assertNotEmpty($textfield_visible_value3);
$textfield_visible_value2_or_value3 = $page->findField('textfield_visible_when_select_trigger_has_value2_or_value3');
$this->assertNotEmpty($textfield_visible_value2_or_value3);
// Verify initial state.
$this->assertFalse($item_visible_value2->isVisible());
$this->assertFalse($textfield_visible_value3->isVisible());
$this->assertFalse($textfield_visible_value2_or_value3->isVisible());
// Change state: select the 'Value 2' option.
$trigger->setValue('value2');
$this->assertTrue($item_visible_value2->isVisible());
$this->assertFalse($textfield_visible_value3->isVisible());
$this->assertTrue($textfield_visible_value2_or_value3->isVisible());
// Change state: select the 'Value 3' option.
$trigger->setValue('value3');
$this->assertFalse($item_visible_value2->isVisible());
$this->assertTrue($textfield_visible_value3->isVisible());
$this->assertTrue($textfield_visible_value2_or_value3->isVisible());
$this->container->get('module_installer')->install(['big_pipe']);
$this->drupalGet('form-test/javascript-states-form');
$select_visible_2 = $this->assertSession()->elementExists('css', 'select[name="select_visible_2"]');
$select_visible_3 = $this->assertSession()->elementExists('css', 'select[name="select_visible_3"]');
$this->assertFalse($select_visible_3->isVisible());
$select_visible_2->setValue('1');
$this->assertTrue($select_visible_3->isVisible());
}
/**
* Tests states of elements triggered by a multiple select element.
*/
protected function doMultipleSelectTriggerTests() {
$this->drupalGet('form-test/javascript-states-form');
$page = $this->getSession()->getPage();
// Find trigger and target elements.
$trigger = $page->findField('multiple_select_trigger[]');
$this->assertNotEmpty($trigger);
$item_visible_value2 = $this->assertSession()->elementExists('css', '#edit-item-visible-when-multiple-select-trigger-has-value2');
$item_visible_no_value = $this->assertSession()->elementExists('css', '#edit-item-visible-when-multiple-select-trigger-has-no-value');
$textfield_visible_value3 = $page->findField('textfield_visible_when_multiple_select_trigger_has_value3');
$this->assertNotEmpty($textfield_visible_value3);
$textfield_visible_value2_or_value3 = $page->findField('textfield_visible_when_multiple_select_trigger_has_value2_or_value3');
$this->assertNotEmpty($textfield_visible_value2_or_value3);
$textfield_visible_value2_and_value3 = $page->findField('textfield_visible_when_multiple_select_trigger_has_value2_and_value3');
$this->assertNotEmpty($textfield_visible_value2_and_value3);
// Verify initial state.
$this->assertFalse($item_visible_value2->isVisible());
$this->assertTrue($item_visible_no_value->isVisible());
$this->assertFalse($textfield_visible_value3->isVisible());
$this->assertFalse($textfield_visible_value2_or_value3->isVisible());
$this->assertFalse($textfield_visible_value2_and_value3->isVisible());
// Change state: select the 'Value 2' option.
$trigger->setValue('value2');
$this->assertTrue($item_visible_value2->isVisible());
$this->assertFalse($item_visible_no_value->isVisible());
$this->assertFalse($textfield_visible_value3->isVisible());
$this->assertTrue($textfield_visible_value2_or_value3->isVisible());
$this->assertFalse($textfield_visible_value2_and_value3->isVisible());
// Change state: select the 'Value 3' option.
$trigger->setValue('value3');
$this->assertFalse($item_visible_value2->isVisible());
$this->assertFalse($item_visible_no_value->isVisible());
$this->assertTrue($textfield_visible_value3->isVisible());
$this->assertTrue($textfield_visible_value2_or_value3->isVisible());
$this->assertFalse($textfield_visible_value2_and_value3->isVisible());
// Change state: select 'Value2' and 'Value 3' options.
$trigger->setValue(['value2', 'value3']);
$this->assertFalse($item_visible_value2->isVisible());
$this->assertFalse($item_visible_no_value->isVisible());
$this->assertFalse($textfield_visible_value3->isVisible());
$this->assertFalse($textfield_visible_value2_or_value3->isVisible());
$this->assertTrue($textfield_visible_value2_and_value3->isVisible());
// Restore initial trigger state (clear the values).
$trigger->setValue([]);
// Make sure the initial element states are restored.
$this->assertFalse($item_visible_value2->isVisible());
$this->assertFalse($textfield_visible_value3->isVisible());
$this->assertFalse($textfield_visible_value2_or_value3->isVisible());
// @todo These last two look to be correct, but the assertion is failing.
// @see https://www.drupal.org/project/drupal/issues/3367310
// $this->assertTrue($item_visible_no_value->isVisible());
// $this->assertFalse($textfield_visible_value2_and_value3->isVisible());
}
/**
* Tests states of elements triggered by multiple elements.
*/
protected function doMultipleTriggerTests() {
$this->drupalGet('form-test/javascript-states-form');
$page = $this->getSession()->getPage();
// Find trigger and target elements.
$select_trigger = $page->findField('select_trigger');
$this->assertNotEmpty($select_trigger);
$textfield_trigger = $page->findField('textfield_trigger');
$this->assertNotEmpty($textfield_trigger);
$item_visible_value2_and_textfield = $this->assertSession()->elementExists('css', '#edit-item-visible-when-select-trigger-has-value2-and-textfield-trigger-filled');
// Verify initial state.
$this->assertFalse($item_visible_value2_and_textfield->isVisible());
// Change state: select the 'Value 2' option.
$select_trigger->setValue('value2');
$this->assertFalse($item_visible_value2_and_textfield->isVisible());
// Change state: fill the textfield.
$textfield_trigger->setValue('filled');
$this->assertTrue($item_visible_value2_and_textfield->isVisible());
}
/**
* Tests states of radios element triggered by other radios element.
*/
protected function doNestedTriggerTests() {
$this->drupalGet('form-test/javascript-states-form');
$page = $this->getSession()->getPage();
// Find trigger and target elements.
$radios_opposite1 = $page->findField('radios_opposite1');
$this->assertNotEmpty($radios_opposite1);
$radios_opposite2 = $page->findField('radios_opposite2');
$this->assertNotEmpty($radios_opposite2);
// Verify initial state.
$this->assertEquals('0', $radios_opposite1->getValue());
$this->assertEquals('1', $radios_opposite2->getValue());
// Set $radios_opposite2 value to 0, $radios_opposite1 value should be 1.
$radios_opposite2->setValue('0');
$this->assertEquals('1', $radios_opposite1->getValue());
// Set $radios_opposite1 value to 1, $radios_opposite2 value should be 0.
$radios_opposite1->setValue('0');
$this->assertEquals('1', $radios_opposite2->getValue());
}
/**
* Tests the submit button, select and textarea disabled states.
*
* The element should be disabled when visit the form
* then they should enable when trigger by a checkbox.
*/
public function doElementsDisabledStateTests(): void {
$this->drupalGet('form-test/javascript-states-form');
$session = $this->assertSession();
// The submit button should be disabled when visit the form.
$button = $session->elementExists('css', 'input[value="Submit button disabled when checkbox not checked"]');
$this->assertTrue($button->hasAttribute('disabled'));
// The submit button should be enabled when the checkbox is checked.
$session->elementExists('css', 'input[name="checkbox_enable_submit_button"]')->check();
$this->assertFalse($button->hasAttribute('disabled'));
// The text field should be disabled when visit the form.
$textfield = $session->elementExists('css', 'input[name="input_textfield"]');
$this->assertTrue($textfield->hasAttribute('disabled'));
// The text field should be enabled when the checkbox is checked.
$session->elementExists('css', 'input[name="checkbox_enable_input_textfield"]')->check();
$this->assertFalse($textfield->hasAttribute('disabled'));
// The select should be disabled when visit the form.
$select = $session->elementExists('css', 'select[name="test_select_disabled"]');
$this->assertTrue($select->hasAttribute('disabled'));
// The select should be enabled when the checkbox is checked.
$session->elementExists('css', 'input[name="checkbox_enable_select"]')->check();
$this->assertFalse($select->hasAttribute('disabled'));
// The textarea should be disabled when visit the form.
$textarea = $session->elementExists('css', 'textarea[name="test_textarea_disabled"]');
$this->assertTrue($textarea->hasAttribute('disabled'));
// The textarea should be enabled when the checkbox is checked.
$session->elementExists('css', 'input[name="checkbox_enable_textarea"]')->check();
$this->assertFalse($textarea->hasAttribute('disabled'));
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Core;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\js_message_test\Controller\JSMessageTestController;
/**
* Tests core/drupal.message library.
*
* @group Javascript
*/
class JsMessageTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['js_message_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Enable the theme.
\Drupal::service('theme_installer')->install(['test_messages']);
$theme_config = \Drupal::configFactory()->getEditable('system.theme');
$theme_config->set('default', 'test_messages');
$theme_config->save();
}
/**
* Tests click on links to show messages and remove messages.
*/
public function testAddRemoveMessages(): void {
$web_assert = $this->assertSession();
$this->drupalGet('js_message_test_link');
$current_messages = [];
foreach (JSMessageTestController::getMessagesSelectors() as $messagesSelector) {
$web_assert->elementExists('css', $messagesSelector);
foreach (JSMessageTestController::getTypes() as $type) {
$this->click('[id="add-' . $messagesSelector . '-' . $type . '"]');
$selector = "$messagesSelector .messages.messages--$type";
$msg_element = $web_assert->waitForElementVisible('css', $selector);
$this->assertNotEmpty($msg_element, "Message element visible: $selector");
$web_assert->elementContains('css', $selector, "This is a message of the type, $type. You be the judge of its importance.");
$current_messages[$selector] = "This is a message of the type, $type. You be the judge of its importance.";
$this->assertCurrentMessages($current_messages, $messagesSelector);
}
// Remove messages 1 by 1 and confirm the messages are expected.
foreach (JSMessageTestController::getTypes() as $type) {
$this->click('[id="remove-' . $messagesSelector . '-' . $type . '"]');
$selector = "$messagesSelector .messages.messages--$type";
// The message for this selector should not be on the page.
unset($current_messages[$selector]);
$this->assertCurrentMessages($current_messages, $messagesSelector);
}
}
$messagesSelector = JSMessageTestController::getMessagesSelectors()[0];
$current_messages = [];
$types = JSMessageTestController::getTypes();
$nb_messages = count($types) * 2;
for ($i = 0; $i < $nb_messages; $i++) {
$current_messages[] = "This is message number $i of the type, {$types[$i % count($types)]}. You be the judge of its importance.";
}
// Test adding multiple messages at once.
// @see processMessages()
$this->click('[id="add-multiple"]');
$this->assertCurrentMessages($current_messages, $messagesSelector);
$this->click('[id="remove-multiple"]');
$this->assertCurrentMessages([], $messagesSelector);
$current_messages = [];
for ($i = 0; $i < $nb_messages; $i++) {
$current_messages[] = "Msg-$i";
}
// The last message is of a different type and shouldn't get cleared.
$last_message = 'Msg-' . count($current_messages);
$current_messages[] = $last_message;
$this->click('[id="add-multiple-error"]');
$this->assertCurrentMessages($current_messages, $messagesSelector);
$this->click('[id="remove-type"]');
$this->assertCurrentMessages([$last_message], $messagesSelector);
$this->click('[id="clear-all"]');
$this->assertCurrentMessages([], $messagesSelector);
// Confirm that when adding a message with an "id" specified but no status
// that it receives the default status.
$this->click('[id="id-no-status"]');
$no_status_msg = 'Msg-id-no-status';
$this->assertCurrentMessages([$no_status_msg], $messagesSelector);
$web_assert->elementTextContains('css', "$messagesSelector .messages--status[data-drupal-message-id=\"my-special-id\"]", $no_status_msg);
}
/**
* Asserts that currently shown messages match expected messages.
*
* @param array $expected_messages
* Expected messages.
* @param string $messagesSelector
* The css selector for the containing messages element.
*
* @internal
*/
protected function assertCurrentMessages(array $expected_messages, string $messagesSelector): void {
$expected_messages = array_values($expected_messages);
$current_messages = [];
if ($message_divs = $this->getSession()->getPage()->findAll('css', "$messagesSelector .messages")) {
foreach ($message_divs as $message_div) {
/** @var \Behat\Mink\Element\NodeElement $message_div */
$current_messages[] = $message_div->getText();
}
}
// Check that each message text contains the expected text.
if (count($expected_messages) !== count($current_messages)) {
$this->fail('The expected messages array contains a different number of values than the current messages array.');
}
for ($i = 0; $i < count($expected_messages); $i++) {
$this->assertStringContainsString($expected_messages[$i], $current_messages[$i]);
}
}
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Core;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests for the machine name field.
*
* @group field
*/
class MachineNameTest extends WebDriverTestBase {
/**
* Required modules.
*
* Node is required because the machine name callback checks for
* access_content.
*
* @var array
*/
protected static $modules = ['node', 'form_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$account = $this->drupalCreateUser([
'access content',
]);
$this->drupalLogin($account);
}
/**
* Tests that machine name field functions.
*
* Makes sure that the machine name field automatically provides a valid
* machine name and that the manual editing mode functions.
*/
public function testMachineName(): void {
// Visit the machine name test page which contains two machine name fields.
$this->drupalGet('form-test/machine-name');
// Test values for conversion.
$test_values = [
[
'input' => 'Test value !0-9@',
'message' => 'A title that should be transliterated must be equal to the php generated machine name',
'expected' => 'test_value_0_9',
],
[
'input' => 'Test value',
'message' => 'A title that should not be transliterated must be equal to the php generated machine name',
'expected' => 'test_value',
],
[
'input' => ' Test Value ',
'message' => 'A title that should not be transliterated must be equal to the php generated machine name',
'expected' => 'test_value',
],
[
'input' => ', Neglect?! ',
'message' => 'A title that should not be transliterated must be equal to the php generated machine name',
'expected' => 'neglect',
],
[
'input' => '0123456789!"$%&/()=?Test value?=)(/&%$"!9876543210',
'message' => 'A title that should not be transliterated must be equal to the php generated machine name',
'expected' => '0123456789_test_value_9876543210',
],
[
'input' => '_Test_Value_',
'message' => 'A title that should not be transliterated must be equal to the php generated machine name',
'expected' => 'test_value',
],
];
// Get page and session.
$page = $this->getSession()->getPage();
// Get elements from the page.
$title_1 = $page->findField('machine_name_1_label');
$machine_name_1_field = $page->findField('machine_name_1');
$machine_name_2_field = $page->findField('machine_name_2');
$machine_name_1_wrapper = $machine_name_1_field->getParent();
$machine_name_2_wrapper = $machine_name_2_field->getParent();
$machine_name_1_value = $page->find('css', '#edit-machine-name-1-label-machine-name-suffix .machine-name-value');
$machine_name_2_value = $page->find('css', '#edit-machine-name-2-label-machine-name-suffix .machine-name-value');
$machine_name_3_value = $page->find('css', '#edit-machine-name-3-label-machine-name-suffix .machine-name-value');
$button_1 = $page->find('css', '#edit-machine-name-1-label-machine-name-suffix button.link');
// Assert all fields are initialized correctly.
$this->assertNotEmpty($machine_name_1_value, 'Machine name field 1 must be initialized');
$this->assertNotEmpty($machine_name_2_value, 'Machine name field 2 must be initialized');
$this->assertNotEmpty($machine_name_3_value, 'Machine name field 3 must be initialized');
// Assert that a machine name based on a default value is initialized.
$this->assertJsCondition('jQuery("#edit-machine-name-3-label-machine-name-suffix .machine-name-value").html() == "yet_another_machine_name"');
// Test each value for conversion to a machine name.
foreach ($test_values as $test_info) {
// Set the value for the field, triggering the machine name update.
$title_1->setValue($test_info['input']);
// Wait the set timeout for fetching the machine name.
$this->assertJsCondition('jQuery("#edit-machine-name-1-label-machine-name-suffix .machine-name-value").html() == "' . $test_info['expected'] . '"');
// Validate the generated machine name.
$this->assertEquals($test_info['expected'], $machine_name_1_value->getHtml(), $test_info['message']);
// Validate the second machine name field is empty.
$this->assertEmpty($machine_name_2_value->getHtml(), 'The second machine name field should still be empty');
}
// Validate the machine name field is hidden.
$this->assertFalse($machine_name_1_wrapper->isVisible(), 'The ID field must not be visible');
$this->assertFalse($machine_name_2_wrapper->isVisible(), 'The ID field must not be visible');
// Test switching back to the manual editing mode by clicking the edit link.
$button_1->click();
// Validate the visibility of the machine name field.
$this->assertTrue($machine_name_1_wrapper->isVisible(), 'The ID field must now be visible');
// Validate the visibility of the second machine name field.
$this->assertFalse($machine_name_2_wrapper->isVisible(), 'The ID field must not be visible');
// Validate if the element contains the correct value.
$this->assertEquals(end($test_values)['expected'], $machine_name_1_field->getValue(), 'The ID field value must be equal to the php generated machine name');
// Test that machine name generation still occurs after an HTML 5
// validation failure.
$this->drupalGet('form-test/machine-name');
$this->assertSession()->buttonExists('Submit')->press();
// Assert all fields are initialized correctly.
$this->assertNotEmpty($machine_name_1_value, 'Machine name field 1 must be initialized');
$this->assertNotEmpty($machine_name_2_value, 'Machine name field 2 must be initialized');
$this->assertNotEmpty($machine_name_3_value, 'Machine name field 3 must be initialized');
// Assert that a machine name based on a default value is initialized.
$this->assertJsCondition('jQuery("#edit-machine-name-3-label-machine-name-suffix .machine-name-value").html() == "yet_another_machine_name"');
// Test each value for conversion to a machine name.
foreach ($test_values as $test_info) {
// Set the value for the field, triggering the machine name update.
$title_1->setValue($test_info['input']);
// Wait the set timeout for fetching the machine name.
$this->assertJsCondition('jQuery("#edit-machine-name-1-label-machine-name-suffix .machine-name-value").html() == "' . $test_info['expected'] . '"');
// Validate the generated machine name.
$this->assertEquals($test_info['expected'], $machine_name_1_value->getHtml(), $test_info['message']);
// Validate the second machine name field is empty.
$this->assertEmpty($machine_name_2_value->getHtml(), 'The second machine name field should still be empty');
}
// Validate the machine name field is hidden. Elements are visually hidden
// using positioning, isVisible() will therefore not work.
$this->assertTrue($machine_name_1_wrapper->hasClass('hidden'), 'The ID field must not be visible');
$this->assertTrue($machine_name_2_wrapper->hasClass('hidden'), 'The ID field must not be visible');
// Test switching back to the manual editing mode by clicking the edit link.
$button_1->click();
// Validate the visibility of the machine name field.
$this->assertFalse($machine_name_1_wrapper->hasClass('hidden'), 'The ID field must now be visible');
// Validate the visibility of the second machine name field.
$this->assertTrue($machine_name_2_wrapper->hasClass('hidden'), 'The ID field must not be visible');
// Validate if the element contains the correct value.
$this->assertEquals($test_values[1]['expected'], $machine_name_1_field->getValue(), 'The ID field value must be equal to the php generated machine name');
$assert = $this->assertSession();
$this->drupalGet('/form-test/form-test-machine-name-validation');
// Test errors after with no AJAX.
$assert->buttonExists('Save')->press();
$assert->pageTextContains('Machine-readable name field is required.');
// Ensure only the first machine name field has an error.
$this->assertTrue($assert->fieldExists('id')->hasClass('error'));
$this->assertFalse($assert->fieldExists('id2')->hasClass('error'));
// Test a successful submit after using AJAX.
$assert->fieldExists('Name')->setValue('test 1');
$machine_name_value = $page->find('css', '#edit-name-machine-name-suffix .machine-name-value');
$this->assertNotEmpty($machine_name_value, 'Machine name field must be initialized');
$this->assertJsCondition('jQuery("#edit-name-machine-name-suffix .machine-name-value").html() == "' . 'test_1' . '"');
// Ensure that machine name generation still occurs after a non-HTML 5
// validation failure.
$this->assertEquals('test_1', $machine_name_value->getHtml(), $test_values[1]['message']);
$machine_name_wrapper = $page->find('css', '#edit-id')->getParent();
// Machine name field should not expand after failing validation.
$this->assertTrue($machine_name_wrapper->hasClass('hidden'), 'The ID field must not be visible');
$assert->selectExists('snack')->selectOption('apple');
$assert->assertWaitOnAjaxRequest();
$assert->buttonExists('Save')->press();
$assert->pageTextContains('The form_test_machine_name_validation_form form has been submitted successfully.');
// Test errors after using AJAX.
$assert->fieldExists('Name')->setValue('duplicate');
$this->assertJsCondition('document.forms[0].id.value === "duplicate"');
$assert->fieldExists('id2')->setValue('duplicate2');
$assert->selectExists('snack')->selectOption('potato');
$assert->assertWaitOnAjaxRequest();
$assert->buttonExists('Save')->press();
$assert->pageTextContains('The machine-readable name is already in use. It must be unique.');
// Ensure both machine name fields both have errors.
$this->assertTrue($assert->fieldExists('id')->hasClass('error'));
$this->assertTrue($assert->fieldExists('id2')->hasClass('error'));
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Core\Session;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\menu_link_content\Entity\MenuLinkContent;
/**
* Tests that sessions don't expire.
*
* @group session
*/
class SessionTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['menu_link_content', 'block'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$account = $this->drupalCreateUser();
$this->drupalLogin($account);
$menu_link_content = MenuLinkContent::create([
'title' => 'Link to front page',
'menu_name' => 'tools',
'link' => ['uri' => 'route:<front>'],
]);
$menu_link_content->save();
$this->drupalPlaceBlock('system_menu_block:tools');
}
/**
* Tests that the session doesn't expire.
*
* Makes sure that drupal_valid_test_ua() works for multiple requests
* performed by the Mink browser. The SIMPLETEST_USER_AGENT cookie must always
* be valid.
*/
public function testSessionExpiration(): void {
// Visit the front page and click the link back to the front page a large
// number of times.
$this->drupalGet('<front>');
$page = $this->getSession()->getPage();
for ($i = 0; $i < 25; $i++) {
$page->clickLink('Link to front page');
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Dialog;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests the JavaScript functionality of the dialog position.
*
* @group dialog
*/
class DialogPositionTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['block'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests if the dialog UI works properly with block layout page.
*/
public function testDialogOpenAndClose(): void {
$admin_user = $this->drupalCreateUser(['administer blocks']);
$this->drupalLogin($admin_user);
$this->drupalGet('admin/structure/block');
$session = $this->getSession();
$assert_session = $this->assertSession();
$page = $session->getPage();
// Open the dialog using the place block link.
$placeBlockLink = $page->findLink('Place block');
$this->assertTrue($placeBlockLink->isVisible(), 'Place block button exists.');
$placeBlockLink->click();
$assert_session->assertWaitOnAjaxRequest();
$dialog = $page->find('css', '.ui-dialog');
$this->assertTrue($dialog->isVisible(), 'Dialog is opened after clicking the Place block button.');
// Close the dialog again.
$closeButton = $page->find('css', '.ui-dialog-titlebar-close');
$closeButton->click();
$dialog = $page->find('css', '.ui-dialog');
$this->assertNull($dialog, 'Dialog is closed after clicking the close button.');
// Resize the window. The test should pass after waiting for JavaScript to
// finish as no Javascript errors should have been triggered. If there were
// javascript errors the test will fail on that.
$session->resizeWindow(625, 625);
usleep(5000);
}
}

View File

@@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests;
use Behat\Mink\Driver\Selenium2Driver;
use Behat\Mink\Exception\DriverException;
use WebDriver\Element;
use WebDriver\Exception;
use WebDriver\Exception\UnknownError;
use WebDriver\ServiceFactory;
/**
* Provides a driver for Selenium testing.
*/
class DrupalSelenium2Driver extends Selenium2Driver {
/**
* {@inheritdoc}
*/
public function __construct($browserName = 'firefox', $desiredCapabilities = NULL, $wdHost = 'http://localhost:4444/wd/hub') {
parent::__construct($browserName, $desiredCapabilities, $wdHost);
ServiceFactory::getInstance()->setServiceClass('service.curl', WebDriverCurlService::class);
}
/**
* {@inheritdoc}
*/
public function setCookie($name, $value = NULL) {
if ($value === NULL) {
$this->getWebDriverSession()->deleteCookie($name);
return;
}
$cookieArray = [
'name' => $name,
'value' => urlencode($value),
'secure' => FALSE,
// Unlike \Behat\Mink\Driver\Selenium2Driver::setCookie we set a domain
// and an expire date, as otherwise cookies leak from one test site into
// another.
'domain' => parse_url($this->getWebDriverSession()->url(), PHP_URL_HOST),
'expires' => time() + 80000,
];
$this->getWebDriverSession()->setCookie($cookieArray);
}
/**
* Uploads a file to the Selenium instance and returns the remote path.
*
* \Behat\Mink\Driver\Selenium2Driver::uploadFile() is a private method so
* that can't be used inside a test, but we need the remote path that is
* generated when uploading to make sure the file reference exists on the
* container running selenium.
*
* @param string $path
* The path to the file to upload.
*
* @return string
* The remote path.
*
* @throws \Behat\Mink\Exception\DriverException
* When PHP is compiled without zip support, or the file doesn't exist.
* @throws \WebDriver\Exception\UnknownError
* When an unknown error occurred during file upload.
* @throws \Exception
* When a known error occurred during file upload.
*/
public function uploadFileAndGetRemoteFilePath($path) {
if (!is_file($path)) {
throw new DriverException('File does not exist locally and cannot be uploaded to the remote instance.');
}
if (!class_exists('ZipArchive')) {
throw new DriverException('Could not compress file, PHP is compiled without zip support.');
}
// Selenium only accepts uploads that are compressed as a Zip archive.
$tempFilename = tempnam('', 'WebDriverZip');
$archive = new \ZipArchive();
$result = $archive->open($tempFilename, \ZipArchive::OVERWRITE);
if (!$result) {
throw new DriverException('Zip archive could not be created. Error ' . $result);
}
$result = $archive->addFile($path, basename($path));
if (!$result) {
throw new DriverException('File could not be added to zip archive.');
}
$result = $archive->close();
if (!$result) {
throw new DriverException('Zip archive could not be closed.');
}
try {
$remotePath = $this->getWebDriverSession()->file(['file' => base64_encode(file_get_contents($tempFilename))]);
// If no path is returned the file upload failed silently.
if (empty($remotePath)) {
throw new UnknownError();
}
}
catch (\Exception $e) {
throw $e;
}
finally {
unlink($tempFilename);
}
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 {
$element = $this->getWebDriverSession()->element('xpath', $xpath);
// \Behat\Mink\Driver\Selenium2Driver::setValue() will call .blur() on
// the element, modify that to trigger the "input" and "change" events
// instead. They indicate the value has changed, rather than implying
// user focus changes. This script only runs when Drupal javascript has
// been loaded.
$this->executeJsOnElement($element, <<<JS
if (typeof Drupal !== 'undefined') {
var node = {{ELEMENT}};
var original = node.blur;
node.blur = function() {
node.dispatchEvent(new Event("input", {bubbles:true}));
node.dispatchEvent(new Event("change", {bubbles:true}));
// Do not wait for the debounce, which only triggers the 'formUpdated` event
// up to once every 0.3 seconds. In tests, no humans are typing, hence there
// is no need to debounce.
// @see Drupal.behaviors.formUpdated
node.dispatchEvent(new Event("formUpdated", {bubbles:true}));
node.blur = original;
};
}
JS);
if (!is_string($value) && strtolower($element->name()) === 'input' && in_array(strtolower($element->attribute('type')), ['text', 'number', 'radio'], TRUE)) {
// @todo Trigger deprecation in
// https://www.drupal.org/project/drupal/issues/3421105.
$value = (string) $value;
}
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;
}
/**
* {@inheritdoc}
*/
public function dragTo($sourceXpath, $destinationXpath) {
// Ensure both the source and destination exist at this point.
$this->getWebDriverSession()->element('xpath', $sourceXpath);
$this->getWebDriverSession()->element('xpath', $destinationXpath);
try {
parent::dragTo($sourceXpath, $destinationXpath);
}
catch (Exception $e) {
// Do not care if this fails for any reason. It is a source of random
// fails. The calling code should be doing assertions on the results of
// dragging anyway. See upstream issues:
// - https://github.com/minkphp/MinkSelenium2Driver/issues/97
// - https://github.com/minkphp/MinkSelenium2Driver/issues/51
}
}
/**
* Executes JS on a given element.
*
* @param \WebDriver\Element $element
* The webdriver element.
* @param string $script
* The script to execute.
*
* @return mixed
* The result of executing the script.
*/
private function executeJsOnElement(Element $element, string $script) {
$script = str_replace('{{ELEMENT}}', 'arguments[0]', $script);
$options = [
'script' => $script,
'args' => [$element],
];
return $this->getWebDriverSession()->execute($options);
}
}

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\EntityReference;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\Tests\field\Traits\EntityReferenceFieldCreationTrait;
use Drupal\Core\Entity\Entity\EntityFormDisplay;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
use Drupal\Tests\node\Traits\NodeCreationTrait;
/**
* Tests the output of entity reference autocomplete widgets.
*
* @group entity_reference
*/
class EntityReferenceAutocompleteWidgetTest extends WebDriverTestBase {
use ContentTypeCreationTrait;
use EntityReferenceFieldCreationTrait;
use NodeCreationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'field_ui',
'entity_test',
'entity_reference_test',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create a Content type and two test nodes.
$this->createContentType(['type' => 'page']);
$this->createNode(['title' => 'Test page']);
$this->createNode(['title' => 'Page test']);
$user = $this->drupalCreateUser([
'access content',
'create page content',
]);
$this->drupalLogin($user);
}
/**
* Tests that the default autocomplete widget return the correct results.
*/
public function testEntityReferenceAutocompleteWidget(): void {
/** @var \Drupal\Core\Entity\EntityDisplayRepositoryInterface $display_repository */
$display_repository = \Drupal::service('entity_display.repository');
// Create an entity reference field and use the default 'CONTAINS' match
// operator.
$field_name = 'field_test';
$this->createEntityReferenceField('node', 'page', $field_name, $field_name, 'node', 'default', ['target_bundles' => ['page'], 'sort' => ['field' => 'title', 'direction' => 'DESC']]);
$form_display = $display_repository->getFormDisplay('node', 'page');
$form_display->setComponent($field_name, [
'type' => 'entity_reference_autocomplete',
'settings' => [
'match_operator' => 'CONTAINS',
],
]);
// To satisfy config schema, the size setting must be an integer, not just
// a numeric value. See https://www.drupal.org/node/2885441.
$this->assertIsInt($form_display->getComponent($field_name)['settings']['size']);
$form_display->save();
$this->assertIsInt($form_display->getComponent($field_name)['settings']['size']);
// Visit the node add page.
$this->drupalGet('node/add/page');
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$autocomplete_field = $assert_session->waitForElement('css', '[name="' . $field_name . '[0][target_id]"].ui-autocomplete-input');
$autocomplete_field->setValue('Test');
$this->getSession()->getDriver()->keyDown($autocomplete_field->getXpath(), ' ');
$assert_session->waitOnAutocomplete();
$results = $page->findAll('css', '.ui-autocomplete li');
$this->assertCount(2, $results);
$assert_session->pageTextContains('Test page');
$assert_session->pageTextContains('Page test');
// Now switch the autocomplete widget to the 'STARTS_WITH' match operator.
$display_repository->getFormDisplay('node', 'page')
->setComponent($field_name, [
'type' => 'entity_reference_autocomplete',
'settings' => [
'match_operator' => 'STARTS_WITH',
],
])
->save();
$this->drupalGet('node/add/page');
$this->doAutocomplete($field_name);
$results = $page->findAll('css', '.ui-autocomplete li');
$this->assertCount(1, $results);
$assert_session->pageTextContains('Test page');
$assert_session->pageTextNotContains('Page test');
// Change the size of the result set.
$display_repository->getFormDisplay('node', 'page')
->setComponent($field_name, [
'type' => 'entity_reference_autocomplete',
'settings' => [
'match_limit' => 1,
],
])
->save();
$this->drupalGet('node/add/page');
$this->doAutocomplete($field_name);
$results = $page->findAll('css', '.ui-autocomplete li');
$this->assertCount(1, $results);
$assert_session->pageTextContains('Test page');
$assert_session->pageTextNotContains('Page test');
// Change the size of the result set via the UI.
$this->drupalLogin($this->createUser([
'access content',
'administer content types',
'administer node fields',
'administer node form display',
'create page content',
]));
$this->drupalGet('/admin/structure/types/manage/page/form-display');
$assert_session->pageTextContains('Autocomplete suggestion list size: 1');
// Click on the widget settings button to open the widget settings form.
$this->submitForm([], $field_name . "_settings_edit");
$this->assertSession()->waitForElement('css', sprintf('[name="fields[%s][settings_edit_form][settings][match_limit]"]', $field_name));
$page->fillField('Number of results', 2);
$page->pressButton('Save');
$assert_session->pageTextContains('Your settings have been saved.');
$assert_session->pageTextContains('Autocomplete suggestion list size: 2');
$this->drupalGet('node/add/page');
$this->doAutocomplete($field_name);
$this->assertCount(2, $page->findAll('css', '.ui-autocomplete li'));
}
/**
* Tests that the autocomplete widget knows about the entity its attached to.
*
* Ensures that the entity the autocomplete widget stores the entity it is
* rendered on, and is available in the autocomplete results' AJAX request.
*/
public function testEntityReferenceAutocompleteWidgetAttachedEntity(): void {
$user = $this->drupalCreateUser([
'administer entity_test content',
]);
$this->drupalLogin($user);
$field_name = 'field_test';
$this->createEntityReferenceField('entity_test', 'entity_test', $field_name, $field_name, 'entity_test', 'entity_test_all_except_host', ['target_bundles' => ['entity_test']]);
$form_display = EntityFormDisplay::load('entity_test.entity_test.default');
$form_display->setComponent($field_name, [
'type' => 'entity_reference_autocomplete',
'settings' => [
'match_operator' => 'CONTAINS',
],
]);
$form_display->save();
$host = EntityTest::create(['name' => 'dark green']);
$host->save();
EntityTest::create(['name' => 'dark blue'])->save();
$this->drupalGet($host->toUrl('edit-form'));
// Trigger the autocomplete.
$page = $this->getSession()->getPage();
$autocomplete_field = $page->findField($field_name . '[0][target_id]');
$autocomplete_field->setValue('dark');
$this->getSession()->getDriver()->keyDown($autocomplete_field->getXpath(), ' ');
$this->assertSession()->waitOnAutocomplete();
// Check the autocomplete results.
$results = $page->findAll('css', '.ui-autocomplete li');
$this->assertCount(1, $results);
$this->assertSession()->elementTextNotContains('css', '.ui-autocomplete li', 'dark green');
$this->assertSession()->elementTextContains('css', '.ui-autocomplete li', 'dark blue');
}
/**
* Executes an autocomplete on a given field and waits for it to finish.
*
* @param string $field_name
* The field name.
*/
protected function doAutocomplete($field_name) {
$autocomplete_field = $this->getSession()->getPage()->findField($field_name . '[0][target_id]');
$autocomplete_field->setValue('Test');
$this->getSession()->getDriver()->keyDown($autocomplete_field->getXpath(), ' ');
$this->assertSession()->waitOnAutocomplete();
}
}

View File

@@ -0,0 +1,729 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests;
use Behat\Mink\Element\Element;
use Behat\Mink\Element\NodeElement;
use Behat\Mink\Exception\ElementHtmlException;
use Behat\Mink\Exception\ElementNotFoundException;
use Behat\Mink\Exception\UnsupportedDriverActionException;
use Drupal\Tests\WebAssert;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Constraint\IsNull;
use PHPUnit\Framework\Constraint\LogicalNot;
use WebDriver\Exception;
// cspell:ignore interactable
/**
* Defines a class with methods for asserting presence of elements during tests.
*/
class JSWebAssert extends WebAssert {
/**
* Waits for AJAX request to be completed.
*
* @param int $timeout
* (Optional) Timeout in milliseconds, defaults to 10000.
* @param string $message
* (optional) A message for exception.
*
* @throws \RuntimeException
* When the request is not completed. If left blank, a default message will
* be displayed.
*/
public function assertWaitOnAjaxRequest($timeout = 10000, $message = 'Unable to complete AJAX request.'): void {
$this->assertExpectedAjaxRequest(NULL, $timeout, $message);
}
/**
* Asserts that an AJAX request has been completed.
*
* @param int|null $count
* (Optional) The number of completed AJAX requests expected.
* @param int $timeout
* (Optional) Timeout in milliseconds, defaults to 10000.
* @param string $message
* (optional) A message for exception.
*
* @throws \RuntimeException
* When the request is not completed. If left blank, a default message will
* be displayed.
*/
public function assertExpectedAjaxRequest(?int $count = NULL, $timeout = 10000, $message = 'Unable to complete AJAX request.'): void {
// Wait for a very short time to allow page state to update after clicking.
usleep(5000);
$condition = <<<JS
(function() {
function isAjaxing(instance) {
return instance && instance.ajaxing === true;
}
return (
// Assert at least one AJAX request was started and completed.
// For example, the machine name UI component does not use the Drupal
// AJAX system, which means the other two checks below are inadequate.
// @see Drupal.behaviors.machineName
window.drupalActiveXhrCount === 0 && window.drupalCumulativeXhrCount >= 1 &&
// Assert no AJAX request is running (via jQuery or Drupal) and no
// animation is running.
(typeof jQuery === 'undefined' || (jQuery.active === 0 && jQuery(':animated').length === 0)) &&
(typeof Drupal === 'undefined' || typeof Drupal.ajax === 'undefined' || !Drupal.ajax.instances.some(isAjaxing))
);
}())
JS;
$completed = $this->session->wait($timeout, $condition);
// Now that there definitely is no more AJAX request in progress, count the
// number of AJAX responses.
// @see core/modules/system/tests/modules/js_testing_ajax_request_test/js/js_testing_ajax_request_test.js
// @see https://developer.mozilla.org/en-US/docs/Web/API/Performance/timeOrigin
[$drupal_ajax_request_count, $browser_xhr_request_count, $page_hash] = $this->session->evaluateScript(<<<JS
(function(){
return [
window.drupalCumulativeXhrCount,
window.performance
.getEntries()
.filter(entry => entry.initiatorType === 'xmlhttprequest')
.length,
window.performance.timeOrigin
];
})()
JS);
// First invocation of ::assertWaitOnAjaxRequest() on this page: initialize.
static $current_page_hash;
static $current_page_ajax_response_count;
if ($current_page_hash !== $page_hash) {
$current_page_hash = $page_hash;
$current_page_ajax_response_count = 0;
}
// Detect unnecessary AJAX request waits and inform the test author.
if ($drupal_ajax_request_count === $current_page_ajax_response_count) {
@trigger_error(sprintf('%s called unnecessarily in a test is deprecated in drupal:10.2.0 and will throw an exception in drupal:11.0.0. See https://www.drupal.org/node/3401201', __METHOD__), E_USER_DEPRECATED);
}
// Detect untracked AJAX requests. This will alert if the detection is
// failing to provide an accurate count of requests.
// @see core/modules/system/tests/modules/js_testing_ajax_request_test/js/js_testing_ajax_request_test.js
if (!is_null($count) && $drupal_ajax_request_count !== $browser_xhr_request_count) {
throw new \RuntimeException(sprintf('%d XHR requests through jQuery, but %d observed in the browser — this requires js_testing_ajax_request_test.js to be updated.', $drupal_ajax_request_count, $browser_xhr_request_count));
}
// Detect incomplete AJAX request.
if (!$completed) {
throw new \RuntimeException($message);
}
// Update the static variable for the next invocation, to allow detecting
// unnecessary invocations.
$current_page_ajax_response_count = $drupal_ajax_request_count;
if (!is_null($count)) {
Assert::assertSame($count, $drupal_ajax_request_count);
}
}
/**
* Waits for the specified selector and returns it when available.
*
* @param string $selector
* The selector engine name. See ElementInterface::findAll() for the
* supported selectors.
* @param string|array $locator
* The selector locator.
* @param int $timeout
* (Optional) Timeout in milliseconds, defaults to 10000.
*
* @return \Behat\Mink\Element\NodeElement|null
* The page element node if found, NULL if not.
*
* @see \Behat\Mink\Element\ElementInterface::findAll()
*/
public function waitForElement($selector, $locator, $timeout = 10000) {
return $this->waitForHelper($timeout, function (Element $page) use ($selector, $locator) {
return $page->find($selector, $locator);
});
}
/**
* Looks for the specified selector and returns TRUE when it is unavailable.
*
* @param string $selector
* The selector engine name. See ElementInterface::findAll() for the
* supported selectors.
* @param string|array $locator
* The selector locator.
* @param int $timeout
* (Optional) Timeout in milliseconds, defaults to 10000.
*
* @return bool
* TRUE if not found, FALSE if found.
*
* @see \Behat\Mink\Element\ElementInterface::findAll()
*/
public function waitForElementRemoved($selector, $locator, $timeout = 10000) {
return (bool) $this->waitForHelper($timeout, function (Element $page) use ($selector, $locator) {
return !$page->find($selector, $locator);
});
}
/**
* Waits for the specified selector and returns it when available and visible.
*
* @param string $selector
* The selector engine name. See ElementInterface::findAll() for the
* supported selectors.
* @param string|array $locator
* The selector locator.
* @param int $timeout
* (Optional) Timeout in milliseconds, defaults to 10000.
*
* @return \Behat\Mink\Element\NodeElement|null
* The page element node if found and visible, NULL if not.
*
* @see \Behat\Mink\Element\ElementInterface::findAll()
*/
public function waitForElementVisible($selector, $locator, $timeout = 10000) {
return $this->waitForHelper($timeout, function (Element $page) use ($selector, $locator) {
$element = $page->find($selector, $locator);
if (!empty($element) && $element->isVisible()) {
return $element;
}
return NULL;
});
}
/**
* Waits for the specified text and returns TRUE when it is available.
*
* @param string $text
* The text to wait for.
* @param int $timeout
* (Optional) Timeout in milliseconds, defaults to 10000.
*
* @return bool
* TRUE if found, FALSE if not found.
*/
public function waitForText($text, $timeout = 10000) {
return (bool) $this->waitForHelper($timeout, function (Element $page) use ($text) {
$actual = preg_replace('/\s+/u', ' ', $page->getText());
$regex = '/' . preg_quote($text, '/') . '/ui';
return (bool) preg_match($regex, $actual);
});
}
/**
* Wraps waits in a function to catch curl exceptions to continue waiting.
*
* @param int $timeout
* Timeout in milliseconds.
* @param callable $callback
* Callback, which result is both used as waiting condition and returned.
*
* @return mixed
* The result of $callback.
*/
private function waitForHelper(int $timeout, callable $callback) {
return $this->session->getPage()->waitFor($timeout / 1000, $callback);
}
/**
* Waits for the button specified by the locator and returns it.
*
* @param string $locator
* The button ID, value or alt string.
* @param int $timeout
* (Optional) Timeout in milliseconds, defaults to 10000.
*
* @return \Behat\Mink\Element\NodeElement|null
* The page element node if found, NULL if not.
*/
public function waitForButton($locator, $timeout = 10000) {
return $this->waitForElement('named', ['button', $locator], $timeout);
}
/**
* Waits for a link with specified locator and returns it when available.
*
* @param string $locator
* The link ID, title, text or image alt.
* @param int $timeout
* (Optional) Timeout in milliseconds, defaults to 10000.
*
* @return \Behat\Mink\Element\NodeElement|null
* The page element node if found, NULL if not.
*/
public function waitForLink($locator, $timeout = 10000) {
return $this->waitForElement('named', ['link', $locator], $timeout);
}
/**
* Waits for a field with specified locator and returns it when available.
*
* @param string $locator
* The input ID, name or label for the field (input, textarea, select).
* @param int $timeout
* (Optional) Timeout in milliseconds, defaults to 10000.
*
* @return \Behat\Mink\Element\NodeElement|null
* The page element node if found, NULL if not.
*/
public function waitForField($locator, $timeout = 10000) {
return $this->waitForElement('named', ['field', $locator], $timeout);
}
/**
* Waits for an element by its id and returns it when available.
*
* @param string $id
* The element ID.
* @param int $timeout
* (Optional) Timeout in milliseconds, defaults to 10000.
*
* @return \Behat\Mink\Element\NodeElement|null
* The page element node if found, NULL if not.
*/
public function waitForId($id, $timeout = 10000) {
return $this->waitForElement('named', ['id', $id], $timeout);
}
/**
* Waits for the jQuery autocomplete delay duration.
*
* @see https://api.jqueryui.com/autocomplete/#option-delay
*/
public function waitOnAutocomplete() {
// Wait for the autocomplete to be visible.
return $this->waitForElementVisible('css', '.ui-autocomplete li');
}
/**
* Tests that a node, or its specific corner, is visible in the viewport.
*
* Note: Always set the viewport size. This can be done in your test with
* \Behat\Mink\Session->resizeWindow(). Drupal CI JavaScript tests by default
* use a viewport of 1024x768px.
*
* @param string $selector_type
* The element selector type (css, xpath).
* @param string|array $selector
* The element selector. Note: the first found element is used.
* @param bool|string $corner
* (Optional) The corner to test:
* topLeft, topRight, bottomRight, bottomLeft.
* Or FALSE to check the complete element (default).
* @param string $message
* (optional) A message for the exception.
*
* @throws \Behat\Mink\Exception\ElementHtmlException
* When the element doesn't exist.
* @throws \Behat\Mink\Exception\ElementNotFoundException
* When the element is not visible in the viewport.
*/
public function assertVisibleInViewport($selector_type, $selector, $corner = FALSE, $message = 'Element is not visible in the viewport.') {
$node = $this->session->getPage()->find($selector_type, $selector);
if ($node === NULL) {
if (is_array($selector)) {
$selector = implode(' ', $selector);
}
throw new ElementNotFoundException($this->session->getDriver(), 'element', $selector_type, $selector);
}
// Check if the node is visible on the page, which is a prerequisite of
// being visible in the viewport.
if (!$node->isVisible()) {
throw new ElementHtmlException($message, $this->session->getDriver(), $node);
}
$result = $this->checkNodeVisibilityInViewport($node, $corner);
if (!$result) {
throw new ElementHtmlException($message, $this->session->getDriver(), $node);
}
}
/**
* Tests that a node, or its specific corner, is not visible in the viewport.
*
* Note: the node should exist in the page, otherwise this assertion fails.
*
* @param string $selector_type
* The element selector type (css, xpath).
* @param string|array $selector
* The element selector. Note: the first found element is used.
* @param bool|string $corner
* (Optional) Corner to test: topLeft, topRight, bottomRight, bottomLeft.
* Or FALSE to check the complete element (default).
* @param string $message
* (optional) A message for the exception.
*
* @throws \Behat\Mink\Exception\ElementHtmlException
* When the element doesn't exist.
* @throws \Behat\Mink\Exception\ElementNotFoundException
* When the element is not visible in the viewport.
*
* @see \Drupal\FunctionalJavascriptTests\JSWebAssert::assertVisibleInViewport()
*/
public function assertNotVisibleInViewport($selector_type, $selector, $corner = FALSE, $message = 'Element is visible in the viewport.') {
$node = $this->session->getPage()->find($selector_type, $selector);
if ($node === NULL) {
if (is_array($selector)) {
$selector = implode(' ', $selector);
}
throw new ElementNotFoundException($this->session->getDriver(), 'element', $selector_type, $selector);
}
$result = $this->checkNodeVisibilityInViewport($node, $corner);
if ($result) {
throw new ElementHtmlException($message, $this->session->getDriver(), $node);
}
}
/**
* Check the visibility of a node, or its specific corner.
*
* @param \Behat\Mink\Element\NodeElement $node
* A valid node.
* @param bool|string $corner
* (Optional) Corner to test: topLeft, topRight, bottomRight, bottomLeft.
* Or FALSE to check the complete element (default).
*
* @return bool
* Returns TRUE if the node is visible in the viewport, FALSE otherwise.
*
* @throws \Behat\Mink\Exception\UnsupportedDriverActionException
* When an invalid corner specification is given.
*/
private function checkNodeVisibilityInViewport(NodeElement $node, $corner = FALSE) {
$xpath = $node->getXpath();
// Build the JavaScript to test if the complete element or a specific corner
// is in the viewport.
switch ($corner) {
case 'topLeft':
$test_javascript_function = <<<JS
function t(r, lx, ly) {
return (
r.top >= 0 &&
r.top <= ly &&
r.left >= 0 &&
r.left <= lx
)
}
JS;
break;
case 'topRight':
$test_javascript_function = <<<JS
function t(r, lx, ly) {
return (
r.top >= 0 &&
r.top <= ly &&
r.right >= 0 &&
r.right <= lx
);
}
JS;
break;
case 'bottomRight':
$test_javascript_function = <<<JS
function t(r, lx, ly) {
return (
r.bottom >= 0 &&
r.bottom <= ly &&
r.right >= 0 &&
r.right <= lx
);
}
JS;
break;
case 'bottomLeft':
$test_javascript_function = <<<JS
function t(r, lx, ly) {
return (
r.bottom >= 0 &&
r.bottom <= ly &&
r.left >= 0 &&
r.left <= lx
);
}
JS;
break;
case FALSE:
$test_javascript_function = <<<JS
function t(r, lx, ly) {
return (
r.top >= 0 &&
r.left >= 0 &&
r.bottom <= ly &&
r.right <= lx
);
}
JS;
break;
// Throw an exception if an invalid corner parameter is given.
default:
throw new UnsupportedDriverActionException($corner, $this->session->getDriver());
}
// Build the full JavaScript test. The shared logic gets the corner
// specific test logic injected.
$full_javascript_visibility_test = <<<JS
(function(t){
var w = window,
d = document,
e = d.documentElement,
n = d.evaluate("$xpath", d, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue,
r = n.getBoundingClientRect(),
lx = (w.innerWidth || e.clientWidth),
ly = (w.innerHeight || e.clientHeight);
return t(r, lx, ly);
}($test_javascript_function));
JS;
// Check the visibility by injecting and executing the full JavaScript test
// script in the page.
return $this->session->evaluateScript($full_javascript_visibility_test);
}
/**
* Passes if the raw text IS NOT found escaped on the loaded page.
*
* Raw text refers to the raw HTML that the page generated.
*
* @param string $raw
* Raw (HTML) string to look for.
*/
public function assertNoEscaped($raw) {
$this->responseNotContains($this->escapeHtml($raw));
}
/**
* Passes if the raw text IS found escaped on the loaded page.
*
* Raw text refers to the raw HTML that the page generated.
*
* @param string $raw
* Raw (HTML) string to look for.
*/
public function assertEscaped($raw) {
$this->responseContains($this->escapeHtml($raw));
}
/**
* Escapes HTML for testing.
*
* Drupal's Html::escape() uses the ENT_QUOTES flag with htmlspecialchars() to
* escape both single and double quotes. With WebDriverTestBase testing the
* browser is automatically converting &quot; and &#039; to double and single
* quotes respectively therefore we can not escape them when testing for
* escaped HTML.
*
* @param $raw
* The raw string to escape.
*
* @return string
* The string with escaped HTML.
*
* @see Drupal\Component\Utility\Html::escape()
*/
protected function escapeHtml($raw) {
return htmlspecialchars($raw, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/**
* Asserts that no matching element exists on the page after a wait.
*
* @param string $selector_type
* The element selector type (css, xpath).
* @param string|array $selector
* The element selector.
* @param int $timeout
* (optional) Timeout in milliseconds, defaults to 10000.
* @param string $message
* (optional) The exception message.
*
* @throws \Behat\Mink\Exception\ElementHtmlException
* When an element still exists on the page.
*/
public function assertNoElementAfterWait($selector_type, $selector, $timeout = 10000, $message = 'Element exists on the page.') {
$start = microtime(TRUE);
$end = $start + ($timeout / 1000);
$page = $this->session->getPage();
do {
$node = $page->find($selector_type, $selector);
if (empty($node)) {
return;
}
usleep(100000);
} while (microtime(TRUE) < $end);
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());
}
/**
* Asserts that a status message exists after wait.
*
* @param string|null $type
* The optional message type: status, error, or warning.
* @param int $timeout
* Optional timeout in milliseconds, defaults to 10000.
*/
public function statusMessageExistsAfterWait(?string $type = NULL, int $timeout = 10000): void {
$selector = $this->buildJavascriptStatusMessageSelector(NULL, $type);
$status_message_element = $this->waitForElement('xpath', $selector, $timeout);
if ($type) {
$failure_message = sprintf('A status message of type "%s" does not appear on this page, but it should.', $type);
}
else {
$failure_message = 'A status message does not appear on this page, but it should.';
}
// There is no Assert::isNotNull() method, so we make our own constraint.
$constraint = new LogicalNot(new IsNull());
Assert::assertThat($status_message_element, $constraint, $failure_message);
}
/**
* Asserts that a status message does not exist after wait.
*
* @param string|null $type
* The optional message type: status, error, or warning.
* @param int $timeout
* Optional timeout in milliseconds, defaults to 10000.
*/
public function statusMessageNotExistsAfterWait(?string $type = NULL, int $timeout = 10000): void {
$selector = $this->buildJavascriptStatusMessageSelector(NULL, $type);
$status_message_element = $this->waitForElement('xpath', $selector, $timeout);
if ($type) {
$failure_message = sprintf('A status message of type "%s" appears on this page, but it should not.', $type);
}
else {
$failure_message = 'A status message appears on this page, but it should not.';
}
Assert::assertThat($status_message_element, Assert::isNull(), $failure_message);
}
/**
* Asserts that a status message containing given string exists after wait.
*
* @param string $message
* The partial message to assert.
* @param string|null $type
* The optional message type: status, error, or warning.
* @param int $timeout
* Optional timeout in milliseconds, defaults to 10000.
*/
public function statusMessageContainsAfterWait(string $message, ?string $type = NULL, int $timeout = 10000): void {
$selector = $this->buildJavascriptStatusMessageSelector($message, $type);
$status_message_element = $this->waitForElement('xpath', $selector, $timeout);
if ($type) {
$failure_message = sprintf('A status message of type "%s" containing "%s" does not appear on this page, but it should.', $type, $message);
}
else {
$failure_message = sprintf('A status message containing "%s" does not appear on this page, but it should.', $type);
}
// There is no Assert::isNotNull() method, so we make our own constraint.
$constraint = new LogicalNot(new IsNull());
Assert::assertThat($status_message_element, $constraint, $failure_message);
}
/**
* Asserts that no status message containing given string exists after wait.
*
* @param string $message
* The partial message to assert.
* @param string|null $type
* The optional message type: status, error, or warning.
* @param int $timeout
* Optional timeout in milliseconds, defaults to 10000.
*/
public function statusMessageNotContainsAfterWait(string $message, ?string $type = NULL, int $timeout = 10000): void {
$selector = $this->buildJavascriptStatusMessageSelector($message, $type);
$status_message_element = $this->waitForElement('xpath', $selector, $timeout);
if ($type) {
$failure_message = sprintf('A status message of type "%s" containing "%s" appears on this page, but it should not.', $type, $message);
}
else {
$failure_message = sprintf('A status message containing "%s" appears on this page, but it should not.', $message);
}
Assert::assertThat($status_message_element, Assert::isNull(), $failure_message);
}
/**
* Builds a xpath selector for a message with given type and text.
*
* The selector is designed to work with the Drupal.theme.message
* template defined in message.js in addition to status-messages.html.twig
* in the system module.
*
* @param string|null $message
* The optional message or partial message to assert.
* @param string|null $type
* The optional message type: status, error, or warning.
*
* @return string
* The xpath selector for the message.
*
* @throws \InvalidArgumentException
* Thrown when $type is not an allowed type.
*/
private function buildJavascriptStatusMessageSelector(?string $message = NULL, ?string $type = NULL): string {
$allowed_types = [
'status',
'error',
'warning',
NULL,
];
if (!in_array($type, $allowed_types, TRUE)) {
throw new \InvalidArgumentException(sprintf("If a status message type is specified, the allowed values are 'status', 'error', 'warning'. The value provided was '%s'.", $type));
}
if ($type) {
$class = 'messages--' . $type;
}
else {
$class = 'messages__wrapper';
}
if ($message) {
$js_selector = $this->buildXPathQuery('//div[contains(@class, :class) and contains(., :message)]', [
':class' => $class,
':message' => $message,
]);
}
else {
$js_selector = $this->buildXPathQuery('//div[contains(@class, :class)]', [
':class' => $class,
]);
}
// We select based on WebAssert::buildStatusMessageSelector() or the
// js_selector we have just built.
return $this->buildStatusMessageSelector($message, $type) . ' | ' . $js_selector;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests;
/**
* Tests Javascript deprecation notices.
*
* @group javascript
* @group legacy
*/
class JavascriptDeprecationTest extends WebDriverTestBase {
protected static $modules = ['js_deprecation_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests Javascript deprecation notices.
*/
public function testJavascriptDeprecation(): void {
$this->expectDeprecation('Javascript Deprecation: This function is deprecated for testing purposes.');
$this->expectDeprecation('Javascript Deprecation: This property is deprecated for testing purposes.');
$this->drupalGet('js_deprecation_test');
// Ensure that deprecation message from previous page loads will be
// detected.
$this->drupalGet('user');
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests;
/**
* Tests that Drupal.throwError can be suppressed to allow a test to pass.
*
* @group javascript
*/
class JavascriptErrorsSuppressionTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $modules = ['js_errors_test'];
/**
* {@inheritdoc}
*/
protected $failOnJavascriptConsoleErrors = FALSE;
/**
* Tests that JavaScript console errors can be suppressed.
*/
public function testJavascriptErrors(): void {
// Visit page that will throw a JavaScript console error.
$this->drupalGet('js_errors_test');
// Ensure that errors from previous page loads will be
// detected.
$this->drupalGet('user');
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests;
use PHPUnit\Framework\AssertionFailedError;
/**
* Tests that Drupal.throwError will cause a test failure.
*
* @group javascript
*/
class JavascriptErrorsTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $modules = ['js_errors_test'];
/**
* Tests that JavaScript console errors will result in a test failure.
*/
public function testJavascriptErrors(): void {
// Visit page that will throw a JavaScript console error.
$this->drupalGet('js_errors_test');
// Ensure that errors from previous page loads will be
// detected.
$this->drupalGet('user');
$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessageMatches('/^Error: A manually thrown error/');
// Manually call the method under test, as it cannot be caught by PHPUnit
// when triggered from assertPostConditions().
$this->failOnJavaScriptErrors();
}
/**
* Tests JavaScript console errors during asynchronous calls.
*/
public function testJavascriptErrorsAsync(): void {
// Visit page that will throw a JavaScript console error in async context.
$this->drupalGet('js_errors_async_test');
// Ensure that errors from previous page loads will be detected.
$this->drupalGet('user');
$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessageMatches('/^Error: An error thrown in async context./');
// Manually call the method under test, as it cannot be caught by PHPUnit
// when triggered from assertPostConditions().
$this->failOnJavaScriptErrors();
}
/**
* Clear the JavaScript error log to prevent this test failing for real.
*
* @postCondition
*/
public function clearErrorLog() {
$this->getSession()->executeScript("sessionStorage.removeItem('js_testing_log_test.errors')");
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests;
/**
* Tests Drupal settings retrieval in WebDriverTestBase tests.
*
* @group javascript
*/
class JavascriptGetDrupalSettingsTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['test_page_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests retrieval of Drupal settings.
*
* @see \Drupal\FunctionalJavascriptTests\WebDriverTestBase::getDrupalSettings()
*/
public function testGetDrupalSettings(): void {
$this->drupalLogin($this->drupalCreateUser());
$this->drupalGet('test-page');
// Check that we can read the JS settings.
$js_settings = $this->getDrupalSettings();
$this->assertSame('azAZ09();.,\\\/-_{}', $js_settings['test-setting']);
// Dynamically change the setting using JavaScript.
$script = <<<EndOfScript
(function () {
drupalSettings['test-setting'] = 'foo';
})();
EndOfScript;
$this->getSession()->evaluateScript($script);
// Check that the setting has been changed.
$js_settings = $this->getDrupalSettings();
$this->assertSame('foo', $js_settings['test-setting']);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\MachineName;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\language\Entity\ConfigurableLanguage;
/**
* Tests the machine name transliteration functionality.
*
* @group javascript
* @group #slow
*/
class MachineNameTransliterationTest extends WebDriverTestBase {
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = [
'language',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$admin_user = $this->drupalCreateUser([
'administer site configuration',
'administer languages',
'access administration pages',
'administer permissions',
]);
$this->drupalLogin($admin_user);
}
/**
* Test for machine name transliteration functionality.
*
* @dataProvider machineNameInputOutput
*/
public function testMachineNameTransliterations($langcode, $input, $output): void {
$page = $this->getSession()->getPage();
if ($langcode !== 'en') {
ConfigurableLanguage::createFromLangcode($langcode)->save();
}
$this->config('system.site')->set('default_langcode', $langcode)->save();
$this->rebuildContainer();
$this->drupalGet("/admin/people/roles/add");
$page->find('css', '[data-drupal-selector="edit-label"]')->setValue($input);
$this->assertSession()->pageTextContains($output);
}
/**
* Data for the testMachineNameTransliterations.
*
* @return array
*/
public static function machineNameInputOutput(): array {
return [
// cSpell:disable
['en', 'Bob', 'bob'],
['en', 'Äwesome', 'awesome'],
['de', 'Äwesome', 'aewesome'],
['da', 'äöüåøhello', 'aouaaoehello'],
['fr', 'ц', 'c'],
// These tests are not working with chromedriver as
// 'ᐑ','𐌰𐌸' chars are not accepted.
// ['fr', 'ᐑ', 'wii'],
// ['en', '𐌰𐌸', '__'],
['en', 'Ä Ö Ü Å Ø äöüåøhello', 'a_o_u_a_o_aouaohello'],
['de', 'Ä Ö Ü Å Ø äöüåøhello', 'ae_oe_ue_a_o_aeoeueaohello'],
['de', ']URY&m_G^;', ' ury_m_g'],
['da', 'Ä Ö Ü Å Ø äöüåøhello', 'a_o_u_aa_oe_aouaaoehello'],
['kg', 'ц', 'ts'],
['en', ' Hello Abventor! ', 'hello_abventor'],
// cSpell:enable
];
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests;
use Drupal\Core\Database\Database;
use Drupal\Tests\PerformanceTestTrait;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Collects performance metrics.
*
* @ingroup testing
*/
class PerformanceTestBase extends WebDriverTestBase {
use PerformanceTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['performance_test'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->doSetUpTasks();
}
/**
* {@inheritdoc}
*/
protected function prepareEnvironment() {
parent::prepareEnvironment();
$db = Database::getConnection();
$test_file_name = (new \ReflectionClass($this))->getFileName();
$is_core_test = str_starts_with($test_file_name, DRUPAL_ROOT . DIRECTORY_SEPARATOR . 'core');
if ($db->databaseType() !== 'mysql' && $is_core_test) {
$this->markTestSkipped('Drupal core performance tests only run on MySQL');
}
}
/**
* {@inheritdoc}
*/
protected function installModulesFromClassProperty(ContainerInterface $container) {
$this->doInstallModulesFromClassProperty($container);
}
/**
* {@inheritdoc}
*/
protected function getMinkDriverArgs() {
return $this->doGetMinkDriverArgs();
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests;
/**
* Provides functions for simulating sort changes.
*
* Selenium uses ChromeDriver for FunctionalJavascript tests, but it does not
* currently support HTML5 drag and drop. These methods manipulate the DOM.
* This trait should be deprecated when the Chromium bug is fixed.
*
* @see https://www.drupal.org/project/drupal/issues/3078152
*/
trait SortableTestTrait {
/**
* Define to provide any necessary callback following layout change.
*
* @param string $item
* The HTML selector for the element to be moved.
* @param string $from
* The HTML selector for the previous container element.
* @param null|string $to
* The HTML selector for the target container.
*/
abstract protected function sortableUpdate($item, $from, $to = NULL);
/**
* Simulates a drag on an element from one container to another.
*
* @param string $item
* The HTML selector for the element to be moved.
* @param string $from
* The HTML selector for the previous container element.
* @param null|string $to
* The HTML selector for the target container.
*/
protected function sortableTo($item, $from, $to) {
$item = addslashes($item);
$from = addslashes($from);
$to = addslashes($to);
$script = <<<JS
(function (src, to) {
var sourceElement = document.querySelector(src);
var toElement = document.querySelector(to);
toElement.insertBefore(sourceElement, toElement.firstChild);
})('{$item}', '{$to}')
JS;
$options = [
'script' => $script,
'args' => [],
];
$this->getSession()->getDriver()->getWebDriverSession()->execute($options);
$this->sortableUpdate($item, $from, $to);
}
/**
* Simulates a drag moving an element after its sibling in the same container.
*
* @param string $item
* The HTML selector for the element to be moved.
* @param string $target
* The HTML selector for the sibling element.
* @param string $from
* The HTML selector for the element container.
*/
protected function sortableAfter($item, $target, $from) {
$item = addslashes($item);
$target = addslashes($target);
$from = addslashes($from);
$script = <<<JS
(function (src, to) {
var sourceElement = document.querySelector(src);
var toElement = document.querySelector(to);
toElement.insertAdjacentElement('afterend', sourceElement);
})('{$item}', '{$target}')
JS;
$options = [
'script' => $script,
'args' => [],
];
$this->getSession()->getDriver()->getWebDriverSession()->execute($options);
$this->sortableUpdate($item, $from);
}
}

View File

@@ -0,0 +1,666 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\TableDrag;
use Behat\Mink\Element\NodeElement;
use Behat\Mink\Exception\ExpectationException;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests draggable table.
*
* @group javascript
*/
class TableDragTest extends WebDriverTestBase {
/**
* Class used to verify that dragging operations are in execution.
*/
const DRAGGING_CSS_CLASS = 'tabledrag-test-dragging';
/**
* {@inheritdoc}
*/
protected static $modules = ['tabledrag_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The state service.
*
* @var \Drupal\Core\State\StateInterface
*/
protected $state;
/**
* Xpath selector for finding tabledrag indentation elements in a table row.
*
* @var string
*/
protected static $indentationXpathSelector = 'child::td[1]/*[contains(concat(" ", normalize-space(@class), " "), " js-indentation ")][contains(concat(" ", normalize-space(@class), " "), " indentation ")]';
/**
* Xpath selector for finding the tabledrag changed marker.
*
* @var string
*/
protected static $tabledragChangedXpathSelector = 'child::td[1]/abbr[contains(concat(" ", normalize-space(@class), " "), " tabledrag-changed ")]';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->state = $this->container->get('state');
}
/**
* Tests row weight switch.
*/
public function testRowWeightSwitch(): void {
$this->state->set('tabledrag_test_table', array_flip(range(1, 3)));
$this->drupalGet('tabledrag_test');
$session = $this->getSession();
$page = $session->getPage();
$weight_select1 = $page->findField("table[1][weight]");
$weight_select2 = $page->findField("table[2][weight]");
$weight_select3 = $page->findField("table[3][weight]");
// Check that rows weight selects are hidden.
$this->assertFalse($weight_select1->isVisible());
$this->assertFalse($weight_select2->isVisible());
$this->assertFalse($weight_select3->isVisible());
// Toggle row weight selects as visible.
$this->findWeightsToggle('Show row weights')->click();
// Check that rows weight selects are visible.
$this->assertTrue($weight_select1->isVisible());
$this->assertTrue($weight_select2->isVisible());
$this->assertTrue($weight_select3->isVisible());
// Toggle row weight selects back to hidden.
$this->findWeightsToggle('Hide row weights')->click();
// Check that rows weight selects are hidden again.
$this->assertFalse($weight_select1->isVisible());
$this->assertFalse($weight_select2->isVisible());
$this->assertFalse($weight_select3->isVisible());
}
/**
* Tests draggable table drag'n'drop.
*/
public function testDragAndDrop(): void {
$this->state->set('tabledrag_test_table', array_flip(range(1, 3)));
$this->drupalGet('tabledrag_test');
$session = $this->getSession();
$page = $session->getPage();
// Confirm touchevents detection is loaded with Tabledrag
$this->assertNotNull($this->assertSession()->waitForElement('css', 'html.no-touchevents'));
$weight_select1 = $page->findField("table[1][weight]");
$weight_select2 = $page->findField("table[2][weight]");
$weight_select3 = $page->findField("table[3][weight]");
// Check that initially the rows are in the correct order.
$this->assertOrder(['Row with id 1', 'Row with id 2', 'Row with id 3']);
// Check that the 'unsaved changes' text is not present in the message area.
$this->assertSession()->pageTextNotContains('You have unsaved changes.');
$row1 = $this->findRowById(1)->find('css', 'a.tabledrag-handle');
$row2 = $this->findRowById(2)->find('css', 'a.tabledrag-handle');
$row3 = $this->findRowById(3)->find('css', 'a.tabledrag-handle');
// Drag row1 over row2.
$row1->dragTo($row2);
// Check that the 'unsaved changes' text was added in the message area.
$this->assertSession()->waitForText('You have unsaved changes.');
// Check that row1 and row2 were swapped.
$this->assertOrder(['Row with id 2', 'Row with id 1', 'Row with id 3']);
// Check that weights were changed.
$this->assertGreaterThan($weight_select2->getValue(), $weight_select1->getValue());
$this->assertGreaterThan($weight_select2->getValue(), $weight_select3->getValue());
$this->assertGreaterThan($weight_select1->getValue(), $weight_select3->getValue());
// Now move the last row (row3) in the second position. row1 should go last.
$row3->dragTo($row1);
// Check that the order is: row2, row3 and row1.
$this->assertOrder(['Row with id 2', 'Row with id 3', 'Row with id 1']);
}
/**
* Tests accessibility through keyboard of the tabledrag functionality.
*/
public function testKeyboardAccessibility(): void {
$this->assertKeyboardAccessibility();
}
/**
* Asserts accessibility through keyboard of a test draggable table.
*
* @param string $drupal_path
* The drupal path where the '#tabledrag-test-table' test table is present.
* Defaults to 'tabledrag_test'.
* @param array|null $structure
* The expected table structure. If this isn't specified or equals NULL,
* then the expected structure will be set by this method. Defaults to NULL.
*
* @internal
*/
protected function assertKeyboardAccessibility(string $drupal_path = 'tabledrag_test', ?array $structure = NULL): void {
$expected_table = $structure ?: [
['id' => '1', 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
['id' => '2', 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
['id' => '3', 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
['id' => '4', 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
['id' => '5', 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
];
if (!empty($drupal_path)) {
$this->state->set('tabledrag_test_table', array_flip(range(1, 5)));
$this->drupalGet($drupal_path);
}
$this->assertDraggableTable($expected_table);
// Nest the row with id 2 as child of row 1.
$this->moveRowWithKeyboard($this->findRowById(2), 'right');
$expected_table[1] = ['id' => '2', 'weight' => -10, 'parent' => '1', 'indentation' => 1, 'changed' => TRUE];
$this->assertDraggableTable($expected_table);
// Nest the row with id 3 as child of row 1.
$this->moveRowWithKeyboard($this->findRowById(3), 'right');
$expected_table[2] = ['id' => '3', 'weight' => -9, 'parent' => '1', 'indentation' => 1, 'changed' => TRUE];
$this->assertDraggableTable($expected_table);
// Nest the row with id 3 as child of row 2.
$this->moveRowWithKeyboard($this->findRowById(3), 'right');
$expected_table[2] = ['id' => '3', 'weight' => -10, 'parent' => '2', 'indentation' => 2, 'changed' => TRUE];
$this->assertDraggableTable($expected_table);
// Nesting should be allowed to maximum level 2.
$this->moveRowWithKeyboard($this->findRowById(4), 'right', 4);
$expected_table[3] = ['id' => '4', 'weight' => -9, 'parent' => '2', 'indentation' => 2, 'changed' => TRUE];
$this->assertDraggableTable($expected_table);
// Re-order children of row 1.
$this->moveRowWithKeyboard($this->findRowById(4), 'up');
$expected_table[2] = ['id' => '4', 'weight' => -10, 'parent' => '2', 'indentation' => 2, 'changed' => TRUE];
$expected_table[3] = ['id' => '3', 'weight' => -9, 'parent' => '2', 'indentation' => 2, 'changed' => TRUE];
$this->assertDraggableTable($expected_table);
// Move back the row 3 to the 1st level.
$this->moveRowWithKeyboard($this->findRowById(3), 'left');
$expected_table[3] = ['id' => '3', 'weight' => -9, 'parent' => '1', 'indentation' => 1, 'changed' => TRUE];
$this->assertDraggableTable($expected_table);
$this->moveRowWithKeyboard($this->findRowById(3), 'left');
$expected_table[0] = ['id' => '1', 'weight' => -10, 'parent' => '', 'indentation' => 0, 'changed' => FALSE];
$expected_table[3] = ['id' => '3', 'weight' => -9, 'parent' => '', 'indentation' => 0, 'changed' => TRUE];
$expected_table[4] = ['id' => '5', 'weight' => -8, 'parent' => '', 'indentation' => 0, 'changed' => FALSE];
$this->assertDraggableTable($expected_table);
// Move row 3 to the last position.
$this->moveRowWithKeyboard($this->findRowById(3), 'down');
$expected_table[3] = ['id' => '5', 'weight' => -9, 'parent' => '', 'indentation' => 0, 'changed' => FALSE];
$expected_table[4] = ['id' => '3', 'weight' => -8, 'parent' => '', 'indentation' => 0, 'changed' => TRUE];
$this->assertDraggableTable($expected_table);
// Nothing happens when trying to move the last row further down.
$this->moveRowWithKeyboard($this->findRowById(3), 'down');
$this->assertDraggableTable($expected_table);
// Nest row 3 under 5. The max depth allowed should be 1.
$this->moveRowWithKeyboard($this->findRowById(3), 'right', 3);
$expected_table[4] = ['id' => '3', 'weight' => -10, 'parent' => '5', 'indentation' => 1, 'changed' => TRUE];
$this->assertDraggableTable($expected_table);
// The first row of the table cannot be nested.
$this->moveRowWithKeyboard($this->findRowById(1), 'right');
$this->assertDraggableTable($expected_table);
// Move a row which has nested children. The children should move with it,
// with nesting preserved. Swap the order of the top-level rows by moving
// row 1 to after row 3.
$this->moveRowWithKeyboard($this->findRowById(1), 'down', 2);
$expected_table[0] = ['id' => '5', 'weight' => -10, 'parent' => '', 'indentation' => 0, 'changed' => FALSE];
$expected_table[3] = $expected_table[1];
$expected_table[1] = $expected_table[4];
$expected_table[4] = $expected_table[2];
$expected_table[2] = ['id' => '1', 'weight' => -9, 'parent' => '', 'indentation' => 0, 'changed' => TRUE];
$this->assertDraggableTable($expected_table);
}
/**
* Tests the root and leaf behaviors for rows.
*/
public function testRootLeafDraggableRowsWithKeyboard(): void {
$this->state->set('tabledrag_test_table', [
1 => [],
2 => ['parent' => 1, 'depth' => 1, 'classes' => ['tabledrag-leaf']],
3 => ['parent' => 1, 'depth' => 1],
4 => [],
5 => ['classes' => ['tabledrag-root']],
]);
$this->drupalGet('tabledrag_test');
$expected_table = [
['id' => '1', 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
['id' => '2', 'weight' => 0, 'parent' => '1', 'indentation' => 1, 'changed' => FALSE],
['id' => '3', 'weight' => 0, 'parent' => '1', 'indentation' => 1, 'changed' => FALSE],
['id' => '4', 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
['id' => '5', 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
];
$this->assertDraggableTable($expected_table);
// Rows marked as root cannot be moved as children of another row.
$this->moveRowWithKeyboard($this->findRowById(5), 'right');
$this->assertDraggableTable($expected_table);
// Rows marked as leaf cannot have children. Trying to move the row #3
// as child of #2 should have no results.
$this->moveRowWithKeyboard($this->findRowById(3), 'right');
$this->assertDraggableTable($expected_table);
// Leaf can be still swapped and moved to first level.
$this->moveRowWithKeyboard($this->findRowById(2), 'down');
$this->moveRowWithKeyboard($this->findRowById(2), 'left');
$expected_table[0]['weight'] = -10;
$expected_table[1]['id'] = '3';
$expected_table[1]['weight'] = -10;
$expected_table[2] = ['id' => '2', 'weight' => -9, 'parent' => '', 'indentation' => 0, 'changed' => TRUE];
$expected_table[3]['weight'] = -8;
$expected_table[4]['weight'] = -7;
$this->assertDraggableTable($expected_table);
// Root rows can have children.
$this->moveRowWithKeyboard($this->findRowById(4), 'down');
$this->moveRowWithKeyboard($this->findRowById(4), 'right');
$expected_table[3]['id'] = '5';
$expected_table[4] = ['id' => '4', 'weight' => -10, 'parent' => '5', 'indentation' => 1, 'changed' => TRUE];
$this->assertDraggableTable($expected_table);
}
/**
* Tests the warning that appears upon making changes to a tabledrag table.
*/
public function testTableDragChangedWarning(): void {
$this->drupalGet('tabledrag_test');
// By default no text is visible.
$this->assertSession()->pageTextNotContains('You have unsaved changes.');
// Try to make a non-allowed action, like moving further down the last row.
// No changes happen, so no message should be shown.
$this->moveRowWithKeyboard($this->findRowById(5), 'down');
$this->assertSession()->pageTextNotContains('You have unsaved changes.');
// Make a change. The message will appear.
$this->moveRowWithKeyboard($this->findRowById(5), 'right');
$this->assertSession()->pageTextContainsOnce('You have unsaved changes.');
// Make another change, the text will stay visible and appear only once.
$this->moveRowWithKeyboard($this->findRowById(2), 'up');
$this->assertSession()->pageTextContainsOnce('You have unsaved changes.');
}
/**
* Asserts that several pieces of markup are in a given order in the page.
*
* @param string[] $items
* An ordered list of strings.
*
* @throws \Behat\Mink\Exception\ExpectationException
* When any of the given string is not found.
*
* @todo Remove this and use the WebAssert method when #2817657 is done.
*
* @internal
*/
protected function assertOrder(array $items): void {
$session = $this->getSession();
$text = $session->getPage()->getHtml();
$strings = [];
foreach ($items as $item) {
if (($pos = strpos($text, $item)) === FALSE) {
throw new ExpectationException("Cannot find '$item' in the page", $session->getDriver());
}
$strings[$pos] = $item;
}
ksort($strings);
$this->assertSame($items, array_values($strings), "Strings found on the page but incorrectly ordered.");
}
/**
* Tests nested draggable tables through keyboard.
*/
public function testNestedDraggableTables(): void {
$this->state->set('tabledrag_test_table', array_flip(range(1, 5)));
$this->drupalGet('tabledrag_test_nested');
$this->assertKeyboardAccessibility('');
// Now move the rows of the parent table.
$expected_parent_table = [
[
'id' => 'parent_1',
'weight' => 0,
'parent' => '',
'indentation' => 0,
'changed' => FALSE,
],
[
'id' => 'parent_2',
'weight' => 0,
'parent' => '',
'indentation' => 0,
'changed' => FALSE,
],
[
'id' => 'parent_3',
'weight' => 0,
'parent' => '',
'indentation' => 0,
'changed' => FALSE,
],
];
$this->assertDraggableTable($expected_parent_table, 'tabledrag-test-parent-table', TRUE);
// Switch parent table rows children.
$this->moveRowWithKeyboard($this->findRowById('parent_2', 'tabledrag-test-parent-table'), 'up');
$expected_parent_table = [
[
'id' => 'parent_2',
'weight' => -10,
'parent' => '',
'indentation' => 0,
'changed' => TRUE,
],
[
'id' => 'parent_1',
'weight' => -9,
'parent' => '',
'indentation' => 0,
'changed' => FALSE,
],
[
'id' => 'parent_3',
'weight' => -8,
'parent' => '',
'indentation' => 0,
'changed' => FALSE,
],
];
$this->assertDraggableTable($expected_parent_table, 'tabledrag-test-parent-table', TRUE);
// Try to move the row that contains the nested table to the last position.
// Order should be changed, but changed marker isn't added.
// This seems to be buggy, but this is the original behavior.
$this->moveRowWithKeyboard($this->findRowById('parent_1', 'tabledrag-test-parent-table'), 'down');
$expected_parent_table = [
[
'id' => 'parent_2',
'weight' => -10,
'parent' => '',
'indentation' => 0,
'changed' => TRUE,
],
[
'id' => 'parent_3',
'weight' => -9,
'parent' => '',
'indentation' => 0,
'changed' => FALSE,
],
// Since 'parent_1' row was moved, it should be marked as changed, but
// this would fail with core tabledrag.js.
[
'id' => 'parent_1',
'weight' => -8,
'parent' => '',
'indentation' => 0,
'changed' => NULL,
],
];
$this->assertDraggableTable($expected_parent_table, 'tabledrag-test-parent-table', TRUE);
// Re-test the nested draggable table.
$expected_child_table_structure = [
[
'id' => '5',
'weight' => -10,
'parent' => '',
'indentation' => 0,
'changed' => FALSE,
],
[
'id' => '3',
'weight' => -10,
'parent' => '5',
'indentation' => 1,
'changed' => TRUE,
],
[
'id' => '1',
'weight' => -9,
'parent' => '',
'indentation' => 0,
'changed' => TRUE,
],
[
'id' => '2',
'weight' => -10,
'parent' => '1',
'indentation' => 1,
'changed' => TRUE,
],
[
'id' => '4',
'weight' => -10,
'parent' => '2',
'indentation' => 2,
'changed' => TRUE,
],
];
$this->assertDraggableTable($expected_child_table_structure);
}
/**
* Asserts the whole structure of the draggable test table.
*
* @param array $structure
* The table structure. Each entry represents a row and consists of:
* - id: the expected value for the ID hidden field.
* - weight: the expected row weight.
* - parent: the expected parent ID for the row.
* - indentation: how many indents the row should have.
* - changed: whether or not the row should have been marked as changed.
* @param string $table_id
* The ID of the table. Defaults to 'tabledrag-test-table'.
* @param bool $skip_missing
* Whether assertions done on missing elements value may be skipped or not.
* Defaults to FALSE.
*
* @internal
*/
protected function assertDraggableTable(array $structure, string $table_id = 'tabledrag-test-table', bool $skip_missing = FALSE): void {
$rows = $this->getSession()->getPage()->findAll('xpath', "//table[@id='$table_id']/tbody/tr");
$this->assertSession()->elementsCount('xpath', "//table[@id='$table_id']/tbody/tr", count($structure));
foreach ($structure as $delta => $expected) {
$this->assertTableRow($rows[$delta], $expected['id'], $expected['weight'], $expected['parent'], $expected['indentation'], $expected['changed'], $skip_missing);
}
}
/**
* Asserts the values of a draggable row.
*
* @param \Behat\Mink\Element\NodeElement $row
* The row element to assert.
* @param string $id
* The expected value for the ID hidden input of the row.
* @param int $weight
* The expected weight of the row.
* @param string $parent
* The expected parent ID.
* @param int $indentation
* The expected indentation of the row.
* @param bool|null $changed
* Whether or not the row should have been marked as changed. NULL means
* that this assertion should be skipped.
* @param bool $skip_missing
* Whether assertions done on missing elements value may be skipped or not.
* Defaults to FALSE.
*
* @internal
*/
protected function assertTableRow(NodeElement $row, string $id, int $weight, string $parent = '', int $indentation = 0, ?bool $changed = FALSE, bool $skip_missing = FALSE): void {
// Assert that the row position is correct by checking that the id
// corresponds.
$id_name = "table[$id][id]";
if (!$skip_missing || $row->find('hidden_field_selector', ['hidden_field', $id_name])) {
$this->assertSession()->hiddenFieldValueEquals($id_name, $id, $row);
}
$parent_name = "table[$id][parent]";
if (!$skip_missing || $row->find('hidden_field_selector', ['hidden_field', $parent_name])) {
$this->assertSession()->hiddenFieldValueEquals($parent_name, $parent, $row);
}
$this->assertSession()->fieldValueEquals("table[$id][weight]", $weight, $row);
$this->assertSession()->elementsCount('xpath', static::$indentationXpathSelector, $indentation, $row);
// A row is marked as changed when the related markup is present.
if ($changed !== NULL) {
$this->assertSession()->elementsCount('xpath', static::$tabledragChangedXpathSelector, (int) $changed, $row);
}
}
/**
* Finds a row in the test table by the row ID.
*
* @param string $id
* The ID of the row.
* @param string $table_id
* The ID of the parent table. Defaults to 'tabledrag-test-table'.
*
* @return \Behat\Mink\Element\NodeElement
* The row element.
*/
protected function findRowById($id, $table_id = 'tabledrag-test-table') {
$xpath = "//table[@id='$table_id']/tbody/tr[.//input[@name='table[$id][id]']]";
$row = $this->getSession()->getPage()->find('xpath', $xpath);
$this->assertNotEmpty($row);
return $row;
}
/**
* Finds the show/hide weight toggle element.
*
* @param string $expected_text
* The expected text on the element.
*
* @return \Behat\Mink\Element\NodeElement
* The toggle element.
*/
protected function findWeightsToggle($expected_text) {
$toggle = $this->getSession()->getPage()->findButton($expected_text);
$this->assertNotEmpty($toggle);
return $toggle;
}
/**
* Moves a row through the keyboard.
*
* @param \Behat\Mink\Element\NodeElement $row
* The row to move.
* @param string $arrow
* The arrow button to use to move the row. Either one of 'left', 'right',
* 'up' or 'down'.
* @param int $repeat
* (optional) How many times to press the arrow button. Defaults to 1.
*/
protected function moveRowWithKeyboard(NodeElement $row, $arrow, $repeat = 1) {
$keys = [
'left' => 37,
'right' => 39,
'up' => 38,
'down' => 40,
];
if (!isset($keys[$arrow])) {
throw new \InvalidArgumentException('The arrow parameter must be one of "left", "right", "up" or "down".');
}
$key = $keys[$arrow];
$handle = $row->find('css', 'a.tabledrag-handle');
$handle->focus();
for ($i = 0; $i < $repeat; $i++) {
$this->markRowHandleForDragging($handle);
$handle->keyDown($key);
$handle->keyUp($key);
$this->waitUntilDraggingCompleted($handle);
}
$handle->blur();
}
/**
* Marks a row handle for dragging.
*
* The handle is marked by adding a css class that is removed by an helper
* js library once the dragging is over.
*
* @param \Behat\Mink\Element\NodeElement $handle
* The draggable row handle element.
*
* @throws \Exception
* Thrown when the class is not added successfully to the handle.
*/
protected function markRowHandleForDragging(NodeElement $handle) {
$class = self::DRAGGING_CSS_CLASS;
$script = <<<JS
document.evaluate("{$handle->getXpath()}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null)
.singleNodeValue.classList.add('{$class}');
JS;
$this->getSession()->executeScript($script);
$has_class = $this->getSession()->getPage()->waitFor(1, function () use ($handle, $class) {
return $handle->hasClass($class);
});
if (!$has_class) {
throw new \Exception(sprintf('Dragging css class was not added on handle "%s".', $handle->getXpath()));
}
}
/**
* Waits until the dragging operations are finished on a row handle.
*
* @param \Behat\Mink\Element\NodeElement $handle
* The draggable row handle element.
*
* @throws \Exception
* Thrown when the dragging operations are not completed on time.
*/
protected function waitUntilDraggingCompleted(NodeElement $handle) {
$class_removed = $this->getSession()->getPage()->waitFor(1, function () use ($handle) {
return !$handle->hasClass($this::DRAGGING_CSS_CLASS);
});
if (!$class_removed) {
throw new \Exception(sprintf('Dragging operations did not complete on time on handle %s', $handle->getXpath()));
}
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Tests;
use Behat\Mink\Driver\Selenium2Driver;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\Tests\file\Functional\FileFieldCreationTrait;
use Drupal\Tests\TestFileCreationTrait;
/**
* Tests the DrupalSelenium2Driver methods.
*
* @coversDefaultClass \Drupal\FunctionalJavascriptTests\DrupalSelenium2Driver
* @group javascript
*/
class DrupalSelenium2DriverTest extends WebDriverTestBase {
use TestFileCreationTrait;
use FileFieldCreationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['file', 'field_ui', 'entity_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$storage_settings = ['cardinality' => 3];
$this->createFileField('field_file', 'entity_test', 'entity_test', $storage_settings);
$this->drupalLogin($this->drupalCreateUser([
'administer entity_test content',
'access content',
]));
}
/**
* Tests uploading remote files.
*/
public function testGetRemoteFilePath(): void {
$web_driver = $this->getSession()->getDriver();
$this->assertInstanceOf(Selenium2Driver::class, $web_driver);
$this->assertFalse($web_driver->isW3C(), 'Driver is not operating in W3C mode');
$file_system = \Drupal::service('file_system');
$entity = EntityTest::create();
$entity->save();
$files = array_slice($this->getTestFiles('text'), 0, 3);
$real_paths = [];
foreach ($files as $file) {
$real_paths[] = $file_system->realpath($file->uri);
}
$remote_paths = [];
foreach ($real_paths as $path) {
$remote_paths[] = $web_driver->uploadFileAndGetRemoteFilePath($path);
}
// Tests that uploading multiple remote files works with remote path.
$this->drupalGet($entity->toUrl('edit-form'));
$multiple_field = $this->assertSession()->elementExists('xpath', '//input[@multiple]');
$multiple_field->setValue(implode("\n", $remote_paths));
$this->assertSession()->assertWaitOnAjaxRequest();
$this->getSession()->getPage()->findButton('Save')->click();
$entity = EntityTest::load($entity->id());
$this->assertCount(3, $entity->field_file);
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
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(): void {
$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(): void {
$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(): void {
$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');
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Tests;
use Behat\Mink\Element\NodeElement;
use Behat\Mink\Exception\ElementHtmlException;
use Drupal\Component\Utility\Timer;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests for the JSWebAssert class.
*
* @group javascript
*/
class JSWebAssertTest extends WebDriverTestBase {
/**
* Required modules.
*
* @var array
*/
protected static $modules = ['js_webassert_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests that JSWebAssert assertions work correctly.
*/
public function testJsWebAssert(): void {
$this->drupalGet('js_webassert_test_form');
$session = $this->getSession();
$assert_session = $this->assertSession();
$page = $session->getPage();
$assert_session->elementExists('css', '[data-drupal-selector="edit-test-assert-no-element-after-wait-pass"]');
$page->findButton('Test assertNoElementAfterWait: pass')->press();
$assert_session->assertNoElementAfterWait('css', '[data-drupal-selector="edit-test-assert-no-element-after-wait-pass"]', 1000);
$assert_session->elementExists('css', '[data-drupal-selector="edit-test-assert-no-element-after-wait-fail"]');
$page->findButton('Test assertNoElementAfterWait: fail')->press();
try {
Timer::start('JSWebAssertTest');
$assert_session->assertNoElementAfterWait('css', '[data-drupal-selector="edit-test-assert-no-element-after-wait-fail"]', 500, 'Element exists on page after too short wait.');
// This test is fragile if webdriver responses are very slow for some
// reason. If they are, do not fail the test.
// @todo https://www.drupal.org/project/drupal/issues/3316317 remove this
// workaround.
if (Timer::read('JSWebAssertTest') < 1000) {
$this->fail("Element not exists on page after too short wait.");
}
}
catch (ElementHtmlException $e) {
$this->assertSame('Element exists on page after too short wait.', $e->getMessage());
}
$assert_session->assertNoElementAfterWait('css', '[data-drupal-selector="edit-test-assert-no-element-after-wait-fail"]', 2500, 'Element remove after another wait.ss');
$test_button = $page->findButton('Add button');
$test_link = $page->findButton('Add link');
$test_field = $page->findButton('Add field');
$test_id = $page->findButton('Add ID');
$test_wait_on_ajax = $page->findButton('Test assertWaitOnAjaxRequest');
$test_wait_on_element_visible = $page->findButton('Test waitForElementVisible');
// Test the wait...() methods by first checking the fields aren't available
// and then are available after the wait method.
$result = $page->findButton('Added button');
$this->assertEmpty($result);
$test_button->click();
$result = $assert_session->waitForButton('Added button');
$this->assertNotEmpty($result);
$this->assertInstanceOf(NodeElement::class, $result);
$result = $page->findLink('Added link');
$this->assertEmpty($result);
$test_link->click();
$result = $assert_session->waitForLink('Added link');
$this->assertNotEmpty($result);
$this->assertInstanceOf(NodeElement::class, $result);
$result = $page->findField('added_field');
$this->assertEmpty($result);
$test_field->click();
$result = $assert_session->waitForField('added_field');
$this->assertNotEmpty($result);
$this->assertInstanceOf(NodeElement::class, $result);
$result = $page->findById('js_webassert_test_field_id');
$this->assertEmpty($result);
$test_id->click();
$result = $assert_session->waitForId('js_webassert_test_field_id');
$this->assertNotEmpty($result);
$this->assertInstanceOf(NodeElement::class, $result);
// Test waitOnAjaxRequest. Verify the element is available after the wait
// and the behaviors have run on completing by checking the value.
$result = $page->findField('test_assert_wait_on_ajax_input');
$this->assertEmpty($result);
$test_wait_on_ajax->click();
$assert_session->assertWaitOnAjaxRequest();
$result = $page->findField('test_assert_wait_on_ajax_input');
$this->assertNotEmpty($result);
$this->assertInstanceOf(NodeElement::class, $result);
$this->assertEquals('js_webassert_test', $result->getValue());
$result = $page->findButton('Added WaitForElementVisible');
$this->assertEmpty($result);
$test_wait_on_element_visible->click();
$result = $assert_session->waitForElementVisible('named', ['button', 'Added WaitForElementVisible']);
$this->assertNotEmpty($result);
$this->assertInstanceOf(NodeElement::class, $result);
$this->assertEquals(TRUE, $result->isVisible());
$this->drupalGet('js_webassert_test_page');
// Ensure that the javascript has replaced the element 1100 times.
$assert_session->waitForText('New Text!! 1100');
$result = $page->find('named', ['id', 'test_text']);
$this->assertSame('test_text', $result->getAttribute('id'));
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Theme;
use Drupal\Tests\block\FunctionalJavascript\BlockFilterTest;
/**
* Runs BlockFilterTest in Claro.
*
* @group block
*
* @see \Drupal\Tests\block\FunctionalJavascript\BlockFilterTest.
*/
class ClaroBlockFilterTest extends BlockFilterTest {
/**
* Modules to enable.
*
* Install the shortcut module so that claro.settings has its schema checked.
* There's currently no way for Claro to provide a default and have valid
* configuration as themes cannot react to a module install.
*
* @var string[]
*/
protected static $modules = ['shortcut'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->container->get('theme_installer')->install(['claro']);
$this->config('system.theme')->set('default', 'claro')->save();
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Theme;
use Drupal\Tests\field_ui\FunctionalJavascript\EntityDisplayTest;
/**
* Runs EntityDisplayTest in Claro.
*
* @group claro
*
* @see \Drupal\Tests\field_ui\FunctionalJavascript\EntityDisplayTest.
*/
class ClaroEntityDisplayTest extends EntityDisplayTest {
/**
* Modules to enable.
*
* Install the shortcut module so that claro.settings has its schema checked.
* There's currently no way for Claro to provide a default and have valid
* configuration as themes cannot react to a module install.
*
* @var string[]
*/
protected static $modules = ['shortcut'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->container->get('theme_installer')->install(['claro']);
$this->config('system.theme')->set('default', 'claro')->save();
}
/**
* Copied from parent.
*
* This is Drupal\Tests\field_ui\FunctionalJavascript\EntityDisplayTest::testEntityForm()
* with a line changed to reflect row weight toggle being a link instead
* of a button.
*/
public function testEntityForm(): void {
$this->drupalGet('entity_test/manage/1/edit');
$this->assertSession()->fieldExists('field_test_text[0][value]');
$this->drupalGet('entity_test/structure/entity_test/form-display');
$this->assertTrue($this->assertSession()->optionExists('fields[field_test_text][region]', 'content')->isSelected());
$this->getSession()->getPage()->pressButton('Show row weights');
$this->assertSession()->waitForElementVisible('css', '[name="fields[field_test_text][region]"]');
$this->getSession()->getPage()->selectFieldOption('fields[field_test_text][region]', 'hidden');
$this->assertTrue($this->assertSession()->optionExists('fields[field_test_text][region]', 'hidden')->isSelected());
$this->submitForm([], 'Save');
$this->assertSession()->pageTextContains('Your settings have been saved.');
$this->assertTrue($this->assertSession()->optionExists('fields[field_test_text][region]', 'hidden')->isSelected());
$this->drupalGet('entity_test/manage/1/edit');
$this->assertSession()->fieldNotExists('field_test_text[0][value]');
}
/**
* Copied from parent.
*
* This is Drupal\Tests\field_ui\FunctionalJavascript\EntityDisplayTest::testEntityView()
* with a line changed to reflect row weight toggle being a link instead
* of a button.
*/
public function testEntityView(): void {
$this->drupalGet('entity_test/1');
$this->assertSession()->elementNotExists('css', '.field--name-field-test-text');
$this->drupalGet('entity_test/structure/entity_test/display');
$this->assertSession()->elementExists('css', '.region-content-message.region-empty');
$this->getSession()->getPage()->pressButton('Show row weights');
$this->assertSession()->waitForElementVisible('css', '[name="fields[field_test_text][region]"]');
$this->assertTrue($this->assertSession()->optionExists('fields[field_test_text][region]', 'hidden')->isSelected());
$this->getSession()->getPage()->selectFieldOption('fields[field_test_text][region]', 'content');
$this->assertTrue($this->assertSession()->optionExists('fields[field_test_text][region]', 'content')->isSelected());
$this->submitForm([], 'Save');
$this->assertSession()->pageTextContains('Your settings have been saved.');
$this->assertTrue($this->assertSession()->optionExists('fields[field_test_text][region]', 'content')->isSelected());
$this->drupalGet('entity_test/1');
$this->assertSession()->elementExists('css', '.field--name-field-test-text');
}
/**
* Copied from parent.
*
* This is Drupal\Tests\field_ui\FunctionalJavascript\EntityDisplayTest::testExtraFields()
* with a line changed to reflect Claro's tabledrag selector.
*/
public function testExtraFields(): void {
entity_test_create_bundle('bundle_with_extra_fields');
$this->drupalGet('entity_test/structure/bundle_with_extra_fields/display');
$this->assertSession()->waitForElement('css', '.tabledrag-handle');
$id = $this->getSession()->getPage()->find('css', '[name="form_build_id"]')->getValue();
$extra_field_row = $this->getSession()->getPage()->find('css', '#display-extra-field');
$disabled_region_row = $this->getSession()->getPage()->find('css', '.region-hidden-title');
$extra_field_row->find('css', '.js-tabledrag-handle')->dragTo($disabled_region_row);
$this->assertSession()->assertWaitOnAjaxRequest();
$this->assertSession()
->waitForElement('css', "[name='form_build_id']:not([value='$id'])");
$this->submitForm([], 'Save');
$this->assertSession()->pageTextContains('Your settings have been saved.');
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Theme;
use Drupal\Tests\menu_ui\FunctionalJavascript\MenuUiJavascriptTest;
/**
* Runs MenuUiJavascriptTest in Claro.
*
* @group claro
*
* @see \Drupal\Tests\menu_ui\FunctionalJavascript\MenuUiJavascriptTest;
*/
class ClaroMenuUiJavascriptTest extends MenuUiJavascriptTest {
/**
* {@inheritdoc}
*/
protected static $modules = [
'shortcut',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->container->get('theme_installer')->install(['claro']);
$this->config('system.theme')->set('default', 'claro')->save();
}
/**
* Intentionally empty method.
*
* Contextual links do not work in admin themes, so this is empty to prevent
* this test running in the parent class.
*/
public function testBlockContextualLinks(): void {
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Theme;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\Tests\media_library\FunctionalJavascript\MediaLibraryTestBase;
use Drupal\Tests\TestFileCreationTrait;
/**
* Tests that buttons in modals are not in their button pane.
*
* @group claro
*/
class ClaroModalDisplayTest extends MediaLibraryTestBase {
use TestFileCreationTrait;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'claro';
/**
* Tests the position f "add another" button in dialogs.
*/
public function testModalAddAnother(): void {
// Add unlimited field to the media type four.
$unlimited_field_storage = FieldStorageConfig::create([
'entity_type' => 'media',
'field_name' => 'unlimited',
'type' => 'string',
'cardinality' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED,
]);
$unlimited_field_storage->save();
$unlimited_field = FieldConfig::create([
'field_storage' => $unlimited_field_storage,
'bundle' => 'type_four',
'label' => 'Unlimited',
]);
$unlimited_field->save();
/** @var \Drupal\Core\Entity\EntityDisplayRepositoryInterface $display_repository */
$display_repository = \Drupal::service('entity_display.repository');
$display_repository->getFormDisplay('media', 'type_four', 'media_library')
->setComponent('unlimited', [
'type' => 'string_textfield',
])
->save();
$assert_session = $this->assertSession();
foreach ($this->getTestFiles('image') as $image) {
$extension = pathinfo($image->filename, PATHINFO_EXTENSION);
if ($extension === 'jpg') {
$jpg_image = $image;
}
}
if (!isset($jpg_image)) {
$this->fail('Expected test files not present.');
}
// Create a user that can create media for all media types.
$user = $this->drupalCreateUser([
'access administration pages',
'access content',
'create basic_page content',
'create media',
'view media',
]);
$this->drupalLogin($user);
// Visit a node create page.
$this->drupalGet('node/add/basic_page');
// Add to the twin media field.
$this->openMediaLibraryForField('field_twin_media');
$this->switchToMediaType('Four');
// A file needs to be added for the unlimited field to appear in the form.
$this->addMediaFileToField('Add files', $this->container->get('file_system')->realpath($jpg_image->uri));
// Wait for the file upload to be completed.
// Copied from \Drupal\Tests\media_library\FunctionalJavascript\MediaLibraryTestBase::assertMediaAdded.
$selector = '.js-media-library-add-form-added-media';
$this->assertJsCondition('jQuery("' . $selector . '").is(":focus")');
// Assert that the 'add another item' button is not in the dialog footer.
$assert_session->elementNotExists('css', '.ui-dialog-buttonset .field-add-more-submit');
$assert_session->elementExists('css', '.ui-dialog-content .field-add-more-submit');
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Theme;
use Drupal\Tests\user\FunctionalJavascript\PasswordConfirmWidgetTest;
/**
* Tests the password confirm widget with Claro theme.
*
* @group claro
*/
class ClaroPasswordConfirmWidgetTest extends PasswordConfirmWidgetTest {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'claro';
/**
* Tests that password match message is invisible when widget is initialized.
*/
public function testPasswordConfirmMessage(): void {
$this->drupalGet($this->testUser->toUrl('edit-form'));
$password_confirm_widget_selector = '.js-form-type-password-confirm.js-form-item-pass';
$password_confirm_selector = '.js-form-item-pass-pass2';
$password_confirm_widget = $this->assert->elementExists('css', $password_confirm_widget_selector);
$password_confirm_item = $password_confirm_widget->find('css', $password_confirm_selector);
// Password match message.
$this->assertTrue($password_confirm_item->has('css', 'input.js-password-confirm + [data-drupal-selector="password-confirm-message"]'));
$this->assertFalse($password_confirm_item->find('css', 'input.js-password-confirm + [data-drupal-selector="password-confirm-message"]')->isVisible());
}
/**
* {@inheritdoc}
*/
public function testFillConfirmOnly(): void {
// This test is not applicable to Claro because confirm field is hidden
// until the password has been filled in the main field.
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Theme;
use Drupal\FunctionalJavascriptTests\TableDrag\TableDragTest;
/**
* Tests draggable tables with Claro theme.
*
* @group claro
*
* @see \Drupal\FunctionalJavascriptTests\TableDrag\TableDragTest
*/
class ClaroTableDragTest extends TableDragTest {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'claro';
/**
* {@inheritdoc}
*/
protected static $indentationXpathSelector = 'child::td[1]/div[contains(concat(" ", normalize-space(@class), " "), " js-tabledrag-cell-content ")]/div[contains(concat(" ", normalize-space(@class), " "), " js-indentation ")]';
/**
* {@inheritdoc}
*/
protected static $tabledragChangedXpathSelector = 'child::td[1]/div[contains(concat(" ", normalize-space(@class), " "), " js-tabledrag-cell-content ")]/abbr[contains(concat(" ", normalize-space(@class), " "), " tabledrag-changed ")]';
/**
* Ensures that there are no duplicate tabledrag handles.
*/
public function testNoDuplicates(): void {
$this->drupalGet('tabledrag_test_nested');
$this->assertCount(1, $this->findRowById(1)->findAll('css', '.tabledrag-handle'));
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Theme;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
use Drupal\Tests\node\Traits\NodeCreationTrait;
/**
* Tests Claro's Views Bulk Operations form.
*
* @group claro
*/
class ClaroViewsBulkOperationsTest extends WebDriverTestBase {
use ContentTypeCreationTrait;
use NodeCreationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'views'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'claro';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create a Content type and two test nodes.
$this->createContentType(['type' => 'page']);
$this->createNode(['title' => 'Page One']);
$this->createNode(['title' => 'Page Two']);
// Create a user privileged enough to use exposed filters and view content.
$user = $this->drupalCreateUser([
'administer site configuration',
'access content',
'access content overview',
'edit any page content',
]);
$this->drupalLogin($user);
}
/**
* Tests the dynamic Bulk Operations form.
*/
public function testBulkOperationsUi(): void {
$this->drupalGet('admin/content');
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$no_items_selected = 'No items selected';
$one_item_selected = '1 item selected';
$two_items_selected = '2 items selected';
$vbo_available_message = 'Bulk actions are now available';
$this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$no_items_selected\")"));
$select_all = $page->find('css', '.select-all > input');
$page->checkField('node_bulk_form[0]');
$this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$one_item_selected\")"));
// When the bulk operations controls are first activated, this should be
// relayed to screen readers.
$this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$vbo_available_message\")"));
$this->assertFalse($select_all->isChecked());
$page->checkField('node_bulk_form[1]');
$this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$two_items_selected\")"));
$this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$two_items_selected\")"));
$assert_session->pageTextNotContains($vbo_available_message);
$this->assertTrue($select_all->isChecked());
$page->uncheckField('node_bulk_form[0]');
$this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$one_item_selected\")"));
$this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$one_item_selected\")"));
$assert_session->pageTextNotContains($vbo_available_message);
$this->assertFalse($select_all->isChecked());
$page->uncheckField('node_bulk_form[1]');
$this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$no_items_selected\")"));
$this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$no_items_selected\")"));
$assert_session->pageTextNotContains($vbo_available_message);
$this->assertFalse($select_all->isChecked());
$select_all->check();
$this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$two_items_selected\")"));
$this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$vbo_available_message\")"));
$this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$two_items_selected\")"));
$select_all->uncheck();
$this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$no_items_selected\")"));
$this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$no_items_selected\")"));
$assert_session->pageTextNotContains($vbo_available_message);
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Theme;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Runs tests on Views UI using Claro.
*
* @group claro
*/
class ClaroViewsUiTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['views_ui'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'claro';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Disable automatic live preview to make the sequence of calls clearer.
$this->config('views.settings')->set('ui.always_live_preview', FALSE)->save();
// Create the test user and log in.
$admin_user = $this->drupalCreateUser([
'administer views',
'access administration pages',
'view the administration theme',
]);
$this->drupalLogin($admin_user);
}
/**
* Tests Views UI display menu tabs CSS classes.
*
* Ensures that the CSS classes added to display menu tabs are preserved when
* Views UI is updated with AJAX.
*/
public function testViewsUiTabsCssClasses(): void {
$this->drupalGet('admin/structure/views/view/who_s_online');
$assert_session = $this->assertSession();
$assert_session->elementExists('css', '#views-display-menu-tabs.views-tabs.views-tabs--secondary');
// Click on the Display name and wait for the Views UI dialog.
$assert_session->elementExists('css', '#edit-display-settings-top .views-display-setting a')->click();
$this->assertNotNull($this->assertSession()->waitForElement('css', '.js-views-ui-dialog'));
// Click the Apply button of the dialog.
$assert_session->elementExists('css', '.js-views-ui-dialog .ui-dialog-buttonpane')->findButton('Apply')->press();
// Wait for AJAX to finish.
$assert_session->assertWaitOnAjaxRequest();
// Check that the display menu tabs list still has the expected CSS classes.
$assert_session->elementExists('css', '#views-display-menu-tabs.views-tabs.views-tabs--secondary');
}
/**
* Tests Views UI dropbutton CSS classes.
*
* Ensures that the CSS classes added to the Views UI extra actions dropbutton
* in .views-display-top are preserved when Views UI is refreshed with AJAX.
*/
public function testViewsUiDropButtonCssClasses(): void {
$this->drupalGet('admin/structure/views/view/who_s_online');
$assert_session = $this->assertSession();
$extra_actions_dropbutton_list = $assert_session->elementExists('css', '#views-display-extra-actions.dropbutton--small');
$list_item_selectors = ['li:first-child', 'li:last-child'];
// Test list item CSS classes.
foreach ($list_item_selectors as $list_item_selector) {
$this->assertNotNull($extra_actions_dropbutton_list->find('css', "$list_item_selector.dropbutton__item"));
}
// Click on the Display name and wait for the Views UI dialog.
$assert_session->elementExists('css', '#edit-display-settings-top .views-display-setting a')->click();
$this->assertNotNull($this->assertSession()->waitForElement('css', '.js-views-ui-dialog'));
// Click the Apply button of the dialog.
$assert_session->elementExists('css', '.js-views-ui-dialog .ui-dialog-buttonpane')->findButton('Apply')->press();
// Wait for AJAX to finish.
$this->assertSession()->assertWaitOnAjaxRequest();
// Check that the drop button list still has the expected CSS classes.
$this->assertTrue($extra_actions_dropbutton_list->hasClass('dropbutton--small'));
// Check list item CSS classes.
foreach ($list_item_selectors as $list_item_selector) {
$this->assertNotNull($extra_actions_dropbutton_list->find('css', "$list_item_selector.dropbutton__item"));
}
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests\Theme;
use Drupal\FunctionalJavascriptTests\Core\JsMessageTest;
use Drupal\js_message_test\Controller\JSMessageTestController;
/**
* Runs OliveroMessagesTest in Olivero.
*
* @group olivero
*
* @see \Drupal\FunctionalJavascriptTests\Core\JsMessageTest.
*/
class OliveroMessagesTest extends JsMessageTest {
/**
* {@inheritdoc}
*/
protected static $modules = [
'js_message_test',
'system',
'block',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Enable the theme.
\Drupal::service('theme_installer')->install(['olivero']);
$theme_config = \Drupal::configFactory()->getEditable('system.theme');
$theme_config->set('default', 'olivero');
$theme_config->save();
}
/**
* Tests data-drupal-selector="messages" exists.
*/
public function testDataDrupalSelectors(): void {
$web_assert = $this->assertSession();
$this->drupalGet('js_message_test_link');
foreach (JSMessageTestController::getMessagesSelectors() as $messagesSelector) {
$web_assert->elementExists('css', $messagesSelector);
foreach (JSMessageTestController::getTypes() as $type) {
$this->click('[id="add-' . $messagesSelector . '-' . $type . '"]');
$selector = '[data-drupal-selector="messages"]';
$msg_element = $web_assert->waitForElementVisible('css', $selector);
$this->assertNotEmpty($msg_element, "Message element visible: $selector");
}
}
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests;
use WebDriver\Service\CurlService;
use WebDriver\Exception\CurlExec;
use WebDriver\Exception as WebDriverException;
/**
* Provides a curl service to interact with Selenium driver.
*
* Extends WebDriver\Service\CurlService to solve problem with race conditions,
* when multiple processes requests.
*/
class WebDriverCurlService extends CurlService {
/**
* Flag that indicates if retries are enabled.
*
* @var bool
*/
private static $retry = TRUE;
/**
* Enables retries.
*
* This is useful if the caller is implementing it's own waiting process.
*/
public static function enableRetry() {
static::$retry = TRUE;
}
/**
* Disables retries.
*
* This is useful if the caller is implementing it's own waiting process.
*/
public static function disableRetry() {
static::$retry = FALSE;
}
/**
* {@inheritdoc}
*/
public function execute($requestMethod, $url, $parameters = NULL, $extraOptions = []) {
$extraOptions += [
CURLOPT_FAILONERROR => TRUE,
];
$retries = 0;
$max_retries = static::$retry ? 10 : 1;
while ($retries < $max_retries) {
try {
$customHeaders = [
'Content-Type: application/json;charset=UTF-8',
'Accept: application/json;charset=UTF-8',
];
$curl = curl_init($url);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, TRUE);
switch ($requestMethod) {
case 'GET':
break;
case 'POST':
if ($parameters && is_array($parameters)) {
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($parameters));
}
else {
$customHeaders[] = 'Content-Length: 0';
// Suppress "Transfer-Encoding: chunked" header automatically
// added by cURL that causes a 400 bad request (bad
// content-length).
$customHeaders[] = 'Transfer-Encoding:';
}
// Suppress "Expect: 100-continue" header automatically added by
// cURL that causes a 1 second delay if the remote server does not
// support Expect.
$customHeaders[] = 'Expect:';
curl_setopt($curl, CURLOPT_POST, TRUE);
break;
case 'DELETE':
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'DELETE');
break;
case 'PUT':
if ($parameters && is_array($parameters)) {
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($parameters));
}
else {
$customHeaders[] = 'Content-Length: 0';
// Suppress "Transfer-Encoding: chunked" header automatically
// added by cURL that causes a 400 bad request (bad
// content-length).
$customHeaders[] = 'Transfer-Encoding:';
}
// Suppress "Expect: 100-continue" header automatically added by
// cURL that causes a 1 second delay if the remote server does not
// support Expect.
$customHeaders[] = 'Expect:';
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'PUT');
break;
}
foreach ($extraOptions as $option => $value) {
curl_setopt($curl, $option, $value);
}
curl_setopt($curl, CURLOPT_HTTPHEADER, $customHeaders);
$result = curl_exec($curl);
$rawResult = NULL;
if ($result !== FALSE) {
$rawResult = trim($result);
}
$info = curl_getinfo($curl);
$info['request_method'] = $requestMethod;
if (array_key_exists(CURLOPT_FAILONERROR, $extraOptions) && $extraOptions[CURLOPT_FAILONERROR] && CURLE_GOT_NOTHING !== ($errno = curl_errno($curl)) && $error = curl_error($curl)) {
curl_close($curl);
throw WebDriverException::factory(WebDriverException::CURL_EXEC, sprintf("Curl error thrown for http %s to %s%s\n\n%s", $requestMethod, $url, $parameters && is_array($parameters) ? ' with params: ' . json_encode($parameters) : '', $error));
}
curl_close($curl);
$result = json_decode($rawResult, TRUE);
if (isset($result['status']) && $result['status'] === WebDriverException::STALE_ELEMENT_REFERENCE) {
usleep(100000);
$retries++;
continue;
}
return [$rawResult, $info];
}
catch (CurlExec $exception) {
$retries++;
}
}
if (empty($error)) {
$error = "Retries: $retries and last result:\n" . ($rawResult ?? '');
}
throw WebDriverException::factory(WebDriverException::CURL_EXEC, sprintf("Curl error thrown for http %s to %s%s\n\n%s", $requestMethod, $url, $parameters && is_array($parameters) ? ' with params: ' . json_encode($parameters) : '', $error));
}
}

View File

@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests;
use Behat\Mink\Exception\DriverException;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Tests\BrowserTestBase;
use PHPUnit\Runner\BaseTestRunner;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Runs a browser test using a driver that supports JavaScript.
*
* Base class for testing browser interaction implemented in JavaScript.
*
* @ingroup testing
*/
abstract class WebDriverTestBase extends BrowserTestBase {
/**
* Determines if a test should fail on JavaScript console errors.
*
* @var bool
*/
protected $failOnJavascriptConsoleErrors = TRUE;
/**
* Disables CSS animations in tests for more reliable testing.
*
* CSS animations are disabled by installing the css_disable_transitions_test
* module. Set to FALSE to test CSS animations.
*
* @var bool
*/
protected $disableCssAnimations = TRUE;
/**
* {@inheritdoc}
*/
protected $minkDefaultDriverClass = DrupalSelenium2Driver::class;
/**
* {@inheritdoc}
*/
protected function initMink() {
if (!is_a($this->minkDefaultDriverClass, DrupalSelenium2Driver::class, TRUE)) {
throw new \UnexpectedValueException(sprintf("%s has to be an instance of %s", $this->minkDefaultDriverClass, DrupalSelenium2Driver::class));
}
$this->minkDefaultDriverArgs = ['chrome', ['goog:chromeOptions' => ['w3c' => FALSE]], 'http://localhost:4444'];
try {
return parent::initMink();
}
catch (DriverException $e) {
if ($this->minkDefaultDriverClass === DrupalSelenium2Driver::class) {
$this->markTestSkipped("The test wasn't able to connect to your webdriver instance. For more information read core/tests/README.md.\n\nThe original message while starting Mink: {$e->getMessage()}");
}
else {
throw $e;
}
}
catch (\Exception $e) {
$this->markTestSkipped('An unexpected error occurred while starting Mink: ' . $e->getMessage());
}
}
/**
* {@inheritdoc}
*/
protected function installModulesFromClassProperty(ContainerInterface $container) {
self::$modules = [
'js_testing_ajax_request_test',
'js_testing_log_test',
'jquery_keyevent_polyfill_test',
];
if ($this->disableCssAnimations) {
self::$modules[] = 'css_disable_transitions_test';
}
parent::installModulesFromClassProperty($container);
}
/**
* {@inheritdoc}
*/
protected function initFrontPage() {
parent::initFrontPage();
// Set a standard window size so that all javascript tests start with the
// same viewport.
$this->getSession()->resizeWindow(1024, 768);
}
/**
* {@inheritdoc}
*/
protected function tearDown(): void {
if ($this->mink) {
$status = $this->getStatus();
if ($status === BaseTestRunner::STATUS_ERROR || $status === BaseTestRunner::STATUS_WARNING || $status === BaseTestRunner::STATUS_FAILURE) {
// Ensure we capture the output at point of failure.
@$this->htmlOutput();
}
// Wait for all requests to finish. It is possible that an AJAX request is
// still on-going.
$result = $this->getSession()->wait(5000, 'window.drupalActiveXhrCount === 0 || typeof window.drupalActiveXhrCount === "undefined"');
if (!$result) {
// If the wait is unsuccessful, there may still be an AJAX request in
// progress. If we tear down now, then this AJAX request may fail with
// missing database tables, because tear down will have removed them.
// Rather than allow it to fail, throw an explicit exception now
// explaining what the problem is.
throw new \RuntimeException('Unfinished AJAX requests while tearing down a test');
}
$warnings = $this->getSession()->evaluateScript("JSON.parse(sessionStorage.getItem('js_testing_log_test.warnings') || JSON.stringify([]))");
foreach ($warnings as $warning) {
if (str_starts_with($warning, '[Deprecation]')) {
// phpcs:ignore Drupal.Semantics.FunctionTriggerError
@trigger_error('Javascript Deprecation:' . substr($warning, 13), E_USER_DEPRECATED);
}
}
}
parent::tearDown();
}
/**
* Triggers a test failure if a JavaScript error was encountered.
*
* @throws \PHPUnit\Framework\AssertionFailedError
*
* @postCondition
*/
protected function failOnJavaScriptErrors(): void {
if ($this->failOnJavascriptConsoleErrors) {
$errors = $this->getSession()->evaluateScript("JSON.parse(sessionStorage.getItem('js_testing_log_test.errors') || JSON.stringify([]))");
if (!empty($errors)) {
$this->fail(implode("\n", $errors));
}
}
}
/**
* {@inheritdoc}
*/
protected function getMinkDriverArgs() {
if ($this->minkDefaultDriverClass === DrupalSelenium2Driver::class) {
$json = getenv('MINK_DRIVER_ARGS_WEBDRIVER') ?: parent::getMinkDriverArgs();
if (!($json === FALSE || $json === '')) {
$args = json_decode($json, TRUE);
if (isset($args[1]['chromeOptions'])) {
@trigger_error('The "chromeOptions" array key is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use "goog:chromeOptions instead. See https://www.drupal.org/node/3422624', E_USER_DEPRECATED);
$args[1]['goog:chromeOptions'] = $args[1]['chromeOptions'];
unset($args[1]['chromeOptions']);
}
if (isset($args[0]) && $args[0] === 'chrome' && !isset($args[1]['goog:chromeOptions']['w3c'])) {
// @todo https://www.drupal.org/project/drupal/issues/3421202
// Deprecate defaulting behavior and require w3c to be set.
$args[1]['goog:chromeOptions']['w3c'] = FALSE;
}
$json = json_encode($args);
}
return $json;
}
return parent::getMinkDriverArgs();
}
/**
* Waits for the given time or until the given JS condition becomes TRUE.
*
* @param string $condition
* JS condition to wait until it becomes TRUE.
* @param int $timeout
* (Optional) Timeout in milliseconds, defaults to 10000.
* @param string $message
* (optional) A message to display with the assertion. If left blank, a
* default message will be displayed.
*
* @throws \PHPUnit\Framework\AssertionFailedError
*
* @see \Behat\Mink\Driver\DriverInterface::evaluateScript()
*/
protected function assertJsCondition($condition, $timeout = 10000, $message = '') {
$message = $message ?: "JavaScript condition met:\n" . $condition;
$result = $this->getSession()->getDriver()->wait($timeout, $condition);
$this->assertTrue($result, $message);
}
/**
* Creates a screenshot.
*
* @param string $filename
* The file name of the resulting screenshot including a writable path. For
* example, /tmp/test_screenshot.jpg.
* @param bool $set_background_color
* (optional) By default this method will set the background color to white.
* Set to FALSE to override this behavior.
*
* @throws \Behat\Mink\Exception\UnsupportedDriverActionException
* When operation not supported by the driver.
* @throws \Behat\Mink\Exception\DriverException
* When the operation cannot be done.
*/
protected function createScreenshot($filename, $set_background_color = TRUE) {
$session = $this->getSession();
if ($set_background_color) {
$session->executeScript("document.body.style.backgroundColor = 'white';");
}
$image = $session->getScreenshot();
file_put_contents($filename, $image);
}
/**
* {@inheritdoc}
*/
public function assertSession($name = NULL) {
return new WebDriverWebAssert($this->getSession($name), $this->baseUrl);
}
/**
* Gets the current Drupal javascript settings and parses into an array.
*
* Unlike BrowserTestBase::getDrupalSettings(), this implementation reads the
* current values of drupalSettings, capturing all changes made via javascript
* after the page was loaded.
*
* @return array
* The Drupal javascript settings array.
*
* @see \Drupal\Tests\BrowserTestBase::getDrupalSettings()
*/
protected function getDrupalSettings() {
$script = <<<EndOfScript
(function () {
if (typeof drupalSettings !== 'undefined') {
return drupalSettings;
}
})();
EndOfScript;
$settings = $this->getSession()->evaluateScript($script) ?: [];
if (isset($settings['ajaxPageState'])) {
$settings['ajaxPageState']['libraries'] = UrlHelper::uncompressQueryParameter($settings['ajaxPageState']['libraries']);
}
return $settings;
}
/**
* {@inheritdoc}
*/
protected function getHtmlOutputHeaders() {
// The webdriver API does not support fetching headers.
return '';
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalJavascriptTests;
/**
* Defines a JSWebAssert with no support for status code and header assertions.
*/
class WebDriverWebAssert extends JSWebAssert {
/**
* The use of statusCodeEquals() is not available.
*
* @param int $code
* The status code.
*/
public function statusCodeEquals($code) {
@trigger_error('WebDriverWebAssert::statusCodeEquals() is deprecated in drupal:8.4.0 and is removed in drupal:12.0.0. See https://www.drupal.org/node/2857562', E_USER_DEPRECATED);
parent::statusCodeEquals($code);
}
/**
* The use of statusCodeNotEquals() is not available.
*
* @param int $code
* The status code.
*/
public function statusCodeNotEquals($code) {
@trigger_error('WebDriverWebAssert::statusCodeNotEquals() is deprecated in drupal:8.4.0 and is removed in drupal:12.0.0. See https://www.drupal.org/node/2857562', E_USER_DEPRECATED);
parent::statusCodeNotEquals($code);
}
/**
* The use of responseHeaderEquals() is not available.
*
* @param string $name
* The name of the header.
* @param string $value
* The value to check the header against.
*/
public function responseHeaderEquals($name, $value) {
@trigger_error('WebDriverWebAssert::responseHeaderEquals() is deprecated in drupal:8.4.0 and is removed in drupal:12.0.0. See https://www.drupal.org/node/2857562', E_USER_DEPRECATED);
parent::responseHeaderEquals($name, $value);
}
/**
* The use of responseHeaderNotEquals() is not available.
*
* @param string $name
* The name of the header.
* @param string $value
* The value to check the header against.
*/
public function responseHeaderNotEquals($name, $value) {
@trigger_error('WebDriverWebAssert::responseHeaderNotEquals() is deprecated in drupal:8.4.0 and is removed in drupal:12.0.0. See https://www.drupal.org/node/2857562', E_USER_DEPRECATED);
parent::responseHeaderNotEquals($name, $value);
}
/**
* The use of responseHeaderContains() is not available.
*
* @param string $name
* The name of the header.
* @param string $value
* The value to check the header against.
*/
public function responseHeaderContains($name, $value) {
@trigger_error('WebDriverWebAssert::responseHeaderContains() is deprecated in drupal:8.4.0 and is removed in drupal:12.0.0. See https://www.drupal.org/node/2857562', E_USER_DEPRECATED);
parent::responseHeaderContains($name, $value);
}
/**
* The use of responseHeaderNotContains() is not available.
*
* @param string $name
* The name of the header.
* @param string $value
* The value to check the header against.
*/
public function responseHeaderNotContains($name, $value) {
@trigger_error('WebDriverWebAssert::responseHeaderNotContains() is deprecated in drupal:8.4.0 and is removed in drupal:12.0.0. See https://www.drupal.org/node/2857562', E_USER_DEPRECATED);
parent::responseHeaderNotContains($name, $value);
}
/**
* The use of responseHeaderMatches() is not available.
*
* @param string $name
* The name of the header.
* @param string $regex
* The value to check the header against.
*/
public function responseHeaderMatches($name, $regex) {
@trigger_error('WebDriverWebAssert::responseHeaderMatches() is deprecated in drupal:8.4.0 and is removed in drupal:12.0.0. See https://www.drupal.org/node/2857562', E_USER_DEPRECATED);
parent::responseHeaderMatches($name, $regex);
}
/**
* The use of responseHeaderNotMatches() is not available.
*
* @param string $name
* The name of the header.
* @param string $regex
* The value to check the header against.
*/
public function responseHeaderNotMatches($name, $regex) {
@trigger_error('WebDriverWebAssert::responseHeaderNotMatches() is deprecated in drupal:8.4.0 and is removed in drupal:12.0.0. See https://www.drupal.org/node/2857562', E_USER_DEPRECATED);
parent::responseHeaderNotMatches($name, $regex);
}
}