From 064ea15f8b58c96ec2fedcc74a4c676db96d580d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 12 May 2022 15:47:23 +0200 Subject: [PATCH] MOBILE-4061 behat: Include Behat runtime in the app --- gulpfile.js | 2 +- .../tests/behat/app_behat_runtime.js | 908 ------------------ .../tests/behat/behat_app.php | 86 +- src/app/app.module.ts | 2 + src/testing/behat-testing.module.prod.ts | 21 + src/testing/behat-testing.module.ts | 34 + src/testing/services/behat-blocking.ts | 241 +++++ src/testing/services/behat-dom.ts | 482 ++++++++++ src/testing/services/behat-runtime.ts | 391 ++++++++ 9 files changed, 1218 insertions(+), 949 deletions(-) delete mode 100644 local-moodleappbehat/tests/behat/app_behat_runtime.js create mode 100644 src/testing/behat-testing.module.prod.ts create mode 100644 src/testing/behat-testing.module.ts create mode 100644 src/testing/services/behat-blocking.ts create mode 100644 src/testing/services/behat-dom.ts create mode 100644 src/testing/services/behat-runtime.ts diff --git a/gulpfile.js b/gulpfile.js index 31db54239..7c7c5bb96 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -71,5 +71,5 @@ gulp.task('watch', () => { }); gulp.task('watch-behat', () => { - gulp.watch(['./tests/behat'], { interval: 500 }, gulp.parallel('behat')); + gulp.watch(['./src/**/*.feature', './local-moodleappbehat'], { interval: 500 }, gulp.parallel('behat')); }); diff --git a/local-moodleappbehat/tests/behat/app_behat_runtime.js b/local-moodleappbehat/tests/behat/app_behat_runtime.js deleted file mode 100644 index 9846e0cf1..000000000 --- a/local-moodleappbehat/tests/behat/app_behat_runtime.js +++ /dev/null @@ -1,908 +0,0 @@ -(function() { - // Set up the M object - only pending_js is implemented. - window.M = window.M ? window.M : {}; - const M = window.M; - M.util = M.util ? M.util : {}; - M.util.pending_js = M.util.pending_js ? M.util.pending_js : []; // eslint-disable-line camelcase - - /** - * Logs information from this Behat runtime JavaScript, including the time and the 'BEHAT' - * keyword so we can easily filter for it if needed. - * - * @param {string} text Information to log - */ - const log = function() { - 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, ...arguments); // eslint-disable-line no-console - }; - - /** - * Run after several setTimeouts to ensure queued events are finished. - * - * @param {function} target function to run - * @param {number} count Number of times to do setTimeout (leave blank for 10) - */ - const runAfterEverything = function(target, count) { - if (count === undefined) { - count = 10; - } - setTimeout(function() { - count--; - if (count == 0) { - target(); - } else { - runAfterEverything(target, count); - } - }, 0); - }; - - /** - * Adds a pending key to the array. - * - * @param {string} key Key to add - */ - const addPending = function(key) { - // Add a special DELAY entry whenever another entry is added. - if (window.M.util.pending_js.length == 0) { - window.M.util.pending_js.push('DELAY'); - } - window.M.util.pending_js.push(key); - - log('PENDING+: ' + window.M.util.pending_js); - }; - - /** - * Removes a pending key from the array. If this would clear the array, the actual clear only - * takes effect after the queued events are finished. - * - * @param {string} key Key to remove - */ - const removePending = function(key) { - // Remove the key immediately. - window.M.util.pending_js = window.M.util.pending_js.filter(function(x) { // eslint-disable-line camelcase - return x !== key; - }); - log('PENDING-: ' + window.M.util.pending_js); - - // If the only thing left is DELAY, then remove that as well, later... - if (window.M.util.pending_js.length === 1) { - runAfterEverything(function() { - // Check there isn't a spinner... - checkUIBlocked(); - - // Only remove it if the pending array is STILL empty after all that. - if (window.M.util.pending_js.length === 1) { - window.M.util.pending_js = []; // eslint-disable-line camelcase - log('PENDING-: ' + window.M.util.pending_js); - } - }); - } - }; - - /** - * Adds a pending key to the array, but removes it after some setTimeouts finish. - */ - const addPendingDelay = function() { - addPending('...'); - removePending('...'); - }; - - // Override XMLHttpRequest to mark things pending while there is a request waiting. - const realOpen = XMLHttpRequest.prototype.open; - let requestIndex = 0; - XMLHttpRequest.prototype.open = function() { - const index = requestIndex++; - const key = 'httprequest-' + index; - - try { - // Add to the list of pending requests. - addPending(key); - - // Detect when it finishes and remove it from the list. - this.addEventListener('loadend', function() { - removePending(key); - }); - - return realOpen.apply(this, arguments); - } catch (error) { - removePending(key); - throw error; - } - }; - - let waitingBlocked = false; - - /** - * Checks if a loading spinner is present and visible; if so, adds it to the pending array - * (and if not, removes it). - */ - const checkUIBlocked = function() { - const blocked = document.querySelector('span.core-loading-spinner, ion-loading, .click-block-active'); - if (blocked && blocked.offsetParent) { - if (!waitingBlocked) { - addPending('blocked'); - waitingBlocked = true; - } - } else { - if (waitingBlocked) { - removePending('blocked'); - waitingBlocked = false; - } - } - }; - - // It would be really beautiful if you could detect CSS transitions and animations, that would - // cover almost everything, but sadly there is no way to do this because the transitionstart - // and animationcancel events are not implemented in Chrome, so we cannot detect either of - // these reliably. Instead, we have to look for any DOM changes and do horrible polling. Most - // of the animations are set to 500ms so we allow it to continue from 500ms after any DOM - // change. - - let recentMutation = false; - let lastMutation; - - /** - * Called from the mutation callback to remove the pending tag after 500ms if nothing else - * gets mutated. - * - * This will be called after 500ms, then every 100ms until there have been no mutation events - * for 500ms. - */ - const pollRecentMutation = function() { - if (Date.now() - lastMutation > 500) { - recentMutation = false; - removePending('dom-mutation'); - } else { - setTimeout(pollRecentMutation, 100); - } - }; - - /** - * Mutation callback, called whenever the DOM is mutated. - */ - const mutationCallback = function() { - lastMutation = Date.now(); - if (!recentMutation) { - recentMutation = true; - addPending('dom-mutation'); - setTimeout(pollRecentMutation, 500); - } - // Also update the spinner presence if needed. - checkUIBlocked(); - }; - - // Set listener using the mutation callback. - const observer = new MutationObserver(mutationCallback); - observer.observe(document, {attributes: true, childList: true, subtree: true}); - - /** - * Check if an element is visible. - * - * @param {HTMLElement} element Element - * @param {HTMLElement} container Container - * @returns {boolean} Whether the element is visible or not - */ - const isElementVisible = (element, container) => { - if (element.getAttribute('aria-hidden') === 'true' || getComputedStyle(element).display === 'none') - return false; - - const parentElement = getParentElement(element); - if (parentElement === container) - return true; - - if (!parentElement) - return false; - - return isElementVisible(parentElement, container); - }; - - /** - * Check if an element is selected. - * - * @param {HTMLElement} element Element - * @param {HTMLElement} container Container - * @returns {boolean} Whether the element is selected or not - */ - const isElementSelected = (element, container) => { - const ariaCurrent = element.getAttribute('aria-current'); - if ( - (ariaCurrent && ariaCurrent !== 'false') || - (element.getAttribute('aria-selected') === 'true') || - (element.getAttribute('aria-checked') === 'true') - ) - return true; - - const parentElement = getParentElement(element); - if (!parentElement || parentElement === container) - return false; - - return isElementSelected(parentElement, container); - }; - - /** - * Finds elements within a given container with exact info. - * - * @param {HTMLElement} container Parent element to search the element within - * @param {string} text Text to look for - * @return {Array} Elements containing the given text with exact boolean. - */ - const findElementsBasedOnTextWithinWithExact = (container, text) => { - const elements = []; - const attributesSelector = `[aria-label*="${text}"], a[title*="${text}"], img[alt*="${text}"]`; - - for (const foundByAttributes of container.querySelectorAll(attributesSelector)) { - if (!isElementVisible(foundByAttributes, container)) - continue; - - const exact = foundByAttributes.title == text || foundByAttributes.alt == text || foundByAttributes.ariaLabel == text; - elements.push({ element: foundByAttributes, exact: exact }); - } - - const treeWalker = document.createTreeWalker( - container, - NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_DOCUMENT_FRAGMENT | NodeFilter.SHOW_TEXT, - { - acceptNode: node => { - if ( - node instanceof HTMLStyleElement || - node instanceof HTMLLinkElement || - node instanceof HTMLScriptElement - ) - return NodeFilter.FILTER_REJECT; - - if ( - node instanceof HTMLElement && ( - node.getAttribute('aria-hidden') === 'true' || getComputedStyle(node).display === 'none' - ) - ) - return NodeFilter.FILTER_REJECT; - - return NodeFilter.FILTER_ACCEPT; - } - }, - ); - - let currentNode; - while (currentNode = treeWalker.nextNode()) { - if (currentNode instanceof Text) { - if (currentNode.textContent.includes(text)) { - elements.push({ element: currentNode.parentElement, exact: currentNode.textContent.trim() == text }); - } - - continue; - } - - 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.shadowRoot) { - for (const childNode of currentNode.shadowRoot.childNodes) { - if ( - !(childNode instanceof HTMLElement) || ( - childNode instanceof HTMLStyleElement || - childNode instanceof HTMLLinkElement || - childNode instanceof HTMLScriptElement - ) - ) { - continue; - } - - if (childNode.matches(attributesSelector)) { - const exact = childNode.title == text || childNode.alt == text || childNode.ariaLabel == text; - elements.push({ element: childNode, exact: exact}); - - continue; - } - - elements.push(...findElementsBasedOnTextWithinWithExact(childNode, text)); - } - } - } - - return elements; - }; - - /** - * Finds elements within a given container. - * - * @param {HTMLElement} container Parent element to search the element within. - * @param {string} text Text to look for. - * @return {HTMLElement[]} Elements containing the given text. - */ - const findElementsBasedOnTextWithin = (container, text) => { - const elements = findElementsBasedOnTextWithinWithExact(container, text); - - // Give more relevance to exact matches. - elements.sort((a, b) => { - return b.exact - 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 {Array} elements Elements list. - * @return {Array} Top ancestors. - */ - const getTopAncestors = function(elements) { - const uniqueElements = new Set(elements); - - for (const element of uniqueElements) { - for (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 {HTMLElement} element Element. - * @return {HTMLElement} Parent element. - */ - const getParentElement = function(element) { - return element.parentElement || (element.getRootNode() && element.getRootNode().host) || null; - }; - - /** - * Get closest element matching a selector, without traversing up a given container. - * - * @param {HTMLElement} element Element. - * @param {string} selector Selector. - * @param {HTMLElement} container Topmost container to search within. - * @return {HTMLElement} Closest matching element. - */ - const getClosestMatching = function(element, selector, container) { - if (element.matches(selector)) { - return element; - } - - if (element === container || !element.parentElement) { - return null; - } - - return getClosestMatching(element.parentElement, selector, container); - }; - - /** - * Function to find top container element. - * - * @param {string} containerName Whether to search inside the a container name. - * @return {HTMLElement} Found top container element. - */ - const getCurrentTopContainerElement = function (containerName) { - let topContainer; - let containers; - - switch (containerName) { - case 'html': - containers = document.querySelectorAll('html'); - break; - case 'toast': - containers = document.querySelectorAll('ion-app ion-toast.hydrated'); - containers = Array.from(containers).map(container => container.shadowRoot.querySelector('.toast-container')); - break; - case 'alert': - containers = document.querySelectorAll('ion-app ion-alert.hydrated'); - break; - case 'action-sheet': - containers = document.querySelectorAll('ion-app ion-action-sheet.hydrated'); - break; - case 'modal': - containers = document.querySelectorAll('ion-app ion-modal.hydrated'); - break; - case 'popover': - containers = document.querySelectorAll('ion-app ion-popover.hydrated'); - break; - case 'user-tour': - containers = document.querySelectorAll('core-user-tours-user-tour.is-active'); - break; - default: - // Other containerName or not implemented. - const containerSelector = 'ion-alert, ion-popover, ion-action-sheet, ion-modal, core-user-tours-user-tour.is-active, page-core-mainmenu, ion-app'; - containers = document.querySelectorAll(containerSelector); - } - - if (containers.length > 0) { - // Get the one with more zIndex. - topContainer = Array.from(containers).reduce((a, b) => { - return getComputedStyle(a).zIndex > getComputedStyle(b).zIndex ? a : b; - }, containers[0]); - } - - if (containerName == 'page' || containerName == 'split-view content') { - // Find non hidden pages inside the container. - let pageContainers = topContainer.querySelectorAll('.ion-page:not(.ion-page-hidden)'); - pageContainers = Array.from(pageContainers).filter((page) => { - return !page.closest('.ion-page.ion-page-hidden'); - }); - - if (pageContainers.length > 0) { - // Get the more general one to avoid failing. - topContainer = pageContainers[0]; - } - - if (containerName == 'split-view content') { - topContainer = topContainer.querySelector('core-split-view ion-router-outlet'); - } - } - - return topContainer; - } - - /** - * Function to find elements based on their text or Aria label. - * - * @param {object} locator Element locator. - * @param {string} containerName Whether to search only inside a specific container. - * @return {HTMLElement} Found elements - */ - const findElementsBasedOnText = function(locator, containerName) { - let topContainer = getCurrentTopContainerElement(containerName); - - let container = topContainer; - - if (locator.within) { - const withinElements = findElementsBasedOnText(locator.within); - - if (withinElements.length === 0) { - throw new Error('There was no match for within text') - } else if (withinElements.length > 1) { - const withinElementsAncestors = getTopAncestors(withinElements); - - if (withinElementsAncestors.length > 1) { - throw new Error('Too many matches for within text'); - } - - topContainer = container = withinElementsAncestors[0]; - } else { - topContainer = container = withinElements[0]; - } - } - - if (topContainer && locator.near) { - const nearElements = findElementsBasedOnText(locator.near); - - if (nearElements.length === 0) { - throw new Error('There was no match for near text') - } else if (nearElements.length > 1) { - const nearElementsAncestors = getTopAncestors(nearElements); - - if (nearElementsAncestors.length > 1) { - throw new Error('Too many matches for near text'); - } - - container = getParentElement(nearElementsAncestors[0]); - } else { - container = getParentElement(nearElements[0]); - } - } - - do { - const elements = findElementsBasedOnTextWithin(container, locator.text); - const filteredElements = locator.selector - ? elements.map(element => getClosestMatching(element, locator.selector, container)).filter(element => !!element) - : elements; - - if (filteredElements.length > 0) { - return filteredElements; - } - } while (container !== topContainer && (container = getParentElement(container)) && container !== topContainer); - - return []; - }; - - /** - * Make sure that an element is visible and wait to trigger the callback. - * - * @param {HTMLElement} element Element. - * @param {Function} callback Callback called when the element is visible, passing bounding box parameter. - */ - const ensureElementVisible = function(element, callback) { - const initialRect = element.getBoundingClientRect(); - - element.scrollIntoView(false); - - requestAnimationFrame(function () { - const rect = element.getBoundingClientRect(); - - if (initialRect.y !== rect.y) { - setTimeout(function () { - callback(rect); - }, 300); - addPendingDelay(); - - return; - } - - callback(rect); - }); - - addPendingDelay(); - }; - - /** - * Press an element. - * - * @param {HTMLElement} element Element to press. - */ - const pressElement = function(element) { - ensureElementVisible(element, function(rect) { - // Simulate a mouse click on the button. - const eventOptions = { - clientX: rect.left + rect.width / 2, - clientY: rect.top + rect.height / 2, - bubbles: true, - view: window, - cancelable: true, - }; - - // Events don't bubble up across Shadow DOM boundaries, and some buttons - // may not work without doing this. - const parentElement = getParentElement(element); - - if (parentElement && parentElement.matches('ion-button, ion-back-button')) { - element = parentElement; - } - - // 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(); - }, 300); - - // Mark busy until the button click finishes processing. - addPendingDelay(); - }); - }; - - /** - * Function to find and click an app standard button. - * - * @param {string} button Type of button to press - * @return {string} OK if successful, or ERROR: followed by message - */ - const behatPressStandard = function(button) { - log('Action - Click standard button: ' + button); - - // Find button - let foundButton = null; - - switch (button) { - case 'back': - foundButton = findElementsBasedOnText({ text: 'Back' })[0]; - break; - case 'main menu': // Deprecated name. - case 'more menu': - foundButton = findElementsBasedOnText({ - text: 'More', - selector: 'ion-tab-button', - })[0]; - break; - case 'user menu' : - foundButton = findElementsBasedOnText({ text: 'User account' })[0]; - break; - case 'page menu': - foundButton = findElementsBasedOnText({ text: 'Display options' })[0]; - break; - default: - return 'ERROR: Unsupported standard button type'; - } - - // Click button - pressElement(foundButton); - - return 'OK'; - }; - - /** - * When there is a popup, clicks on the backdrop. - * - * @return {string} OK if successful, or ERROR: followed by message - */ - const behatClosePopup = function() { - log('Action - Close popup'); - - let backdrops = Array.from(document.querySelectorAll('ion-backdrop')); - backdrops = backdrops.filter(function(backdrop) { - return !!backdrop.offsetParent; - }); - - if (!backdrops.length) { - return 'ERROR: Could not find backdrop'; - } - if (backdrops.length > 1) { - return 'ERROR: Found too many backdrops'; - } - const backdrop = backdrops[0]; - backdrop.click(); - - // Mark busy until the click finishes processing. - addPendingDelay(); - - return 'OK'; - }; - - /** - * Function to find an arbitrary element based on its text or aria label. - * - * @param {object} locator Element locator. - * @param {string} containerName Whether to search only inside a specific container content. - * @return {string} OK if successful, or ERROR: followed by message - */ - const behatFind = function(locator, containerName) { - log('Action - Find', { locator, containerName }); - - try { - const element = findElementsBasedOnText(locator, containerName)[0]; - - if (!element) { - return 'ERROR: No matches for text'; - } - - log('Action - Found', { locator, containerName, element }); - return 'OK'; - } catch (error) { - return 'ERROR: ' + error.message; - } - }; - - /** - * Scroll an element into view. - * - * @param {object} locator Element locator. - * @return {string} OK if successful, or ERROR: followed by message - */ - const behatScrollTo = function(locator) { - log('Action - scrollTo', { locator }); - - try { - let element = findElementsBasedOnText(locator)[0]; - - if (!element) { - return 'ERROR: No matches for text'; - } - - element = element.closest('ion-item') ?? element.closest('button') ?? element; - - element.scrollIntoView(); - - 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 {string} OK if successful, or ERROR: followed by message - */ - const behatLoadMoreItems = async function() { - 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 {object} locator Element locator. - * @return {string} YES or NO if successful, or ERROR: followed by message - */ - const behatIsSelected = function(locator) { - log('Action - Is Selected', locator); - - try { - const element = findElementsBasedOnText(locator)[0]; - - return 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 {object} locator Element locator. - * @return {string} OK if successful, or ERROR: followed by message - */ - const behatPress = function(locator) { - log('Action - Press', locator); - - let found; - try { - found = findElementsBasedOnText(locator)[0]; - - if (!found) { - return 'ERROR: No matches for text'; - } - } catch (error) { - return 'ERROR: ' + error.message; - } - - pressElement(found); - - return 'OK'; - }; - - /** - * Gets the currently displayed page header. - * - * @return {string} OK: followed by header text if successful, or ERROR: followed by message. - */ - const behatGetHeader = function() { - log('Action - Get header'); - - let titles = Array.from(document.querySelectorAll('.ion-page:not(.ion-page-hidden) > ion-header h1')); - titles = titles.filter(function(title) { - return isElementVisible(title, document.body); - }); - - if (titles.length > 1) { - return 'ERROR: Too many possible titles'; - } 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 {string} field Field name - * @param {string} value New value - * @return {string} OK or ERROR: followed by message - */ - const behatSetField = function(field, value) { - log('Action - Set field ' + field + ' to: ' + value); - - const found = findElementsBasedOnText({ text: field, selector: 'input, textarea, [contenteditable="true"]' })[0]; - if (!found) { - return 'ERROR: No matches for text'; - } - - // Functions to get/set value depending on field type. - let setValue; - let getValue; - switch (found.nodeName) { - case 'INPUT': - case 'TEXTAREA': - setValue = function(text) { - found.value = text; - }; - getValue = function() { - return found.value; - }; - break; - case 'DIV': - setValue = function(text) { - found.innerHTML = text; - }; - getValue = function() { - return found.innerHTML; - }; - break; - } - - // Pretend we have cut and pasted the new text. - let event; - if (getValue() !== '') { - event = new InputEvent('input', {bubbles: true, view: window, cancelable: true, - inputType: 'devareByCut'}); - setTimeout(function() { - setValue(''); - found.dispatchEvent(event); - }, 0); - } - if (value !== '') { - event = new InputEvent('input', {bubbles: true, view: window, cancelable: true, - inputType: 'insertFromPaste', data: value}); - setTimeout(function() { - setValue(value); - found.dispatchEvent(event); - }, 0); - } - - return 'OK'; - }; - - /** - * Get an Angular component instance. - * - * @param {string} selector Element selector - * @param {string} className Constructor class name - * @return {object} Component instance - */ - const behatGetAngularInstance = function(selector, className) { - log('Action - Get Angular instance ' + selector + ', ' + className); - - 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); - }; - - // Make some functions publicly available for Behat to call. - window.behat = { - pressStandard : behatPressStandard, - closePopup : behatClosePopup, - find : behatFind, - scrollTo : behatScrollTo, - loadMoreItems: behatLoadMoreItems, - isSelected : behatIsSelected, - press : behatPress, - setField : behatSetField, - getHeader : behatGetHeader, - getAngularInstance: behatGetAngularInstance, - }; -})(); diff --git a/local-moodleappbehat/tests/behat/behat_app.php b/local-moodleappbehat/tests/behat/behat_app.php index ad12df796..e17c9e1fd 100644 --- a/local-moodleappbehat/tests/behat/behat_app.php +++ b/local-moodleappbehat/tests/behat/behat_app.php @@ -72,6 +72,8 @@ class behat_app extends behat_base { /** @var array Config overrides */ protected $appconfig = ['disableUserTours' => true]; + protected $windowsize = '360x720'; + /** * Register listener. * @@ -147,7 +149,13 @@ class behat_app extends behat_base { public function i_wait_the_app_to_restart() { // Wait window to reload. $this->spin(function() { - return $this->evaluate_script("return !window.behat;"); + $result = $this->evaluate_script("return !window.behat;"); + + if (!$result) { + throw new DriverException('Window is not reloading properly.'); + } + + return true; }); // Prepare testing runtime again. @@ -164,15 +172,14 @@ class behat_app extends behat_base { */ public function i_find_in_the_app(bool $not, string $locator, string $containerName = '') { $locator = $this->parse_element_locator($locator); - $locatorjson = json_encode($locator); if (!empty($containerName)) { preg_match('/^ inside the (.+)$/', $containerName, $matches); $containerName = $matches[1]; } $containerName = json_encode($containerName); - $this->spin(function() use ($not, $locatorjson, $containerName) { - $result = $this->evaluate_script("return window.behat.find($locatorjson, $containerName);"); + $this->spin(function() use ($not, $locator, $containerName) { + $result = $this->evaluate_script("return window.behat.find($locator, $containerName);"); if ($not && $result === 'OK') { throw new DriverException('Error, found an item that should not be found'); @@ -196,10 +203,9 @@ class behat_app extends behat_base { */ public function i_scroll_to_in_the_app(string $locator) { $locator = $this->parse_element_locator($locator); - $locatorjson = json_encode($locator); - $this->spin(function() use ($locatorjson) { - $result = $this->evaluate_script("return window.behat.scrollTo($locatorjson);"); + $this->spin(function() use ($locator) { + $result = $this->evaluate_script("return window.behat.scrollTo($locator);"); if ($result !== 'OK') { throw new DriverException('Error finding item - ' . $result); @@ -224,7 +230,7 @@ class behat_app extends behat_base { $this->spin(function() use ($not) { $result = $this->evaluate_async_script('return window.behat.loadMoreItems();'); - if ($not && $result !== 'ERROR: All items are already loaded') { + if ($not && $result !== 'ERROR: All items are already loaded.') { throw new DriverException('It should not have been possible to load more items'); } @@ -247,7 +253,7 @@ class behat_app extends behat_base { public function i_swipe_in_the_app(string $direction) { $method = 'swipe' . ucwords($direction); - $this->evaluate_script("behat.getAngularInstance('ion-content', 'CoreSwipeNavigationDirective').$method()"); + $this->evaluate_script("window.behat.getAngularInstance('ion-content', 'CoreSwipeNavigationDirective').$method()"); // Wait swipe animation to finish. $this->getSession()->wait(300); @@ -262,10 +268,9 @@ class behat_app extends behat_base { */ public function be_selected_in_the_app(string $locator, bool $not = false) { $locator = $this->parse_element_locator($locator); - $locatorjson = json_encode($locator); - $this->spin(function() use ($locatorjson, $not) { - $result = $this->evaluate_script("return window.behat.isSelected($locatorjson);"); + $this->spin(function() use ($locator, $not) { + $result = $this->evaluate_script("return window.behat.isSelected($locator);"); switch ($result) { case 'YES': @@ -347,8 +352,8 @@ class behat_app extends behat_base { // Enable mobile service. require_once($CFG->dirroot . '/webservice/lib.php'); $webservicemanager = new webservice(); - $service = $webservicemanager->get_external_service_by_shortname( - MOODLE_OFFICIAL_MOBILE_SERVICE, MUST_EXIST); + $service = $webservicemanager->get_external_service_by_shortname(MOODLE_OFFICIAL_MOBILE_SERVICE, MUST_EXIST); + if (!$service->enabled) { $service->enabled = 1; $webservicemanager->update_external_service($service); @@ -474,7 +479,7 @@ class behat_app extends behat_base { // Restart the browser and set its size. $this->getSession()->restart(); - $this->resize_window('360x720', true); + $this->resize_window($this->windowsize, true); if (empty($this->ionicurl)) { $this->ionicurl = $this->start_or_reuse_ionic(); @@ -502,14 +507,13 @@ class behat_app extends behat_base { throw new DriverException('Moodle app not found in browser'); }, false, 60); - // Inject Behat JavaScript runtime. - global $CFG; + try { + // Init Behat JavaScript runtime. + $this->execute_script('window.behatInit();'); + } catch (Exception $error) { + throw new DriverException('Moodle app not running or not running on Automated mode.'); + } - $this->execute_script(" - var script = document.createElement('script'); - script.src = '{$CFG->behat_wwwroot}/local/moodleappbehat/tests/behat/app_behat_runtime.js'; - document.body.append(script); - "); if ($restart) { // Assert initial page. @@ -609,11 +613,11 @@ class behat_app extends behat_base { $this->login($username); } - $mycoursesfound = $this->evaluate_script("return window.behat.find({ text: 'My courses', near: { text: 'Messages' } });"); + $mycoursesfound = $this->evaluate_script("return window.behat.find({ text: 'My courses', selector: 'ion-tab-button'});"); if ($mycoursesfound !== 'OK') { // My courses not present enter from Dashboard. - $this->i_press_in_the_app('"Home" near "Messages"'); + $this->i_press_in_the_app('"Home" "ion-tab-button"'); $this->i_press_in_the_app('"Dashboard"'); $this->i_press_in_the_app('"'.$coursename.'" near "Course overview"'); @@ -622,7 +626,7 @@ class behat_app extends behat_base { return; } - $this->i_press_in_the_app('"My courses" near "Messages"'); + $this->i_press_in_the_app('"My courses" "ion-tab-button"'); $this->i_press_in_the_app('"'.$coursename.'"'); $this->wait_for_pending_js(); @@ -777,10 +781,9 @@ class behat_app extends behat_base { */ public function i_press_in_the_app(string $locator) { $locator = $this->parse_element_locator($locator); - $locatorjson = json_encode($locator); - $this->spin(function() use ($locatorjson) { - $result = $this->evaluate_script("return window.behat.press($locatorjson);"); + $this->spin(function() use ($locator) { + $result = $this->evaluate_script("return window.behat.press($locator);"); if ($result !== 'OK') { throw new DriverException('Error pressing item - ' . $result); @@ -807,27 +810,26 @@ class behat_app extends behat_base { public function i_select_in_the_app(string $selectedtext, string $locator) { $selected = $selectedtext === 'select' ? 'YES' : 'NO'; $locator = $this->parse_element_locator($locator); - $locatorjson = json_encode($locator); - $this->spin(function() use ($selectedtext, $selected, $locatorjson) { + $this->spin(function() use ($selectedtext, $selected, $locator) { // Don't do anything if the item is already in the expected state. - $result = $this->evaluate_script("return window.behat.isSelected($locatorjson);"); + $result = $this->evaluate_script("return window.behat.isSelected($locator);"); if ($result === $selected) { return true; } // Press item. - $result = $this->evaluate_script("return window.behat.press($locatorjson);"); + $result = $this->evaluate_script("return window.behat.press($locator);"); if ($result !== 'OK') { throw new DriverException('Error pressing item - ' . $result); } // Check that it worked as expected. - usleep(1000000); + $this->wait_for_pending_js(); - $result = $this->evaluate_script("return window.behat.isSelected($locatorjson);"); + $result = $this->evaluate_script("return window.behat.isSelected($locator);"); switch ($result) { case 'YES': @@ -1045,7 +1047,7 @@ class behat_app extends behat_base { $this->getSession()->switchToWindow($names[1]); } - $this->execute_script('window.close()'); + $this->execute_script('window.close();'); $this->getSession()->switchToWindow($names[0]); } @@ -1064,10 +1066,14 @@ class behat_app extends behat_base { * Parse an element locator string. * * @param string $text Element locator string. - * @return object + * @return JSON of the locator. */ - public function parse_element_locator(string $text): object { - preg_match('/^"((?:[^"]|\\")*?)"(?: "([^"]*?)")?(?: (near|within) "((?:[^"]|\\")*?)"(?: "([^"]*?)")?)?$/', $text, $matches); + public function parse_element_locator(string $text): string { + preg_match( + '/^"((?:[^"]|\\")*?)"(?: "([^"]*?)")?(?: (near|within) "((?:[^"]|\\")*?)"(?: "([^"]*?)")?)?$/', + $text, + $matches + ); $locator = [ 'text' => str_replace('\\"', '"', $matches[1]), @@ -1081,7 +1087,7 @@ class behat_app extends behat_base { ]; } - return (object) $locator; + return json_encode((object) $locator); } /** @@ -1143,7 +1149,7 @@ class behat_app extends behat_base { } /** - * Evaludate a script that returns a Promise. + * Evaluate a script that returns a Promise. * * @param string $script * @return mixed Resolved promise result. diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 6ae88d2a5..82928a691 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -32,6 +32,7 @@ import { JitCompilerFactory } from '@angular/platform-browser-dynamic'; import { CoreCronDelegate } from '@services/cron'; import { CoreSiteInfoCronHandler } from '@services/handlers/site-info-cron'; import { moodleTransitionAnimation } from '@classes/page-transition'; +import { BehatTestingModule } from '@/testing/behat-testing.module'; // For translate loader. AoT requires an exported function for factories. export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { @@ -59,6 +60,7 @@ export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { AppRoutingModule, CoreModule, AddonsModule, + BehatTestingModule, ], providers: [ { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }, diff --git a/src/testing/behat-testing.module.prod.ts b/src/testing/behat-testing.module.prod.ts new file mode 100644 index 000000000..a95d24c81 --- /dev/null +++ b/src/testing/behat-testing.module.prod.ts @@ -0,0 +1,21 @@ +// (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 { NgModule } from '@angular/core'; + +/** + * Stub used in production to avoid including testing code in production bundles. + */ +@NgModule({}) +export class BehatTestingModule {} diff --git a/src/testing/behat-testing.module.ts b/src/testing/behat-testing.module.ts new file mode 100644 index 000000000..11bfde709 --- /dev/null +++ b/src/testing/behat-testing.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; +import { CoreAppProvider } from '@services/app'; +import { TestsBehatBlockingService } from './services/behat-blocking'; +import { BehatTestsWindow, TestsBehatRuntime } from './services/behat-runtime'; + +function initializeBehatTestsWindow(window: BehatTestsWindow) { + // Make functions publicly available for Behat to call. + window.behatInit = TestsBehatRuntime.init; +} + +@NgModule({ + providers: + CoreAppProvider.isAutomated() + ? [ + { provide: APP_INITIALIZER, multi: true, useValue: () => initializeBehatTestsWindow(window) }, + TestsBehatBlockingService, + ] + : [], +}) +export class BehatTestingModule {} diff --git a/src/testing/services/behat-blocking.ts b/src/testing/services/behat-blocking.ts new file mode 100644 index 000000000..dbffa5a16 --- /dev/null +++ b/src/testing/services/behat-blocking.ts @@ -0,0 +1,241 @@ +// (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 { makeSingleton } from '@singletons'; +import { BehatTestsWindow, TestsBehatRuntime } from './behat-runtime'; + +/** + * Behat block JS manager. + */ +@Injectable({ providedIn: 'root' }) +export class TestsBehatBlockingService { + + protected waitingBlocked = false; + protected recentMutation = false; + protected lastMutation = 0; + protected initialized = false; + + /** + * Listen to mutations and override XML Requests. + */ + init(): void { + if (this.initialized) { + return; + } + + this.initialized = true; + this.listenToMutations(); + this.xmlRequestOverride(); + + const win = window as BehatTestsWindow; + + // Set up the M object - only pending_js is implemented. + win.M = win.M ?? {}; + win.M.util = win.M.util ?? {}; + win.M.util.pending_js = win.M.util.pending_js ?? []; + + TestsBehatRuntime.log('Initialized!'); + } + + /** + * Get pending list on window M object. + */ + protected get pendingList(): string[] { + const win = window as BehatTestsWindow; + + return win.M?.util?.pending_js || []; + } + + /** + * Set pending list on window M object. + */ + protected set pendingList(values: string[]) { + const win = window as BehatTestsWindow; + + if (!win.M?.util?.pending_js) { + return; + } + + win.M.util.pending_js = values; + } + + /** + * Adds a pending key to the array. + * + * @param key Key to add. + */ + block(key: string): void { + // Add a special DELAY entry whenever another entry is added. + if (this.pendingList.length === 0) { + this.pendingList.push('DELAY'); + } + this.pendingList.push(key); + + TestsBehatRuntime.log('PENDING+: ' + this.pendingList); + } + + /** + * Removes a pending key from the array. If this would clear the array, the actual clear only + * takes effect after the queued events are finished. + * + * @param key Key to remove + */ + unblock(key: string): void { + // Remove the key immediately. + this.pendingList = this.pendingList.filter((x) => x !== key); + + TestsBehatRuntime.log('PENDING-: ' + this.pendingList); + + // If the only thing left is DELAY, then remove that as well, later... + if (this.pendingList.length === 1) { + this.runAfterEverything(() => { + // Check there isn't a spinner... + this.checkUIBlocked(); + + // Only remove it if the pending array is STILL empty after all that. + if (this.pendingList.length === 1) { + this.pendingList = []; + TestsBehatRuntime.log('PENDING-: ' + this.pendingList); + } + }); + } + } + + /** + * Adds a pending key to the array, but removes it after some setTimeouts finish. + */ + delay(): void { + this.block('...'); + this.unblock('...'); + } + + /** + * Run after several setTimeouts to ensure queued events are finished. + * + * @param target Function to run. + * @param count Number of times to do setTimeout (leave blank for 10). + */ + protected runAfterEverything(target: () => void, count = 10): void { + setTimeout(() => { + count--; + if (count === 0) { + target(); + + return; + } + this.runAfterEverything(target, count); + }, 0); + } + + /** + * It would be really beautiful if you could detect CSS transitions and animations, that would + * cover almost everything, but sadly there is no way to do this because the transitionstart + * and animationcancel events are not implemented in Chrome, so we cannot detect either of + * these reliably. Instead, we have to look for any DOM changes and do horrible polling. Most + * of the animations are set to 500ms so we allow it to continue from 500ms after any DOM + * change. + */ + protected listenToMutations(): void { + // Set listener using the mutation callback. + const observer = new MutationObserver(() => { + this.lastMutation = Date.now(); + + if (!this.recentMutation) { + this.recentMutation = true; + this.block('dom-mutation'); + + setTimeout(() => { + this.pollRecentMutation(); + }, 500); + } + + // Also update the spinner presence if needed. + this.checkUIBlocked(); + }); + + observer.observe(document, { attributes: true, childList: true, subtree: true }); + } + + /** + * Called from the mutation callback to remove the pending tag after 500ms if nothing else + * gets mutated. + * + * This will be called after 500ms, then every 100ms until there have been no mutation events + * for 500ms. + */ + protected pollRecentMutation(): void { + if (Date.now() - this.lastMutation > 500) { + this.recentMutation = false; + this.unblock('dom-mutation'); + + return; + } + + setTimeout(() => { + this.pollRecentMutation(); + }, 100); + } + + /** + * Checks if a loading spinner is present and visible; if so, adds it to the pending array + * (and if not, removes it). + */ + protected checkUIBlocked(): void { + const blocked = document.querySelector('span.core-loading-spinner, ion-loading, .click-block-active'); + + if (blocked?.offsetParent) { + if (!this.waitingBlocked) { + this.block('blocked'); + this.waitingBlocked = true; + } + } else { + if (this.waitingBlocked) { + this.unblock('blocked'); + this.waitingBlocked = false; + } + } + } + + /** + * Override XMLHttpRequest to mark things pending while there is a request waiting. + */ + protected xmlRequestOverride(): void { + const realOpen = XMLHttpRequest.prototype.open; + let requestIndex = 0; + + XMLHttpRequest.prototype.open = function(...args) { + const index = requestIndex++; + const key = 'httprequest-' + index; + + try { + // Add to the list of pending requests. + TestsBehatBlocking.block(key); + + // Detect when it finishes and remove it from the list. + this.addEventListener('loadend', () => { + TestsBehatBlocking.unblock(key); + }); + + return realOpen.apply(this, args); + } catch (error) { + TestsBehatBlocking.unblock(key); + throw error; + } + }; + } + +} + +export const TestsBehatBlocking = makeSingleton(TestsBehatBlockingService); diff --git a/src/testing/services/behat-dom.ts b/src/testing/services/behat-dom.ts new file mode 100644 index 000000000..eff06ab61 --- /dev/null +++ b/src/testing/services/behat-dom.ts @@ -0,0 +1,482 @@ +// (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 { TestsBehatBlocking } from './behat-blocking'; +import { TestBehatElementLocator } from './behat-runtime'; + +/** + * Behat Dom Utils helper functions. + */ +export class TestsBehatDomUtils { + + /** + * Check if an element is visible. + * + * @param element Element. + * @param container Container. + * @return Whether the element is visible or not. + */ + static 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. + */ + static 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 + * @return Elements containing the given text with exact boolean. + */ + protected static findElementsBasedOnTextWithinWithExact(container: HTMLElement, text: string): ElementsWithExact[] { + const attributesSelector = `[aria-label*="${text}"], a[title*="${text}"], img[alt*="${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 && + (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)); + } + } + } + + 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 static checkElementLabel(element: HTMLElement, text: string): boolean { + return element.title === text || + element.getAttribute('alt') === text || + element.getAttribute('aria-label') === text; + } + + /** + * Finds elements within a given container. + * + * @param container Parent element to search the element within. + * @param text Text to look for. + * @return Elements containing the given text. + */ + protected static findElementsBasedOnTextWithin(container: HTMLElement, text: string): HTMLElement[] { + const elements = this.findElementsBasedOnTextWithinWithExact(container, text); + + // 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 static 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 static 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 static 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 element. + * + * @param containerName Whether to search inside the a container name. + * @return Found top container element. + */ + protected static getCurrentTopContainerElement(containerName: string): HTMLElement | null { + let topContainer: HTMLElement | null = null; + let containers: HTMLElement[] = []; + const nonImplementedSelectors = + 'ion-alert, ion-popover, ion-action-sheet, ion-modal, core-user-tours-user-tour.is-active, page-core-mainmenu, ion-app'; + + switch (containerName) { + case 'html': + containers = Array.from(document.querySelectorAll('html')); + break; + case 'toast': + containers = Array.from(document.querySelectorAll('ion-app ion-toast.hydrated')); + containers = containers.map(container => container?.shadowRoot?.querySelector('.toast-container') || container); + break; + case 'alert': + containers = Array.from(document.querySelectorAll('ion-app ion-alert.hydrated')); + break; + case 'action-sheet': + containers = Array.from(document.querySelectorAll('ion-app ion-action-sheet.hydrated')); + break; + case 'modal': + containers = Array.from(document.querySelectorAll('ion-app ion-modal.hydrated')); + break; + case 'popover': + containers = Array.from(document.querySelectorAll('ion-app ion-popover.hydrated')); + break; + case 'user-tour': + containers = Array.from(document.querySelectorAll('core-user-tours-user-tour.is-active')); + break; + default: + // Other containerName or not implemented. + containers = Array.from(document.querySelectorAll(nonImplementedSelectors)); + } + + if (containers.length > 0) { + // Get the one with more zIndex. + topContainer = + containers.reduce((a, b) => getComputedStyle(a).zIndex > getComputedStyle(b).zIndex ? a : b, containers[0]); + } + + if (!topContainer) { + return null; + } + + if (containerName == 'page' || containerName == 'split-view content') { + // Find non hidden pages inside the container. + let pageContainers = Array.from(topContainer.querySelectorAll('.ion-page:not(.ion-page-hidden)')); + pageContainers = pageContainers.filter((page) => !page.closest('.ion-page.ion-page-hidden')); + + if (pageContainers.length > 0) { + // Get the more general one to avoid failing. + topContainer = pageContainers[0]; + } + + if (containerName == 'split-view content') { + topContainer = topContainer.querySelector('core-split-view ion-router-outlet'); + } + } + + return topContainer; + }; + + /** + * 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. + * @return First found element. + */ + static findElementBasedOnText(locator: TestBehatElementLocator, containerName = ''): HTMLElement { + return this.findElementsBasedOnText(locator, containerName)[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. + * @return Found elements + */ + protected static findElementsBasedOnText(locator: TestBehatElementLocator, containerName = ''): HTMLElement[] { + let topContainer = this.getCurrentTopContainerElement(containerName); + + let container = topContainer; + + if (locator.within) { + const withinElements = this.findElementsBasedOnText(locator.within); + + 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'); + } + + topContainer = container = withinElementsAncestors[0]; + } else { + topContainer = container = withinElements[0]; + } + } + + if (topContainer && locator.near) { + const nearElements = this.findElementsBasedOnText(locator.near); + + 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'); + } + + container = this.getParentElement(nearElementsAncestors[0]); + } else { + container = this.getParentElement(nearElements[0]); + } + } + + do { + if (!container) { + break; + } + + const elements = this.findElementsBasedOnTextWithin(container, locator.text); + + 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. + * @param callback Callback called when the element is visible, passing bounding box parameter. + */ + protected static ensureElementVisible(element: HTMLElement, callback: (rect: DOMRect) => void): void { + const initialRect = element.getBoundingClientRect(); + + element.scrollIntoView(false); + + requestAnimationFrame(() => { + const rect = element.getBoundingClientRect(); + + if (initialRect.y !== rect.y) { + setTimeout(() => { + callback(rect); + }, 300); + + TestsBehatBlocking.delay(); + + return; + } + + callback(rect); + }); + + TestsBehatBlocking.delay(); + }; + + /** + * Press an element. + * + * @param element Element to press. + */ + static pressElement(element: HTMLElement): void { + this.ensureElementVisible(element, (rect) => { + // Simulate a mouse click on the button. + const eventOptions = { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + bubbles: true, + view: window, + cancelable: true, + }; + + // 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; + } + + // 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(); + }, 300); + + // Mark busy until the button click finishes processing. + TestsBehatBlocking.delay(); + }); + } + +} + +type ElementsWithExact = { + element: HTMLElement; + exact: boolean; +}; diff --git a/src/testing/services/behat-runtime.ts b/src/testing/services/behat-runtime.ts new file mode 100644 index 000000000..a13bb61a1 --- /dev/null +++ b/src/testing/services/behat-runtime.ts @@ -0,0 +1,391 @@ +// (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 { TestsBehatDomUtils } from './behat-dom'; +import { TestsBehatBlocking } from './behat-blocking'; + +/** + * Behat runtime servive with public API. + */ +export class TestsBehatRuntime { + + static init(): void { + TestsBehatBlocking.init(); + + (window as BehatTestsWindow).behat = { + closePopup: TestsBehatRuntime.closePopup, + find: TestsBehatRuntime.find, + getAngularInstance: TestsBehatRuntime.getAngularInstance, + getHeader: TestsBehatRuntime.getHeader, + isSelected: TestsBehatRuntime.isSelected, + loadMoreItems: TestsBehatRuntime.loadMoreItems, + log: TestsBehatRuntime.log, + press: TestsBehatRuntime.press, + pressStandard: TestsBehatRuntime.pressStandard, + scrollTo: TestsBehatRuntime.scrollTo, + setField: TestsBehatRuntime.setField, + }; + } + + /** + * 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 + */ + static pressStandard(button: string): string { + this.log('Action - Click standard button: ' + button); + + // Find button + let foundButton: HTMLElement | undefined; + + switch (button) { + case 'back': + foundButton = TestsBehatDomUtils.findElementBasedOnText({ text: 'Back' }); + break; + case 'main menu': // Deprecated name. + case 'more menu': + foundButton = TestsBehatDomUtils.findElementBasedOnText({ + text: 'More', + selector: 'ion-tab-button', + }); + break; + case 'user menu' : + foundButton = TestsBehatDomUtils.findElementBasedOnText({ text: 'User account' }); + break; + case 'page menu': + foundButton = TestsBehatDomUtils.findElementBasedOnText({ text: 'Display options' }); + break; + default: + return 'ERROR: Unsupported standard button type'; + } + + if (!foundButton) { + return `ERROR: Button '${button}' not found`; + } + + // Click button + TestsBehatDomUtils.pressElement(foundButton); + + return 'OK'; + } + + /** + * When there is a popup, clicks on the backdrop. + * + * @return OK if successful, or ERROR: followed by message + */ + static 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'; + } + const backdrop = backdrops[0]; + backdrop.click(); + + // Mark busy until the click finishes processing. + TestsBehatBlocking.delay(); + + return 'OK'; + } + + /** + * 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. + * @return OK if successful, or ERROR: followed by message + */ + static find(locator: TestBehatElementLocator, containerName: string): string { + this.log('Action - Find', { locator, containerName }); + + try { + const element = TestsBehatDomUtils.findElementBasedOnText(locator, containerName); + + if (!element) { + return 'ERROR: No element matches locator to find.'; + } + + this.log('Action - Found', { locator, containerName, element }); + + 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 + */ + static scrollTo(locator: TestBehatElementLocator): string { + this.log('Action - scrollTo', { locator }); + + try { + let element = TestsBehatDomUtils.findElementBasedOnText(locator); + + 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 + */ + static 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 + */ + static isSelected(locator: TestBehatElementLocator): string { + this.log('Action - Is Selected', locator); + + try { + const element = TestsBehatDomUtils.findElementBasedOnText(locator); + + return TestsBehatDomUtils.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 + */ + static press(locator: TestBehatElementLocator): string { + this.log('Action - Press', locator); + + try { + const found = TestsBehatDomUtils.findElementBasedOnText(locator); + + if (!found) { + return 'ERROR: No element matches locator to press.'; + } + + TestsBehatDomUtils.pressElement(found); + + 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. + */ + static 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) => TestsBehatDomUtils.isElementVisible(title, document.body)); + + if (titles.length > 1) { + return 'ERROR: Too many possible titles.'; + } 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 + */ + static setField(field: string, value: string): string { + this.log('Action - Set field ' + field + ' to: ' + value); + + const found: HTMLElement | HTMLInputElement | HTMLTextAreaElement =TestsBehatDomUtils.findElementBasedOnText( + { text: field, selector: 'input, textarea, [contenteditable="true"]' }, + ); + + if (!found) { + return 'ERROR: No element matches field to set.'; + } + + // Functions to get/set value depending on field type. + let setValue = (text: string) => { + found.innerHTML = text; + }; + let getValue = () => found.innerHTML; + + if (found instanceof HTMLInputElement || found instanceof HTMLTextAreaElement) { + setValue = (text: string) => { + found.value = text; + }; + getValue = () => found.value; + } + + // 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', + }); + + setTimeout(() => { + setValue(''); + found.dispatchEvent(event); + }, 0); + } + + if (value !== '') { + event = new InputEvent('input', { + bubbles: true, + view: window, + cancelable: true, + inputType: 'insertFromPaste', + data: value, + }); + + setTimeout(() => { + setValue(value); + found.dispatchEvent(event); + }, 0); + } + + return 'OK'; + } + + /** + * Get an Angular component instance. + * + * @param selector Element selector + * @param className Constructor class name + * @return Component instance + */ + static getAngularInstance(selector: string, className: string): unknown { + 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. + */ + static 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 type BehatTestsWindow = Window & { + M?: { // eslint-disable-line @typescript-eslint/naming-convention + util?: { + pending_js?: string[]; // eslint-disable-line @typescript-eslint/naming-convention + }; + }; + behatInit?: () => void; + behat?: unknown; +}; + +export type TestBehatElementLocator = { + text: string; + within?: TestBehatElementLocator; + near?: TestBehatElementLocator; + selector?: string; +};