620 lines
21 KiB
TypeScript
620 lines
21 KiB
TypeScript
// (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[] {
|
|
// Escape double quotes to prevent breaking the query selector.
|
|
const escapedText = text.replace(/"/g, '\\"');
|
|
const attributesSelector = `[aria-label*="${escapedText}"], a[title*="${escapedText}"], ` +
|
|
`img[alt*="${escapedText}"], [placeholder*="${escapedText}"]`;
|
|
|
|
const elements = Array.from(container.querySelectorAll<HTMLElement>(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<HTMLElement>(`#${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<HTMLElement>([
|
|
'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<HTMLElement>('.ion-page:not(.ion-page-hidden)'));
|
|
let topContainer = pageContainers.find((page) => !page.closest('.ion-page.ion-page-hidden')) ?? null;
|
|
|
|
topContainer = (topContainer || container).querySelector<HTMLElement>('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) {
|
|
return [];
|
|
} else if (withinElements.length > 1) {
|
|
const withinElementsAncestors = this.getTopAncestors(withinElements);
|
|
|
|
if (withinElementsAncestors.length > 1) {
|
|
// Too many matches for within text.
|
|
return [];
|
|
}
|
|
|
|
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) {
|
|
return [];
|
|
} else if (nearElements.length > 1) {
|
|
const nearElementsAncestors = this.getTopAncestors(nearElements);
|
|
|
|
if (nearElementsAncestors.length > 1) {
|
|
// Too many matches for near text.
|
|
return [];
|
|
}
|
|
|
|
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<DOMRect> {
|
|
const initialRect = element.getBoundingClientRect();
|
|
|
|
element.scrollIntoView(false);
|
|
|
|
const promise = new CorePromisedValue<DOMRect>();
|
|
|
|
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<void> {
|
|
await NgZone.run(async () => {
|
|
const promise = new CorePromisedValue<void>();
|
|
|
|
// 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<void> {
|
|
await NgZone.run(async () => {
|
|
const promise = new CorePromisedValue<void>();
|
|
|
|
// 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;
|
|
};
|