// (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 { TestingBehatDomUtils, TestingBehatDomUtilsService } from './behat-dom'; import { TestingBehatBlocking } from './behat-blocking'; import { CoreCustomURLSchemes, CoreCustomURLSchemesProvider } from '@services/urlschemes'; import { ONBOARDING_DONE } from '@features/login/constants'; import { CoreConfig } from '@services/config'; import { EnvironmentConfig } from '@/types/config'; import { LocalNotifications, makeSingleton, NgZone } from '@singletons'; import { CoreNetwork, CoreNetworkService } from '@services/network'; import { CorePushNotifications, CorePushNotificationsProvider } from '@features/pushnotifications/services/pushnotifications'; import { CoreCronDelegate, CoreCronDelegateService } from '@services/cron'; import { CoreLoadingComponent } from '@components/loading/loading'; import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreDom } from '@singletons/dom'; import { Injectable } from '@angular/core'; import { CoreSites, CoreSitesProvider } from '@services/sites'; import { CoreNavigator, CoreNavigatorService } from '@services/navigator'; import { CoreSwipeNavigationDirective } from '@directives/swipe-navigation'; import { Swiper } from 'swiper'; import { LocalNotificationsMock } from '@features/emulator/services/local-notifications'; import { GetClosureArgs } from '@/core/utils/types'; import { CoreIframeComponent } from '@components/iframe/iframe'; /** * Behat runtime servive with public API. */ @Injectable({ providedIn: 'root' }) export class TestingBehatRuntimeService { protected initialized = false; protected openedUrls: { args: GetClosureArgs; contents?: string; }[] = []; get cronDelegate(): CoreCronDelegateService { return CoreCronDelegate.instance; } get customUrlSchemes(): CoreCustomURLSchemesProvider { return CoreCustomURLSchemes.instance; } get network(): CoreNetworkService { return CoreNetwork.instance; } get pushNotifications(): CorePushNotificationsProvider { return CorePushNotifications.instance; } get sites(): CoreSitesProvider { return CoreSites.instance; } get navigator(): CoreNavigatorService { return CoreNavigator.instance; } get domUtils(): TestingBehatDomUtilsService { return TestingBehatDomUtils.instance; } /** * Init behat functions and set options like skipping onboarding. * * @param options Options to set on the app. */ init(options: TestingBehatInitOptions = {}): void { if (this.initialized) { return; } this.initialized = true; TestingBehatBlocking.init(); if (options.skipOnBoarding) { CoreConfig.set(ONBOARDING_DONE, 1); } if (options.configOverrides) { // Set the cookie so it's maintained between reloads. document.cookie = 'MoodleAppConfig=' + JSON.stringify(options.configOverrides); CoreConfig.patchEnvironment(options.configOverrides, { patchDefault: true }); } // Spy on window.open. const originalOpen = window.open.bind(window); window.open = (...args) => { this.openedUrls.push({ args }); return originalOpen(...args); }; // Reduce iframes timeout to speed up tests. CoreIframeComponent.loadingTimeout = 1000; } /** * Check whether the service has been initialized or not. * * @returns Whether the service has been initialized or not. */ hasInitialized(): boolean { return this.initialized; } /** * Run an operation inside the angular zone and return result. * * @param operation Operation callback. * @returns OK if successful, or ERROR: followed by message. */ async runInZone(operation: () => unknown, blocking: boolean = false): Promise { const blockKey = blocking && TestingBehatBlocking.block(); try { await NgZone.run(operation); return 'OK'; } catch (error) { return 'ERROR: ' + error.message; } finally { blockKey && TestingBehatBlocking.unblock(blockKey); } } /** * Wait all controlled components to be rendered. * * @returns Promise resolved when all components have been rendered. */ async waitLoadingToFinish(): Promise { await NgZone.run(async () => { const elements = Array.from(document.body.querySelectorAll('core-loading')) .filter((element) => CoreDom.isElementVisible(element)); await Promise.all(elements.map(element => CoreDirectivesRegistry.waitDirectiveReady(element, CoreLoadingComponent))); }); } /** * Function to find and click an app standard button. * * @param button Type of button to press. * @returns OK if successful, or ERROR: followed by message. */ async pressStandard(button: string): Promise { this.log('Action - Click standard button: ' + button); // Find button let foundButton: HTMLElement | undefined; const options: TestingBehatFindOptions = { onlyClickable: true, containerName: '', }; switch (button) { case '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' }, options); break; case 'page menu': foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'Display options' }, options); break; default: return 'ERROR: Unsupported standard button type'; } if (!foundButton) { return `ERROR: Button '${button}' not found`; } // Click button await TestingBehatDomUtils.pressElement(foundButton); return 'OK'; } /** * When there is a popup, clicks on the backdrop. * * @returns OK if successful, or ERROR: followed by message */ closePopup(): string { this.log('Action - Close popup'); const backdrops = [ ...Array .from(document.querySelectorAll('ion-popover, ion-modal')) .map(popover => popover.shadowRoot?.querySelector('ion-backdrop')) .filter(backdrop => !!backdrop), ...Array .from(document.querySelectorAll('ion-backdrop')) .filter(backdrop => !!backdrop.offsetParent), ]; if (!backdrops.length) { return 'ERROR: Could not find backdrop'; } if (backdrops.length > 1) { return 'ERROR: Found too many backdrops ('+backdrops.length+')'; } backdrops[0]?.click(); // Mark busy until the click finishes processing. TestingBehatBlocking.delay(); return 'OK'; } /** * Function to find an arbitrary element based on its text or aria label. * * @param locator Element locator. * @param options Search options. * @returns OK if successful, or ERROR: followed by message */ find(locator: TestingBehatElementLocator, options: Partial = {}): string { this.log('Action - Find', { locator, ...options }); try { const element = TestingBehatDomUtils.findElementBasedOnText(locator, { onlyClickable: false, containerName: '', ...options, }); if (!element) { return 'ERROR: No element matches locator to find.'; } this.log('Action - Found', { locator, element, ...options }); return 'OK'; } catch (error) { return 'ERROR: ' + error.message; } } /** * Scroll an element into view. * * @param locator Element locator. * @returns OK if successful, or ERROR: followed by message */ scrollTo(locator: TestingBehatElementLocator): string { this.log('Action - scrollTo', { locator }); try { let element = TestingBehatDomUtils.findElementBasedOnText(locator, { onlyClickable: false, containerName: '' }); if (!element) { return 'ERROR: No element matches element to scroll to.'; } element = element.closest('ion-item') ?? element.closest('button') ?? element; element.scrollIntoView(); this.log('Action - Scrolled to', { locator, element }); return 'OK'; } catch (error) { return 'ERROR: ' + error.message; } } /** * Check whether the given url has been opened in the app. * * @param urlPattern Url pattern. * @param contents Url contents. * @param times How many times it should have been opened. * @returns OK if successful, or ERROR: followed by message */ async hasOpenedUrl(urlPattern: string, contents: string, times: number): Promise { const urlRegExp = new RegExp(urlPattern); const urlMatches = await Promise.all(this.openedUrls.map(async (openedUrl) => { const renderedUrl = openedUrl.args[0]?.toString() ?? ''; if (!urlRegExp.test(renderedUrl)) { return false; } if (contents && !('contents' in openedUrl)) { const response = await fetch(renderedUrl); openedUrl.contents = await response.text(); } if (contents && contents !== openedUrl.contents) { return false; } return true; })); if (urlMatches.filter(matches => !!matches).length === times) { return 'OK'; } return times === 1 ? `ERROR: Url matching '${urlPattern}' with '${contents}' contents has not been opened once` : `ERROR: Url matching '${urlPattern}' with '${contents}' contents has not been opened ${times} times`; } /** * Load more items form an active list with infinite loader. * * @returns OK if successful, or ERROR: followed by message */ async loadMoreItems(): Promise { this.log('Action - loadMoreItems'); try { const infiniteLoading = Array .from(document.querySelectorAll('core-infinite-loading')) .find(element => !element.closest('.ion-page-hidden')); if (!infiniteLoading) { return 'ERROR: There isn\'t an infinite loader in the current page.'; } const initialOffset = infiniteLoading.offsetTop; const isLoading = () => !!infiniteLoading.querySelector('ion-spinner[aria-label]'); const isCompleted = () => !isLoading() && !infiniteLoading.querySelector('ion-button'); const hasMoved = () => infiniteLoading.offsetTop !== initialOffset; if (isCompleted()) { return 'ERROR: All items are already loaded.'; } infiniteLoading.scrollIntoView({ behavior: 'smooth' }); // Wait 100ms await new Promise(resolve => setTimeout(resolve, 100)); if (isLoading() || isCompleted() || hasMoved()) { return 'OK'; } infiniteLoading.querySelector('ion-button')?.click(); // Wait 100ms await new Promise(resolve => setTimeout(resolve, 100)); return (isLoading() || isCompleted() || hasMoved()) ? 'OK' : 'ERROR: Couldn\'t load more items.'; } catch (error) { return 'ERROR: ' + error.message; } } /** * Check whether an item is selected or not. * * @param locator Element locator. * @returns YES or NO if successful, or ERROR: followed by message */ isSelected(locator: TestingBehatElementLocator): string { this.log('Action - Is Selected', locator); try { const element = TestingBehatDomUtils.findElementBasedOnText(locator, { onlyClickable: false, containerName: '' }); if (!element) { return 'ERROR: No element matches locator to find.'; } return TestingBehatDomUtils.isElementSelected(element) ? 'YES' : 'NO'; } catch (error) { return 'ERROR: ' + error.message; } } /** * Function to press arbitrary item based on its text or Aria label. * * @param locator Element locator. * @returns OK if successful, or ERROR: followed by message */ async press(locator: TestingBehatElementLocator): Promise; async press(text: string, nearText?: string): Promise; async press(locatorOrText: TestingBehatElementLocator | string, nearText?: string): Promise { const locator = typeof locatorOrText === 'string' ? { text: locatorOrText } : locatorOrText; if (nearText) { locator.near = { text: nearText }; } this.log('Action - Press', locator); try { const found = TestingBehatDomUtils.findElementBasedOnText(locator, { onlyClickable: true, containerName: '' }); if (!found) { return 'ERROR: No element matches locator to press.'; } await TestingBehatDomUtils.pressElement(found); return 'OK'; } catch (error) { return 'ERROR: ' + error.message; } } /** * Get a file input id, adding it if necessary. * * @param locator Input locator. * @returns Input id if successful, or ERROR: followed by message */ async getFileInputId(locator: TestingBehatElementLocator): Promise { this.log('Action - Upload File', { locator }); try { const inputOrContainer = TestingBehatDomUtils.findElementBasedOnText(locator); if (!inputOrContainer) { return 'ERROR: No element matches input locator.'; } const input = inputOrContainer.matches('input[type="file"]') ? inputOrContainer : inputOrContainer.querySelector('input[type="file"]'); if (!input) { return 'ERROR: Input element does not contain a file input.'; } if (!input.hasAttribute('id')) { input.setAttribute('id', `file-${Date.now()}`); } return input.getAttribute('id') ?? ''; } catch (error) { return 'ERROR: ' + error.message; } } /** * Trigger a pull to refresh gesture in the current page. * * @returns OK if successful, or ERROR: followed by message */ async pullToRefresh(): Promise { this.log('Action - pullToRefresh'); try { const ionRefresher = this.getElement('ion-refresher'); if (!ionRefresher) { return 'ERROR: It\'s not possible to pull to refresh the current page.'; } ionRefresher.dispatchEvent(new CustomEvent('ionRefresh')); return 'OK'; } catch (error) { return 'ERROR: ' + error.message; } } /** * Gets the currently displayed page header. * * @returns OK: followed by header text if successful, or ERROR: followed by message. */ getHeader(): string { this.log('Action - Get header'); let titles = Array.from(document.querySelectorAll('.ion-page:not(.ion-page-hidden) > ion-header h1')); titles = titles.filter((title) => TestingBehatDomUtils.isElementVisible(title, document.body)); if (titles.length > 1) { return 'ERROR: Too many possible titles ('+titles.length+').'; } else if (!titles.length) { return 'ERROR: No title found.'; } else { const title = titles[0].innerText.trim(); return 'OK:' + title; } } /** * Sets the text of a field to the specified value. * * This currently matches fields only based on the placeholder attribute. * * @param field Field name * @param value New value * @returns OK or ERROR: followed by message */ async setField(field: string, value: string): Promise { this.log('Action - Set field ' + field + ' to: ' + value); const input = TestingBehatDomUtils.findField(field); if (!input) { return 'ERROR: No element matches field to set.'; } if (input instanceof HTMLSelectElement) { const options = Array.from(input.querySelectorAll('option')); value = options.find(option => option.value === value)?.value ?? options.find(option => option.text === value)?.value ?? options.find(option => option.text.includes(value))?.value ?? value; } else if (input.tagName === 'ION-SELECT') { const options = Array.from(input.querySelectorAll('ion-select-option')); value = options.find(option => option.value?.toString() === value)?.textContent?.trim() ?? options.find(option => option.textContent?.trim() === value)?.textContent?.trim() ?? options.find(option => option.textContent?.includes(value))?.textContent?.trim() ?? value; } try { await TestingBehatDomUtils.setInputValue(input, value); return 'OK'; } catch (error) { return `ERROR: ${error.message ?? 'Unknown error'}`; } } /** * Sets the text of a field to the specified value. * * This currently matches fields only based on the placeholder attribute. * * @param field Field name * @param value New value * @returns OK or ERROR: followed by message */ async fieldMatches(field: string, value: string): Promise { this.log('Action - Field ' + field + ' matches value: ' + value); const found = TestingBehatDomUtils.findField(field); if (!found) { return 'ERROR: No element matches field to set.'; } const foundValue = this.getFieldValue(found); if (value !== foundValue) { return `ERROR: Expecting value "${value}", found "${foundValue}" instead.`; } return 'OK'; } /** * Get the value of a certain field. * * @param element Field to get the value. * @returns Value. */ protected getFieldValue(element: HTMLElement | HTMLInputElement): string { if (element.tagName === 'ION-DATETIME-BUTTON' && element.shadowRoot) { return Array.from(element.shadowRoot.querySelectorAll('button')).map(button => button.innerText).join(' '); } if (element.tagName === 'ION-DATETIME') { const value = 'value' in element ? element.value : element.innerText; // Remove seconds from the value to ensure stability on tests. It could be improved using moment parsing if needed. return value.substring(0, value.length - 3); } return 'value' in element ? element.value : element.innerText; } /** * Get element instance. * * @param selector Element selector. * @param referenceLocator The locator to the reference element to start looking for. If not specified, document body. * @returns Element instance. */ private getElement(selector: string, referenceLocator?: TestingBehatElementLocator): T | null { let startingElement: HTMLElement | undefined = document.body; let queryPrefix = ''; if (referenceLocator) { startingElement = TestingBehatDomUtils.findElementBasedOnText(referenceLocator, { onlyClickable: false, containerName: '', }); if (!startingElement) { return null; } } else { // Searching the whole DOM, search only in visible pages. queryPrefix = '.ion-page:not(.ion-page-hidden) '; } return Array.from(startingElement.querySelectorAll(`${queryPrefix}${selector}`)).pop() as T ?? startingElement.closest(selector) as T; } /** * Logs information from this Behat runtime JavaScript, including the time and the 'BEHAT' * keyword so we can easily filter for it if needed. */ log(...args: unknown[]): void { const now = new Date(); const nowFormatted = String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0') + ':' + String(now.getSeconds()).padStart(2, '0') + '.' + String(now.getMilliseconds()).padStart(2, '0'); console.log('BEHAT: ' + nowFormatted, ...args); // eslint-disable-line no-console } /** * Flush pending notifications. */ flushNotifications(): void { (LocalNotifications as unknown as LocalNotificationsMock).flush(); } /** * Check a notification is present. * * @param title Title of the notification * @returns YES or NO: depending on the result. */ async notificationIsPresentWithText(title: string): Promise { const notifications = await LocalNotifications.getAllTriggered(); const notification = notifications.find((notification) => notification.title?.includes(title)); if (!notification) { return 'NO'; } if (!notification.id) { // Cannot check but has been triggered. return 'YES'; } return (await LocalNotifications.isPresent(notification.id)) ? 'YES' : 'NO'; } /** * Check a notification is scheduled. * * @param title Title of the notification * @param date Scheduled notification date. * @returns YES or NO: depending on the result. */ async notificationIsScheduledWithText(title: string, date?: number): Promise { const notifications = await LocalNotifications.getAllScheduled(); const notification = notifications.find( (notification) => notification.title?.includes(title) && (!date || notification.trigger?.at?.getTime() === date), ); return notification ? 'YES' : 'NO'; } /** * Close notification. * * @param title Title of the notification * @returns OK or ERROR */ async closeNotification(title: string): Promise { const notifications = await LocalNotifications.getAllTriggered(); const notification = notifications.find((notification) => notification.title?.includes(title)); if (!notification || !notification.id) { return `ERROR: Notification with title ${title} cannot be closed`; } await LocalNotifications.clear(notification.id); return 'OK'; } /** * Swipe in the app. * * @param direction Left or right. * @param locator Element locator to swipe. If not specified, swipe in the first ion-content found. * @returns OK if successful, or ERROR: followed by message */ swipe(direction: string, locator?: TestingBehatElementLocator): string { this.log('Action - Swipe', { direction, locator }); if (locator) { // Locator specified, try to find swiper-container first. const swiperContainer = this.getElement<{ swiper: Swiper }>('swiper-container', locator); if (swiperContainer) { direction === 'left' ? swiperContainer.swiper.slideNext() : swiperContainer.swiper.slidePrev(); return 'OK'; } } // No locator specified or swiper-container not found, search swipe navigation now. const ionContent = this.getElement<{ swipeNavigation: CoreSwipeNavigationDirective }>( 'ion-content.uses-swipe-navigation', locator, ); if (!ionContent) { return 'ERROR: Element to swipe not found.'; } direction === 'left' ? ionContent.swipeNavigation.swipeLeft() : ionContent.swipeNavigation.swipeRight(); return 'OK'; } } export const TestingBehatRuntime = makeSingleton(TestingBehatRuntimeService); export type BehatTestsWindow = Window & { M?: { // eslint-disable-line @typescript-eslint/naming-convention util?: { pending_js?: string[]; // eslint-disable-line @typescript-eslint/naming-convention }; }; }; export type TestingBehatFindOptions = { containerName?: string; onlyClickable?: boolean; }; export type TestingBehatElementLocator = { text: string; within?: TestingBehatElementLocator; near?: TestingBehatElementLocator; selector?: string; }; export type TestingBehatInitOptions = { skipOnBoarding?: boolean; configOverrides?: Partial; };