// (C) Copyright 2015 Moodle Pty Ltd. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { Injectable } from '@angular/core'; import { CorePromisedValue } from '@classes/promised-value'; import { CoreUtils } from '@services/utils/utils'; import { makeSingleton, NgZone } from '@singletons'; import { TestingBehatElementLocator, TestingBehatFindOptions } from './behat-runtime'; // Containers that block containers behind them. const blockingContainers = ['ION-ALERT', 'ION-POPOVER', 'ION-ACTION-SHEET', 'CORE-USER-TOURS-USER-TOUR', 'ION-PAGE']; /** * Behat Dom Utils helper functions. */ @Injectable({ providedIn: 'root' }) export class TestingBehatDomUtilsService { /** * Check if an element is visible. * * @param element Element. * @param container Container. * @return Whether the element is visible or not. */ isElementVisible(element: HTMLElement, container: HTMLElement): boolean { if (element.getAttribute('aria-hidden') === 'true' || getComputedStyle(element).display === 'none') { return false; } const parentElement = this.getParentElement(element); if (parentElement === container) { return true; } if (!parentElement) { return false; } return this.isElementVisible(parentElement, container); } /** * Check if an element is selected. * * @param element Element. * @param container Container. * @return Whether the element is selected or not. */ isElementSelected(element: HTMLElement, container: HTMLElement): boolean { const ariaCurrent = element.getAttribute('aria-current'); if ( (ariaCurrent && ariaCurrent !== 'false') || (element.getAttribute('aria-selected') === 'true') || (element.getAttribute('aria-checked') === 'true') ) { return true; } const parentElement = this.getParentElement(element); if (!parentElement || parentElement === container) { return false; } return this.isElementSelected(parentElement, container); }; /** * Finds elements within a given container with exact info. * * @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 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)) .filter((element => this.isElementVisible(element, container))) .map((element) => { const exact = this.checkElementLabel(element, text); return { element, exact }; }); const treeWalker = document.createTreeWalker( container, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_DOCUMENT_FRAGMENT | NodeFilter.SHOW_TEXT, // eslint-disable-line no-bitwise { acceptNode: node => { if ( node instanceof HTMLStyleElement || node instanceof HTMLLinkElement || node instanceof HTMLScriptElement ) { return NodeFilter.FILTER_REJECT; } 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; } return NodeFilter.FILTER_ACCEPT; }, }, ); let currentNode: Node | null = null; // eslint-disable-next-line no-cond-assign while (currentNode = treeWalker.nextNode()) { if (currentNode instanceof Text) { if (currentNode.textContent?.includes(text) && currentNode.parentElement) { elements.push({ element: currentNode.parentElement, exact: currentNode.textContent.trim() === text, }); } continue; } if (currentNode instanceof HTMLElement) { const labelledBy = currentNode.getAttribute('aria-labelledby'); const labelElement = labelledBy && container.querySelector(`#${labelledBy}`); if (labelElement && labelElement.innerText && labelElement.innerText.includes(text)) { elements.push({ element: currentNode, exact: labelElement.innerText.trim() == text, }); continue; } } if (currentNode instanceof Element && currentNode.shadowRoot) { for (const childNode of Array.from(currentNode.shadowRoot.childNodes)) { if (!(childNode instanceof HTMLElement) || ( childNode instanceof HTMLStyleElement || childNode instanceof HTMLLinkElement || childNode instanceof HTMLScriptElement)) { continue; } if (childNode.matches(attributesSelector)) { elements.push({ element: childNode, exact: this.checkElementLabel(childNode, text), }); continue; } elements.push(...this.findElementsBasedOnTextWithinWithExact(childNode, text, options)); } } } return elements; }; /** * Checks an element has exactly the same label (title, alt or aria-label). * * @param element Element to check. * @param text Text to check. * @return If text matches any of the label attributes. */ protected checkElementLabel(element: HTMLElement, text: string): boolean { return element.title === text || element.getAttribute('alt') === text || element.getAttribute('aria-label') === text || element.getAttribute('placeholder') === text; } /** * Finds elements within a given container. * * @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 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)); return elements.map(element => element.element); }; /** * Given a list of elements, get the top ancestors among all of them. * * This will remote duplicates and drop any elements nested within each other. * * @param elements Elements list. * @return Top ancestors. */ protected getTopAncestors(elements: HTMLElement[]): HTMLElement[] { const uniqueElements = new Set(elements); for (const element of uniqueElements) { for (const otherElement of uniqueElements) { if (otherElement === element) { continue; } if (element.contains(otherElement)) { uniqueElements.delete(otherElement); } } } return Array.from(uniqueElements); } /** * Get parent element, including Shadow DOM parents. * * @param element Element. * @return Parent element. */ protected getParentElement(element: HTMLElement): HTMLElement | null { return element.parentElement || (element.getRootNode() && (element.getRootNode() as ShadowRoot).host as HTMLElement) || null; } /** * Get closest element matching a selector, without traversing up a given container. * * @param element Element. * @param selector Selector. * @param container Topmost container to search within. * @return Closest matching element. */ protected getClosestMatching(element: HTMLElement, selector: string, container: HTMLElement | null): HTMLElement | null { if (element.matches(selector)) { return element; } if (element === container || !element.parentElement) { return null; } return this.getClosestMatching(element.parentElement, selector, container); }; /** * Function to find top container elements. * * @param containerName Whether to search inside the a container name. * @return Found top container elements. */ protected getCurrentTopContainerElements(containerName: string): HTMLElement[] { const topContainers: HTMLElement[] = []; let containers = Array.from(document.querySelectorAll([ 'ion-alert.hydrated', 'ion-popover.hydrated', 'ion-action-sheet.hydrated', 'ion-modal.hydrated', 'core-user-tours-user-tour.is-active', 'ion-toast.hydrated', 'page-core-mainmenu', 'ion-app', ].join(', '))); containers = containers .filter(container => { if (container.tagName === 'ION-ALERT') { // For some reason, in Behat sometimes alerts aren't removed from DOM, the close animation doesn't finish. // Filter alerts with pointer-events none since that style is set before the close animation starts. return container.style.pointerEvents !== 'none'; } // Ignore pages that are inside other visible pages. return container.tagName !== 'ION-PAGE' || !container.closest('.ion-page.ion-page-hidden'); }) // Sort them by z-index. .sort((a, b) => Number(getComputedStyle(b).zIndex) - Number(getComputedStyle(a).zIndex)); if (containerName === 'split-view content') { // Find non hidden pages inside the containers. containers.some(container => { if (!container.classList.contains('ion-page')) { return false; } const pageContainers = Array.from(container.querySelectorAll('.ion-page:not(.ion-page-hidden)')); let topContainer = pageContainers.find((page) => !page.closest('.ion-page.ion-page-hidden')) ?? null; topContainer = (topContainer || container).querySelector('core-split-view ion-router-outlet'); topContainer && topContainers.push(topContainer); return !!topContainer; }); return topContainers; } // Get containers until one blocks other views. containers.find(container => { if (container.tagName === 'ION-TOAST') { container = container.shadowRoot?.querySelector('.toast-container') || container; } topContainers.push(container); return blockingContainers.includes(container.tagName); }); return topContainers; }; /** * Function to find element based on their text or Aria label. * * @param locator Element locator. * @param options Search options. * @return First found element. */ 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 options Search options. * @return Found elements */ protected findElementsBasedOnText( locator: TestingBehatElementLocator, options: TestingBehatFindOptions, ): HTMLElement[] { 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], options)); if (elements.length) { break; } } return elements; } /** * Function to find elements based on their text or Aria label. * * @param locator Element locator. * @param topContainer Container to search in. * @param options Search options. * @return Found elements */ protected findElementsBasedOnTextInContainer( locator: TestingBehatElementLocator, topContainer: HTMLElement, options: TestingBehatFindOptions, ): HTMLElement[] { let container: HTMLElement | null = topContainer; if (locator.within) { const withinElements = this.findElementsBasedOnTextInContainer(locator.within, topContainer, options); if (withinElements.length === 0) { throw new Error('There was no match for within text'); } else if (withinElements.length > 1) { const withinElementsAncestors = this.getTopAncestors(withinElements); if (withinElementsAncestors.length > 1) { throw new Error('Too many matches for within text ('+withinElementsAncestors.length+')'); } topContainer = container = withinElementsAncestors[0]; } else { topContainer = container = withinElements[0]; } } if (topContainer && locator.near) { const nearElements = this.findElementsBasedOnTextInContainer(locator.near, topContainer, { ...options, onlyClickable: false, }); if (nearElements.length === 0) { throw new Error('There was no match for near text'); } else if (nearElements.length > 1) { const nearElementsAncestors = this.getTopAncestors(nearElements); if (nearElementsAncestors.length > 1) { throw new Error('Too many matches for near text ('+nearElementsAncestors.length+')'); } container = this.getParentElement(nearElementsAncestors[0]); } else { container = this.getParentElement(nearElements[0]); } } do { if (!container) { break; } const elements = this.findElementsBasedOnTextWithin(container, locator.text, options); let filteredElements: HTMLElement[] = elements; if (locator.selector) { filteredElements = []; const selector = locator.selector; elements.forEach((element) => { const closest = this.getClosestMatching(element, selector, container); if (closest) { filteredElements.push(closest); } }); } if (filteredElements.length > 0) { return filteredElements; } } while (container !== topContainer && (container = this.getParentElement(container)) && container !== topContainer); return []; }; /** * Make sure that an element is visible and wait to trigger the callback. * * @param element Element. */ protected async ensureElementVisible(element: HTMLElement): Promise { const initialRect = element.getBoundingClientRect(); element.scrollIntoView(false); const promise = new CorePromisedValue(); requestAnimationFrame(() => { const rect = element.getBoundingClientRect(); if (initialRect.y !== rect.y) { setTimeout(() => { promise.resolve(rect); }, 300); return; } promise.resolve(rect); }); return promise; }; /** * Press an element. * * @param element Element to press. */ async pressElement(element: HTMLElement): Promise { await NgZone.run(async () => { const promise = new CorePromisedValue(); // Events don't bubble up across Shadow DOM boundaries, and some buttons // may not work without doing this. const parentElement = this.getParentElement(element); if (parentElement && parentElement.matches('ion-button, ion-back-button')) { element = parentElement; } const rect = await this.ensureElementVisible(element); // Simulate a mouse click on the button. const eventOptions: MouseEventInit = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, bubbles: true, view: window, cancelable: true, }; // There are some buttons in the app that don't respond to click events, for example // buttons using the core-supress-events directive. That's why we need to send both // click and mouse events. element.dispatchEvent(new MouseEvent('mousedown', eventOptions)); setTimeout(() => { element.dispatchEvent(new MouseEvent('mouseup', eventOptions)); element.click(); promise.resolve(); }, 300); return promise; }); } /** * Set an element value. * * @param element HTML to set. * @param value Value to be set. */ async setElementValue(element: HTMLInputElement | HTMLElement, value: string): Promise { await NgZone.run(async () => { const promise = new CorePromisedValue(); // Functions to get/set value depending on field type. const setValue = (text: string) => { if (element.tagName === 'ION-SELECT' && 'value' in element) { value = value.trim(); const optionValue = Array.from(element.querySelectorAll('ion-select-option')) .find((option) => option.innerHTML.trim() === value); if (optionValue) { element.value = optionValue.value; } } else if ('value' in element) { element.value = text; } else { element.innerHTML = text; } }; const getValue = () => { if ('value' in element) { return element.value; } else { return element.innerHTML; } }; // Pretend we have cut and pasted the new text. let event: InputEvent; if (getValue() !== '') { event = new InputEvent('input', { bubbles: true, view: window, cancelable: true, inputType: 'deleteByCut', }); await CoreUtils.nextTick(); setValue(''); element.dispatchEvent(event); } if (value !== '') { event = new InputEvent('input', { bubbles: true, view: window, cancelable: true, inputType: 'insertFromPaste', data: value, }); await CoreUtils.nextTick(); setValue(value); element.dispatchEvent(event); } promise.resolve(); return promise; }); } } export const TestingBehatDomUtils = makeSingleton(TestingBehatDomUtilsService); type ElementsWithExact = { element: HTMLElement; exact: boolean; };