forked from CIT/Vmeda.Online
		
	MOBILE-4061 behat: Include Behat runtime in the app
This commit is contained in:
		
							parent
							
								
									8abfed60a6
								
							
						
					
					
						commit
						064ea15f8b
					
				@ -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'));
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
    };
 | 
			
		||||
})();
 | 
			
		||||
@ -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.
 | 
			
		||||
 | 
			
		||||
@ -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 },
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										21
									
								
								src/testing/behat-testing.module.prod.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/testing/behat-testing.module.prod.ts
									
									
									
									
									
										Normal file
									
								
							@ -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 {}
 | 
			
		||||
							
								
								
									
										34
									
								
								src/testing/behat-testing.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/testing/behat-testing.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -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 {}
 | 
			
		||||
							
								
								
									
										241
									
								
								src/testing/services/behat-blocking.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								src/testing/services/behat-blocking.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<HTMLElement>('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);
 | 
			
		||||
							
								
								
									
										482
									
								
								src/testing/services/behat-dom.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										482
									
								
								src/testing/services/behat-dom.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<HTMLElement>(attributesSelector))
 | 
			
		||||
            .filter((element => this.isElementVisible(element, container)))
 | 
			
		||||
            .map((element) => {
 | 
			
		||||
                const exact = this.checkElementLabel(element, text);
 | 
			
		||||
 | 
			
		||||
                return { element, exact };
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
        const treeWalker = document.createTreeWalker(
 | 
			
		||||
            container,
 | 
			
		||||
            NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_DOCUMENT_FRAGMENT | NodeFilter.SHOW_TEXT,  // eslint-disable-line no-bitwise
 | 
			
		||||
            {
 | 
			
		||||
                acceptNode: node => {
 | 
			
		||||
                    if (node instanceof HTMLStyleElement ||
 | 
			
		||||
                        node instanceof HTMLLinkElement ||
 | 
			
		||||
                        node instanceof HTMLScriptElement) {
 | 
			
		||||
                        return NodeFilter.FILTER_REJECT;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    if (node instanceof HTMLElement &&
 | 
			
		||||
                        (node.getAttribute('aria-hidden') === 'true' || getComputedStyle(node).display === 'none')) {
 | 
			
		||||
                        return NodeFilter.FILTER_REJECT;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    return NodeFilter.FILTER_ACCEPT;
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        let currentNode: Node | null = null;
 | 
			
		||||
        // eslint-disable-next-line no-cond-assign
 | 
			
		||||
        while (currentNode = treeWalker.nextNode()) {
 | 
			
		||||
            if (currentNode instanceof Text) {
 | 
			
		||||
                if (currentNode.textContent?.includes(text) && currentNode.parentElement) {
 | 
			
		||||
                    elements.push({
 | 
			
		||||
                        element: currentNode.parentElement,
 | 
			
		||||
                        exact: currentNode.textContent.trim() === text,
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (currentNode instanceof HTMLElement) {
 | 
			
		||||
                const labelledBy = currentNode.getAttribute('aria-labelledby');
 | 
			
		||||
                const labelElement = labelledBy && container.querySelector<HTMLElement>(`#${labelledBy}`);
 | 
			
		||||
                if (labelElement && labelElement.innerText && labelElement.innerText.includes(text)) {
 | 
			
		||||
                    elements.push({
 | 
			
		||||
                        element: currentNode,
 | 
			
		||||
                        exact: labelElement.innerText.trim() == text,
 | 
			
		||||
                    });
 | 
			
		||||
 | 
			
		||||
                    continue;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (currentNode instanceof Element && currentNode.shadowRoot) {
 | 
			
		||||
                for (const childNode of Array.from(currentNode.shadowRoot.childNodes)) {
 | 
			
		||||
                    if (!(childNode instanceof HTMLElement) || (
 | 
			
		||||
                        childNode instanceof HTMLStyleElement ||
 | 
			
		||||
                        childNode instanceof HTMLLinkElement ||
 | 
			
		||||
                        childNode instanceof HTMLScriptElement)) {
 | 
			
		||||
                        continue;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    if (childNode.matches(attributesSelector)) {
 | 
			
		||||
                        elements.push({
 | 
			
		||||
                            element: childNode,
 | 
			
		||||
                            exact: this.checkElementLabel(childNode, text),
 | 
			
		||||
                        });
 | 
			
		||||
 | 
			
		||||
                        continue;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    elements.push(...this.findElementsBasedOnTextWithinWithExact(childNode, text));
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        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<HTMLElement>('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<HTMLElement>(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<HTMLElement>('.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<HTMLElement>('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;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										391
									
								
								src/testing/services/behat-runtime.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										391
									
								
								src/testing/services/behat-runtime.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<string> {
 | 
			
		||||
        this.log('Action - loadMoreItems');
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            const infiniteLoading = Array
 | 
			
		||||
                .from(document.querySelectorAll<HTMLElement>('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<HTMLElement>('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<HTMLElement>('.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<any>(`.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;
 | 
			
		||||
};
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user