// (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 } from './behat-dom'; import { TestingBehatBlocking } from './behat-blocking'; import { CoreCustomURLSchemes, CoreCustomURLSchemesProvider } from '@services/urlschemes'; import { CoreLoginHelperProvider } from '@features/login/services/login-helper'; import { CoreConfig } from '@services/config'; import { EnvironmentConfig } from '@/types/config'; import { 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 { CoreComponentsRegistry } from '@singletons/components-registry'; import { CoreDom } from '@singletons/dom'; import { Injectable } from '@angular/core'; import { CoreSites, CoreSitesProvider } from '@services/sites'; /** * Behat runtime servive with public API. */ @Injectable({ providedIn: 'root' }) export class TestingBehatRuntimeService { protected initialized = false; 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; } /** * 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(CoreLoginHelperProvider.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 }); } } /** * 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. * @return 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. * * @return 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 => CoreComponentsRegistry.waitComponentReady(element, CoreLoadingComponent))); }); } /** * Function to find and click an app standard button. * * @param button Type of button to press. * @return 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. * * @return OK if successful, or ERROR: followed by message */ closePopup(): string { this.log('Action - Close popup'); let backdrops = Array.from(document.querySelectorAll('ion-backdrop')); backdrops = backdrops.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+')'; } const backdrop = backdrops[0]; backdrop.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. * @return 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. * @return 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; } } /** * Load more items form an active list with infinite loader. * * @return 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. * @return 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, document.body) ? 'YES' : 'NO'; } catch (error) { return 'ERROR: ' + error.message; } } /** * Function to press arbitrary item based on its text or Aria label. * * @param locator Element locator. * @return OK if successful, or ERROR: followed by message */ async press(locator: TestingBehatElementLocator): Promise { 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; } } /** * Trigger a pull to refresh gesture in the current page. * * @return OK if successful, or ERROR: followed by message */ async pullToRefresh(): Promise { this.log('Action - pullToRefresh'); try { // 'el' is protected, but there's no other way to trigger refresh programatically. const ionRefresher = this.getAngularInstance<{ el: HTMLIonRefresherElement }>( 'ion-refresher', 'IonRefresher', ); if (!ionRefresher) { return 'ERROR: It\'s not possible to pull to refresh the current page.'; } ionRefresher.el.dispatchEvent(new CustomEvent('ionRefresh')); return 'OK'; } catch (error) { return 'ERROR: ' + error.message; } } /** * Gets the currently displayed page header. * * @return 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 * @return OK or ERROR: followed by message */ async setField(field: string, value: string): Promise { this.log('Action - Set field ' + field + ' to: ' + value); const found = this.findField(field); if (!found) { return 'ERROR: No element matches field to set.'; } await TestingBehatDomUtils.setElementValue(found, value); return 'OK'; } /** * 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 * @return OK or ERROR: followed by message */ async fieldMatches(field: string, value: string): Promise { this.log('Action - Field ' + field + ' matches value: ' + value); const found = this.findField(field); if (!found) { return 'ERROR: No element matches field to set.'; } const foundValue = 'value' in found ? found.value : found.innerText; if (value !== foundValue) { return `ERROR: Expecting value "${value}", found "${foundValue}" instead.`; } return 'OK'; } /** * Find a field. * * @param field Field name. * @return Field element. */ protected findField(field: string): HTMLElement | HTMLInputElement | undefined { return TestingBehatDomUtils.findElementBasedOnText( { text: field, selector: 'input, textarea, [contenteditable="true"], ion-select, ion-datetime' }, { onlyClickable: false, containerName: '' }, ); } /** * Get an Angular component instance. * * @param selector Element selector * @param className Constructor class name * @return Component instance */ getAngularInstance(selector: string, className: string): T | null { this.log('Action - Get Angular instance ' + selector + ', ' + className); // eslint-disable-next-line @typescript-eslint/no-explicit-any const activeElement = Array.from(document.querySelectorAll(`.ion-page:not(.ion-page-hidden) ${selector}`)).pop(); if (!activeElement || !activeElement.__ngContext__) { return null; } return activeElement.__ngContext__.find(node => node?.constructor?.name === className); } /** * 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 } } 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; };