diff --git a/local-moodleappbehat/tests/behat/behat_app.php b/local-moodleappbehat/tests/behat/behat_app.php index 2130ce540..72c389209 100644 --- a/local-moodleappbehat/tests/behat/behat_app.php +++ b/local-moodleappbehat/tests/behat/behat_app.php @@ -126,14 +126,14 @@ class behat_app extends behat_app_helper { $containerName = json_encode($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') { - 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') { - throw new DriverException('Error finding item - ' . $result); + throw new DriverException('Error finding element - ' . $result); } return true; @@ -155,7 +155,7 @@ class behat_app extends behat_app_helper { $result = $this->js("return window.behat.scrollTo($locator);"); if ($result !== 'OK') { - throw new DriverException('Error finding item - ' . $result); + throw new DriverException('Error finding element - ' . $result); } return true; @@ -224,16 +224,16 @@ class behat_app extends behat_app_helper { switch ($result) { case 'YES': 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; case 'NO': 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; default: - throw new DriverException('Error finding item - ' . $result); + throw new DriverException('Error finding element - ' . $result); } return true; @@ -536,7 +536,7 @@ class behat_app extends behat_app_helper { * 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 - * 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$/ * @param string $locator Element locator @@ -578,6 +578,33 @@ class behat_app extends behat_app_helper { $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. * @@ -602,11 +629,11 @@ class behat_app extends behat_app_helper { return true; } - // Press item. + // Press element. $result = $this->js("return await window.behat.press($locator);"); if ($result !== 'OK') { - throw new DriverException('Error pressing item - ' . $result); + throw new DriverException('Error pressing element - ' . $result); } // Check that it worked as expected. diff --git a/src/addons/mod/data/tests/behat/entries.feature b/src/addons/mod/data/tests/behat/entries.feature index 0551dc081..25f960d88 100644 --- a/src/addons/mod/data/tests/behat/entries.feature +++ b/src/addons/mod/data/tests/behat/entries.feature @@ -45,8 +45,9 @@ Feature: Users can manage entries in database activities Scenario: Browse entry 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. - 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: | URL | https://moodle.org/ | | 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 "More" near "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 find "Previous" in the app - And I press "Previous" in the app - And I should find "Moodle Cloud" in the app - And I should find "Next" in the app - And I should not find "Previous" in the app - And I press "Next" in the app - And I should find "Moodle community site" in the app - And I should not find "Moodle Cloud" in the app - And I press the back button in the app + And I should be able to press "Previous" in the app + But I should not be able to press "Next" in the app + + When I press "Previous" in the app + Then I should find "Moodle Cloud" in the app + And I should be able to press "Next" in the app + But I should not be able to press "Previous" in the app + + When I press "Next" 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 Cloud" in the app diff --git a/src/testing/services/behat-dom.ts b/src/testing/services/behat-dom.ts index e175313f4..a5e901c2a 100644 --- a/src/testing/services/behat-dom.ts +++ b/src/testing/services/behat-dom.ts @@ -79,9 +79,14 @@ export class TestingBehatDomUtils { * * @param container Parent element to search the element within * @param text Text to look for + * @param options Search options. * @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 elements = Array.from(container.querySelectorAll(attributesSelector)) @@ -97,16 +102,23 @@ export class TestingBehatDomUtils { NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_DOCUMENT_FRAGMENT | NodeFilter.SHOW_TEXT, // eslint-disable-line no-bitwise { acceptNode: node => { - if (node instanceof HTMLStyleElement || + if ( + node instanceof HTMLStyleElement || node instanceof HTMLLinkElement || - node instanceof HTMLScriptElement) { + node instanceof HTMLScriptElement + ) { return NodeFilter.FILTER_REJECT; } - if (node instanceof HTMLElement && - (node.getAttribute('aria-hidden') === 'true' || - node.getAttribute('aria-disabled') === 'true' || - getComputedStyle(node).display === 'none')) { + if (!(node instanceof HTMLElement)) { + return NodeFilter.FILTER_ACCEPT; + } + + 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; } @@ -160,7 +172,7 @@ export class TestingBehatDomUtils { 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 text Text to look for. + * @param options Search options. * @return Elements containing the given text. */ - protected static findElementsBasedOnTextWithin(container: HTMLElement, text: string): HTMLElement[] { - const elements = this.findElementsBasedOnTextWithinWithExact(container, text); + protected static findElementsBasedOnTextWithin( + container: HTMLElement, + text: string, + options: TestingBehatFindOptions, + ): HTMLElement[] { + const elements = this.findElementsBasedOnTextWithinWithExact(container, text, options); // Give more relevance to exact matches. 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. * * @param locator Element locator. - * @param containerName Whether to search only inside a specific container. + * @param options Search options. * @return First found element. */ - static findElementBasedOnText(locator: TestingBehatElementLocator, containerName = ''): HTMLElement { - return this.findElementsBasedOnText(locator, containerName, true)[0]; + static findElementBasedOnText( + locator: TestingBehatElementLocator, + options: TestingBehatFindOptions, + ): HTMLElement { + return this.findElementsBasedOnText(locator, options)[0]; } /** * Function to find elements based on their text or Aria label. * * @param locator Element locator. - * @param containerName Whether to search only inside a specific container. - * @param stopWhenFound Stop looking in containers once an element is found. + * @param options Search options. * @return Found elements */ protected static findElementsBasedOnText( locator: TestingBehatElementLocator, - containerName = '', - stopWhenFound = false, + options: TestingBehatFindOptions, ): HTMLElement[] { - const topContainers = this.getCurrentTopContainerElements(containerName); + const topContainers = this.getCurrentTopContainerElements(options.containerName); let elements: HTMLElement[] = []; for (let i = 0; i < topContainers.length; i++) { - elements = elements.concat(this.findElementsBasedOnTextInContainer(locator, topContainers[i])); - if (stopWhenFound && elements.length) { + elements = elements.concat(this.findElementsBasedOnTextInContainer(locator, topContainers[i], options)); + if (elements.length) { break; } } @@ -363,16 +381,18 @@ export class TestingBehatDomUtils { * * @param locator Element locator. * @param topContainer Container to search in. + * @param options Search options. * @return Found elements */ protected static findElementsBasedOnTextInContainer( locator: TestingBehatElementLocator, topContainer: HTMLElement, + options: TestingBehatFindOptions, ): HTMLElement[] { let container: HTMLElement | null = topContainer; if (locator.within) { - const withinElements = this.findElementsBasedOnTextInContainer(locator.within, topContainer); + const withinElements = this.findElementsBasedOnTextInContainer(locator.within, topContainer, options); if (withinElements.length === 0) { throw new Error('There was no match for within text'); @@ -390,7 +410,10 @@ export class TestingBehatDomUtils { } 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) { throw new Error('There was no match for near text'); @@ -412,7 +435,7 @@ export class TestingBehatDomUtils { break; } - const elements = this.findElementsBasedOnTextWithin(container, locator.text); + const elements = this.findElementsBasedOnTextWithin(container, locator.text, options); let filteredElements: HTMLElement[] = elements; diff --git a/src/testing/services/behat-runtime.ts b/src/testing/services/behat-runtime.ts index 17fe661f1..554ae6a7d 100644 --- a/src/testing/services/behat-runtime.ts +++ b/src/testing/services/behat-runtime.ts @@ -153,23 +153,27 @@ export class TestingBehatRuntime { // Find button let foundButton: HTMLElement | undefined; + const options: TestingBehatFindOptions = { + onlyClickable: true, + containerName: '', + }; switch (button) { case 'back': - foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'Back' }); + foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'Back' }, options); break; case 'main menu': // Deprecated name. case 'more menu': foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'More', selector: 'ion-tab-button', - }); + }, options); break; case 'user menu' : - foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'User account' }); + foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'User account' }, options); break; case 'page menu': - foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'Display options' }); + foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'Display options' }, options); break; default: 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. * * @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 */ - static find(locator: TestingBehatElementLocator, containerName: string): string { - this.log('Action - Find', { locator, containerName }); + static find(locator: TestingBehatElementLocator, options: Partial = {}): string { + this.log('Action - Find', { locator, ...options }); try { - const element = TestingBehatDomUtils.findElementBasedOnText(locator, containerName); + const element = TestingBehatDomUtils.findElementBasedOnText(locator, { + onlyClickable: false, + containerName: '', + ...options, + }); if (!element) { return 'ERROR: No element matches locator to find.'; } - this.log('Action - Found', { locator, containerName, element }); + this.log('Action - Found', { locator, element, ...options }); return 'OK'; } catch (error) { @@ -246,7 +254,7 @@ export class TestingBehatRuntime { this.log('Action - scrollTo', { locator }); try { - let element = TestingBehatDomUtils.findElementBasedOnText(locator); + let element = TestingBehatDomUtils.findElementBasedOnText(locator, { onlyClickable: false, containerName: '' }); if (!element) { return 'ERROR: No element matches element to scroll to.'; @@ -320,7 +328,7 @@ export class TestingBehatRuntime { this.log('Action - Is Selected', locator); try { - const element = TestingBehatDomUtils.findElementBasedOnText(locator); + const element = TestingBehatDomUtils.findElementBasedOnText(locator, { onlyClickable: false, containerName: '' }); return TestingBehatDomUtils.isElementSelected(element, document.body) ? 'YES' : 'NO'; } catch (error) { @@ -338,7 +346,7 @@ export class TestingBehatRuntime { this.log('Action - Press', locator); try { - const found = TestingBehatDomUtils.findElementBasedOnText(locator); + const found = TestingBehatDomUtils.findElementBasedOnText(locator, { onlyClickable: true, containerName: '' }); if (!found) { return 'ERROR: No element matches locator to press.'; @@ -421,6 +429,7 @@ export class TestingBehatRuntime { const found: HTMLElement | HTMLInputElement = TestingBehatDomUtils.findElementBasedOnText( { text: field, selector: 'input, textarea, [contenteditable="true"], ion-select' }, + { onlyClickable: false, containerName: '' }, ); if (!found) { @@ -478,6 +487,11 @@ export type BehatTestsWindow = Window & { behat?: unknown; }; +export type TestingBehatFindOptions = { + containerName: string; + onlyClickable: boolean; +}; + export type TestingBehatElementLocator = { text: string; within?: TestingBehatElementLocator; diff --git a/src/tests/behat/runtime.feature b/src/tests/behat/runtime.feature new file mode 100644 index 000000000..2b9c03657 --- /dev/null +++ b/src/tests/behat/runtime.feature @@ -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