MOBILE-4110 behat: Allow finding disabled elements

main
Noel De Martin 2022-06-29 12:17:29 +02:00
parent e974912880
commit a0363deb6a
5 changed files with 140 additions and 56 deletions

View File

@ -126,14 +126,14 @@ class behat_app extends behat_app_helper {
$containerName = json_encode($containerName); $containerName = json_encode($containerName);
$this->spin(function() use ($not, $locator, $containerName) { $this->spin(function() use ($not, $locator, $containerName) {
$result = $this->js("return window.behat.find($locator, $containerName);"); $result = $this->js("return window.behat.find($locator, { containerName: $containerName });");
if ($not && $result === 'OK') { if ($not && $result === 'OK') {
throw new DriverException('Error, found an item that should not be found'); throw new DriverException('Error, found an element that should not be found');
} }
if (!$not && $result !== 'OK') { if (!$not && $result !== 'OK') {
throw new DriverException('Error finding item - ' . $result); throw new DriverException('Error finding element - ' . $result);
} }
return true; return true;
@ -155,7 +155,7 @@ class behat_app extends behat_app_helper {
$result = $this->js("return window.behat.scrollTo($locator);"); $result = $this->js("return window.behat.scrollTo($locator);");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error finding item - ' . $result); throw new DriverException('Error finding element - ' . $result);
} }
return true; return true;
@ -224,16 +224,16 @@ class behat_app extends behat_app_helper {
switch ($result) { switch ($result) {
case 'YES': case 'YES':
if ($not) { if ($not) {
throw new ExpectationException("Item was selected and shouldn't have", $this->getSession()->getDriver()); throw new ExpectationException("Element was selected and shouldn't have", $this->getSession()->getDriver());
} }
break; break;
case 'NO': case 'NO':
if (!$not) { if (!$not) {
throw new ExpectationException("Item wasn't selected and should have", $this->getSession()->getDriver()); throw new ExpectationException("Element wasn't selected and should have", $this->getSession()->getDriver());
} }
break; break;
default: default:
throw new DriverException('Error finding item - ' . $result); throw new DriverException('Error finding element - ' . $result);
} }
return true; return true;
@ -536,7 +536,7 @@ class behat_app extends behat_app_helper {
* Clicks on / touches something that is visible in the app. * Clicks on / touches something that is visible in the app.
* *
* Note it is difficult to use the standard 'click on' or 'press' steps because those do not * Note it is difficult to use the standard 'click on' or 'press' steps because those do not
* distinguish visible items and the app always has many non-visible items in the DOM. * distinguish visible elements and the app always has many non-visible elements in the DOM.
* *
* @When /^I press (".+") in the app$/ * @When /^I press (".+") in the app$/
* @param string $locator Element locator * @param string $locator Element locator
@ -578,6 +578,33 @@ class behat_app extends behat_app_helper {
$this->wait_for_pending_js(); $this->wait_for_pending_js();
} }
/**
* Checks if elements can be pressed in the app.
*
* @Then /^I should( not)? be able to press (".+") in the app$/
* @param bool $not Whether to assert that the element cannot be pressed
* @param string $locator Element locator
*/
public function i_should_be_able_to_press_in_the_app(bool $not, string $locator) {
$locator = $this->parse_element_locator($locator);
$this->spin(function() use ($not, $locator) {
$result = $this->js("return window.behat.find($locator, { onlyClickable: true });");
if ($not && $result === 'OK') {
throw new DriverException('Error, found a clickable element that should not be found');
}
if (!$not && $result !== 'OK') {
throw new DriverException('Error finding clickable element - ' . $result);
}
return true;
});
$this->wait_for_pending_js();
}
/** /**
* Select an item from a list of options, such as a radio button. * Select an item from a list of options, such as a radio button.
* *
@ -602,11 +629,11 @@ class behat_app extends behat_app_helper {
return true; return true;
} }
// Press item. // Press element.
$result = $this->js("return await window.behat.press($locator);"); $result = $this->js("return await window.behat.press($locator);");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error pressing item - ' . $result); throw new DriverException('Error pressing element - ' . $result);
} }
// Check that it worked as expected. // Check that it worked as expected.

View File

@ -45,8 +45,9 @@ Feature: Users can manage entries in database activities
Scenario: Browse entry Scenario: Browse entry
Given I entered the data activity "Web links" on course "Course 1" as "student1" in the app Given I entered the data activity "Web links" on course "Course 1" as "student1" in the app
# TODO Create and use a generator for database entries. # TODO Create and use a generator for database entries.
And I press "Add entries" in the app When I press "Add entries" in the app
And I set the following fields to these values in the app: And I set the following fields to these values in the app:
| URL | https://moodle.org/ | | URL | https://moodle.org/ |
| Description | Moodle community site | | Description | Moodle community site |
@ -59,16 +60,19 @@ Feature: Users can manage entries in database activities
And I press "Save" near "Web links" in the app And I press "Save" near "Web links" in the app
And I press "More" near "Moodle community site" in the app And I press "More" near "Moodle community site" in the app
Then I should find "Moodle community site" in the app Then I should find "Moodle community site" in the app
And I should not find "Next" in the app And I should be able to press "Previous" in the app
And I should find "Previous" in the app But I should not be able to press "Next" in the app
And I press "Previous" in the app
And I should find "Moodle Cloud" in the app When I press "Previous" in the app
And I should find "Next" in the app Then I should find "Moodle Cloud" in the app
And I should not find "Previous" in the app And I should be able to press "Next" in the app
And I press "Next" in the app But I should not be able to press "Previous" in the app
And I should find "Moodle community site" in the app
And I should not find "Moodle Cloud" in the app When I press "Next" in the app
And I press the back button in the app Then I should find "Moodle community site" in the app
But I should not find "Moodle Cloud" in the app
When I press the back button in the app
And I should find "Moodle community site" in the app And I should find "Moodle community site" in the app
And I should find "Moodle Cloud" in the app And I should find "Moodle Cloud" in the app

View File

@ -79,9 +79,14 @@ export class TestingBehatDomUtils {
* *
* @param container Parent element to search the element within * @param container Parent element to search the element within
* @param text Text to look for * @param text Text to look for
* @param options Search options.
* @return Elements containing the given text with exact boolean. * @return Elements containing the given text with exact boolean.
*/ */
protected static findElementsBasedOnTextWithinWithExact(container: HTMLElement, text: string): ElementsWithExact[] { protected static findElementsBasedOnTextWithinWithExact(
container: HTMLElement,
text: string,
options: TestingBehatFindOptions,
): ElementsWithExact[] {
const attributesSelector = `[aria-label*="${text}"], a[title*="${text}"], img[alt*="${text}"], [placeholder*="${text}"]`; const attributesSelector = `[aria-label*="${text}"], a[title*="${text}"], img[alt*="${text}"], [placeholder*="${text}"]`;
const elements = Array.from(container.querySelectorAll<HTMLElement>(attributesSelector)) const elements = Array.from(container.querySelectorAll<HTMLElement>(attributesSelector))
@ -97,16 +102,23 @@ export class TestingBehatDomUtils {
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_DOCUMENT_FRAGMENT | NodeFilter.SHOW_TEXT, // eslint-disable-line no-bitwise NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_DOCUMENT_FRAGMENT | NodeFilter.SHOW_TEXT, // eslint-disable-line no-bitwise
{ {
acceptNode: node => { acceptNode: node => {
if (node instanceof HTMLStyleElement || if (
node instanceof HTMLStyleElement ||
node instanceof HTMLLinkElement || node instanceof HTMLLinkElement ||
node instanceof HTMLScriptElement) { node instanceof HTMLScriptElement
) {
return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_REJECT;
} }
if (node instanceof HTMLElement && if (!(node instanceof HTMLElement)) {
(node.getAttribute('aria-hidden') === 'true' || return NodeFilter.FILTER_ACCEPT;
node.getAttribute('aria-disabled') === 'true' || }
getComputedStyle(node).display === 'none')) {
if (options.onlyClickable && (node.getAttribute('aria-disabled') === 'true' || node.hasAttribute('disabled'))) {
return NodeFilter.FILTER_REJECT;
}
if (node.getAttribute('aria-hidden') === 'true' || getComputedStyle(node).display === 'none') {
return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_REJECT;
} }
@ -160,7 +172,7 @@ export class TestingBehatDomUtils {
continue; continue;
} }
elements.push(...this.findElementsBasedOnTextWithinWithExact(childNode, text)); elements.push(...this.findElementsBasedOnTextWithinWithExact(childNode, text, options));
} }
} }
} }
@ -187,10 +199,15 @@ export class TestingBehatDomUtils {
* *
* @param container Parent element to search the element within. * @param container Parent element to search the element within.
* @param text Text to look for. * @param text Text to look for.
* @param options Search options.
* @return Elements containing the given text. * @return Elements containing the given text.
*/ */
protected static findElementsBasedOnTextWithin(container: HTMLElement, text: string): HTMLElement[] { protected static findElementsBasedOnTextWithin(
const elements = this.findElementsBasedOnTextWithinWithExact(container, text); container: HTMLElement,
text: string,
options: TestingBehatFindOptions,
): HTMLElement[] {
const elements = this.findElementsBasedOnTextWithinWithExact(container, text, options);
// Give more relevance to exact matches. // Give more relevance to exact matches.
elements.sort((a, b) => Number(b.exact) - Number(a.exact)); elements.sort((a, b) => Number(b.exact) - Number(a.exact));
@ -325,32 +342,33 @@ export class TestingBehatDomUtils {
* Function to find element based on their text or Aria label. * Function to find element based on their text or Aria label.
* *
* @param locator Element locator. * @param locator Element locator.
* @param containerName Whether to search only inside a specific container. * @param options Search options.
* @return First found element. * @return First found element.
*/ */
static findElementBasedOnText(locator: TestingBehatElementLocator, containerName = ''): HTMLElement { static findElementBasedOnText(
return this.findElementsBasedOnText(locator, containerName, true)[0]; locator: TestingBehatElementLocator,
options: TestingBehatFindOptions,
): HTMLElement {
return this.findElementsBasedOnText(locator, options)[0];
} }
/** /**
* Function to find elements based on their text or Aria label. * Function to find elements based on their text or Aria label.
* *
* @param locator Element locator. * @param locator Element locator.
* @param containerName Whether to search only inside a specific container. * @param options Search options.
* @param stopWhenFound Stop looking in containers once an element is found.
* @return Found elements * @return Found elements
*/ */
protected static findElementsBasedOnText( protected static findElementsBasedOnText(
locator: TestingBehatElementLocator, locator: TestingBehatElementLocator,
containerName = '', options: TestingBehatFindOptions,
stopWhenFound = false,
): HTMLElement[] { ): HTMLElement[] {
const topContainers = this.getCurrentTopContainerElements(containerName); const topContainers = this.getCurrentTopContainerElements(options.containerName);
let elements: HTMLElement[] = []; let elements: HTMLElement[] = [];
for (let i = 0; i < topContainers.length; i++) { for (let i = 0; i < topContainers.length; i++) {
elements = elements.concat(this.findElementsBasedOnTextInContainer(locator, topContainers[i])); elements = elements.concat(this.findElementsBasedOnTextInContainer(locator, topContainers[i], options));
if (stopWhenFound && elements.length) { if (elements.length) {
break; break;
} }
} }
@ -363,16 +381,18 @@ export class TestingBehatDomUtils {
* *
* @param locator Element locator. * @param locator Element locator.
* @param topContainer Container to search in. * @param topContainer Container to search in.
* @param options Search options.
* @return Found elements * @return Found elements
*/ */
protected static findElementsBasedOnTextInContainer( protected static findElementsBasedOnTextInContainer(
locator: TestingBehatElementLocator, locator: TestingBehatElementLocator,
topContainer: HTMLElement, topContainer: HTMLElement,
options: TestingBehatFindOptions,
): HTMLElement[] { ): HTMLElement[] {
let container: HTMLElement | null = topContainer; let container: HTMLElement | null = topContainer;
if (locator.within) { if (locator.within) {
const withinElements = this.findElementsBasedOnTextInContainer(locator.within, topContainer); const withinElements = this.findElementsBasedOnTextInContainer(locator.within, topContainer, options);
if (withinElements.length === 0) { if (withinElements.length === 0) {
throw new Error('There was no match for within text'); throw new Error('There was no match for within text');
@ -390,7 +410,10 @@ export class TestingBehatDomUtils {
} }
if (topContainer && locator.near) { if (topContainer && locator.near) {
const nearElements = this.findElementsBasedOnTextInContainer(locator.near, topContainer); const nearElements = this.findElementsBasedOnTextInContainer(locator.near, topContainer, {
...options,
onlyClickable: false,
});
if (nearElements.length === 0) { if (nearElements.length === 0) {
throw new Error('There was no match for near text'); throw new Error('There was no match for near text');
@ -412,7 +435,7 @@ export class TestingBehatDomUtils {
break; break;
} }
const elements = this.findElementsBasedOnTextWithin(container, locator.text); const elements = this.findElementsBasedOnTextWithin(container, locator.text, options);
let filteredElements: HTMLElement[] = elements; let filteredElements: HTMLElement[] = elements;

View File

@ -153,23 +153,27 @@ export class TestingBehatRuntime {
// Find button // Find button
let foundButton: HTMLElement | undefined; let foundButton: HTMLElement | undefined;
const options: TestingBehatFindOptions = {
onlyClickable: true,
containerName: '',
};
switch (button) { switch (button) {
case 'back': case 'back':
foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'Back' }); foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'Back' }, options);
break; break;
case 'main menu': // Deprecated name. case 'main menu': // Deprecated name.
case 'more menu': case 'more menu':
foundButton = TestingBehatDomUtils.findElementBasedOnText({ foundButton = TestingBehatDomUtils.findElementBasedOnText({
text: 'More', text: 'More',
selector: 'ion-tab-button', selector: 'ion-tab-button',
}); }, options);
break; break;
case 'user menu' : case 'user menu' :
foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'User account' }); foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'User account' }, options);
break; break;
case 'page menu': case 'page menu':
foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'Display options' }); foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'Display options' }, options);
break; break;
default: default:
return 'ERROR: Unsupported standard button type'; return 'ERROR: Unsupported standard button type';
@ -215,20 +219,24 @@ export class TestingBehatRuntime {
* Function to find an arbitrary element based on its text or aria label. * Function to find an arbitrary element based on its text or aria label.
* *
* @param locator Element locator. * @param locator Element locator.
* @param containerName Whether to search only inside a specific container content. * @param options Search options.
* @return OK if successful, or ERROR: followed by message * @return OK if successful, or ERROR: followed by message
*/ */
static find(locator: TestingBehatElementLocator, containerName: string): string { static find(locator: TestingBehatElementLocator, options: Partial<TestingBehatFindOptions> = {}): string {
this.log('Action - Find', { locator, containerName }); this.log('Action - Find', { locator, ...options });
try { try {
const element = TestingBehatDomUtils.findElementBasedOnText(locator, containerName); const element = TestingBehatDomUtils.findElementBasedOnText(locator, {
onlyClickable: false,
containerName: '',
...options,
});
if (!element) { if (!element) {
return 'ERROR: No element matches locator to find.'; return 'ERROR: No element matches locator to find.';
} }
this.log('Action - Found', { locator, containerName, element }); this.log('Action - Found', { locator, element, ...options });
return 'OK'; return 'OK';
} catch (error) { } catch (error) {
@ -246,7 +254,7 @@ export class TestingBehatRuntime {
this.log('Action - scrollTo', { locator }); this.log('Action - scrollTo', { locator });
try { try {
let element = TestingBehatDomUtils.findElementBasedOnText(locator); let element = TestingBehatDomUtils.findElementBasedOnText(locator, { onlyClickable: false, containerName: '' });
if (!element) { if (!element) {
return 'ERROR: No element matches element to scroll to.'; return 'ERROR: No element matches element to scroll to.';
@ -320,7 +328,7 @@ export class TestingBehatRuntime {
this.log('Action - Is Selected', locator); this.log('Action - Is Selected', locator);
try { try {
const element = TestingBehatDomUtils.findElementBasedOnText(locator); const element = TestingBehatDomUtils.findElementBasedOnText(locator, { onlyClickable: false, containerName: '' });
return TestingBehatDomUtils.isElementSelected(element, document.body) ? 'YES' : 'NO'; return TestingBehatDomUtils.isElementSelected(element, document.body) ? 'YES' : 'NO';
} catch (error) { } catch (error) {
@ -338,7 +346,7 @@ export class TestingBehatRuntime {
this.log('Action - Press', locator); this.log('Action - Press', locator);
try { try {
const found = TestingBehatDomUtils.findElementBasedOnText(locator); const found = TestingBehatDomUtils.findElementBasedOnText(locator, { onlyClickable: true, containerName: '' });
if (!found) { if (!found) {
return 'ERROR: No element matches locator to press.'; return 'ERROR: No element matches locator to press.';
@ -421,6 +429,7 @@ export class TestingBehatRuntime {
const found: HTMLElement | HTMLInputElement = TestingBehatDomUtils.findElementBasedOnText( const found: HTMLElement | HTMLInputElement = TestingBehatDomUtils.findElementBasedOnText(
{ text: field, selector: 'input, textarea, [contenteditable="true"], ion-select' }, { text: field, selector: 'input, textarea, [contenteditable="true"], ion-select' },
{ onlyClickable: false, containerName: '' },
); );
if (!found) { if (!found) {
@ -478,6 +487,11 @@ export type BehatTestsWindow = Window & {
behat?: unknown; behat?: unknown;
}; };
export type TestingBehatFindOptions = {
containerName: string;
onlyClickable: boolean;
};
export type TestingBehatElementLocator = { export type TestingBehatElementLocator = {
text: string; text: string;
within?: TestingBehatElementLocator; within?: TestingBehatElementLocator;

View File

@ -0,0 +1,16 @@
@app @javascript
Feature: It has a Behat runtime with testing helpers.
Background:
Given the following "users" exist:
| username |
| student1 |
Scenario: Finds and presses elements
Given I entered the app as "student1"
When I set the following fields to these values in the app:
| Search by activity type or name | Foo bar |
Then I should find "Search" "button" in the app
And I should find "Clear search" in the app
And I should be able to press "Search" "button" in the app
But I should not be able to press "Clear search" in the app