MOBILE-4061 behat: Include Behat runtime in the app
parent
8abfed60a6
commit
064ea15f8b
|
@ -71,5 +71,5 @@ gulp.task('watch', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
gulp.task('watch-behat', () => {
|
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 */
|
/** @var array Config overrides */
|
||||||
protected $appconfig = ['disableUserTours' => true];
|
protected $appconfig = ['disableUserTours' => true];
|
||||||
|
|
||||||
|
protected $windowsize = '360x720';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register listener.
|
* Register listener.
|
||||||
*
|
*
|
||||||
|
@ -147,7 +149,13 @@ class behat_app extends behat_base {
|
||||||
public function i_wait_the_app_to_restart() {
|
public function i_wait_the_app_to_restart() {
|
||||||
// Wait window to reload.
|
// Wait window to reload.
|
||||||
$this->spin(function() {
|
$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.
|
// 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 = '') {
|
public function i_find_in_the_app(bool $not, string $locator, string $containerName = '') {
|
||||||
$locator = $this->parse_element_locator($locator);
|
$locator = $this->parse_element_locator($locator);
|
||||||
$locatorjson = json_encode($locator);
|
|
||||||
if (!empty($containerName)) {
|
if (!empty($containerName)) {
|
||||||
preg_match('/^ inside the (.+)$/', $containerName, $matches);
|
preg_match('/^ inside the (.+)$/', $containerName, $matches);
|
||||||
$containerName = $matches[1];
|
$containerName = $matches[1];
|
||||||
}
|
}
|
||||||
$containerName = json_encode($containerName);
|
$containerName = json_encode($containerName);
|
||||||
|
|
||||||
$this->spin(function() use ($not, $locatorjson, $containerName) {
|
$this->spin(function() use ($not, $locator, $containerName) {
|
||||||
$result = $this->evaluate_script("return window.behat.find($locatorjson, $containerName);");
|
$result = $this->evaluate_script("return window.behat.find($locator, $containerName);");
|
||||||
|
|
||||||
if ($not && $result === 'OK') {
|
if ($not && $result === 'OK') {
|
||||||
throw new DriverException('Error, found an item that should not be found');
|
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) {
|
public function i_scroll_to_in_the_app(string $locator) {
|
||||||
$locator = $this->parse_element_locator($locator);
|
$locator = $this->parse_element_locator($locator);
|
||||||
$locatorjson = json_encode($locator);
|
|
||||||
|
|
||||||
$this->spin(function() use ($locatorjson) {
|
$this->spin(function() use ($locator) {
|
||||||
$result = $this->evaluate_script("return window.behat.scrollTo($locatorjson);");
|
$result = $this->evaluate_script("return window.behat.scrollTo($locator);");
|
||||||
|
|
||||||
if ($result !== 'OK') {
|
if ($result !== 'OK') {
|
||||||
throw new DriverException('Error finding item - ' . $result);
|
throw new DriverException('Error finding item - ' . $result);
|
||||||
|
@ -224,7 +230,7 @@ class behat_app extends behat_base {
|
||||||
$this->spin(function() use ($not) {
|
$this->spin(function() use ($not) {
|
||||||
$result = $this->evaluate_async_script('return window.behat.loadMoreItems();');
|
$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');
|
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) {
|
public function i_swipe_in_the_app(string $direction) {
|
||||||
$method = 'swipe' . ucwords($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.
|
// Wait swipe animation to finish.
|
||||||
$this->getSession()->wait(300);
|
$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) {
|
public function be_selected_in_the_app(string $locator, bool $not = false) {
|
||||||
$locator = $this->parse_element_locator($locator);
|
$locator = $this->parse_element_locator($locator);
|
||||||
$locatorjson = json_encode($locator);
|
|
||||||
|
|
||||||
$this->spin(function() use ($locatorjson, $not) {
|
$this->spin(function() use ($locator, $not) {
|
||||||
$result = $this->evaluate_script("return window.behat.isSelected($locatorjson);");
|
$result = $this->evaluate_script("return window.behat.isSelected($locator);");
|
||||||
|
|
||||||
switch ($result) {
|
switch ($result) {
|
||||||
case 'YES':
|
case 'YES':
|
||||||
|
@ -347,8 +352,8 @@ class behat_app extends behat_base {
|
||||||
// Enable mobile service.
|
// Enable mobile service.
|
||||||
require_once($CFG->dirroot . '/webservice/lib.php');
|
require_once($CFG->dirroot . '/webservice/lib.php');
|
||||||
$webservicemanager = new webservice();
|
$webservicemanager = new webservice();
|
||||||
$service = $webservicemanager->get_external_service_by_shortname(
|
$service = $webservicemanager->get_external_service_by_shortname(MOODLE_OFFICIAL_MOBILE_SERVICE, MUST_EXIST);
|
||||||
MOODLE_OFFICIAL_MOBILE_SERVICE, MUST_EXIST);
|
|
||||||
if (!$service->enabled) {
|
if (!$service->enabled) {
|
||||||
$service->enabled = 1;
|
$service->enabled = 1;
|
||||||
$webservicemanager->update_external_service($service);
|
$webservicemanager->update_external_service($service);
|
||||||
|
@ -474,7 +479,7 @@ class behat_app extends behat_base {
|
||||||
|
|
||||||
// Restart the browser and set its size.
|
// Restart the browser and set its size.
|
||||||
$this->getSession()->restart();
|
$this->getSession()->restart();
|
||||||
$this->resize_window('360x720', true);
|
$this->resize_window($this->windowsize, true);
|
||||||
|
|
||||||
if (empty($this->ionicurl)) {
|
if (empty($this->ionicurl)) {
|
||||||
$this->ionicurl = $this->start_or_reuse_ionic();
|
$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');
|
throw new DriverException('Moodle app not found in browser');
|
||||||
}, false, 60);
|
}, false, 60);
|
||||||
|
|
||||||
// Inject Behat JavaScript runtime.
|
try {
|
||||||
global $CFG;
|
// 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) {
|
if ($restart) {
|
||||||
// Assert initial page.
|
// Assert initial page.
|
||||||
|
@ -609,11 +613,11 @@ class behat_app extends behat_base {
|
||||||
$this->login($username);
|
$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') {
|
if ($mycoursesfound !== 'OK') {
|
||||||
// My courses not present enter from Dashboard.
|
// 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('"Dashboard"');
|
||||||
$this->i_press_in_the_app('"'.$coursename.'" near "Course overview"');
|
$this->i_press_in_the_app('"'.$coursename.'" near "Course overview"');
|
||||||
|
|
||||||
|
@ -622,7 +626,7 @@ class behat_app extends behat_base {
|
||||||
return;
|
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->i_press_in_the_app('"'.$coursename.'"');
|
||||||
|
|
||||||
$this->wait_for_pending_js();
|
$this->wait_for_pending_js();
|
||||||
|
@ -777,10 +781,9 @@ class behat_app extends behat_base {
|
||||||
*/
|
*/
|
||||||
public function i_press_in_the_app(string $locator) {
|
public function i_press_in_the_app(string $locator) {
|
||||||
$locator = $this->parse_element_locator($locator);
|
$locator = $this->parse_element_locator($locator);
|
||||||
$locatorjson = json_encode($locator);
|
|
||||||
|
|
||||||
$this->spin(function() use ($locatorjson) {
|
$this->spin(function() use ($locator) {
|
||||||
$result = $this->evaluate_script("return window.behat.press($locatorjson);");
|
$result = $this->evaluate_script("return window.behat.press($locator);");
|
||||||
|
|
||||||
if ($result !== 'OK') {
|
if ($result !== 'OK') {
|
||||||
throw new DriverException('Error pressing item - ' . $result);
|
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) {
|
public function i_select_in_the_app(string $selectedtext, string $locator) {
|
||||||
$selected = $selectedtext === 'select' ? 'YES' : 'NO';
|
$selected = $selectedtext === 'select' ? 'YES' : 'NO';
|
||||||
$locator = $this->parse_element_locator($locator);
|
$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.
|
// 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) {
|
if ($result === $selected) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Press item.
|
// Press item.
|
||||||
$result = $this->evaluate_script("return window.behat.press($locatorjson);");
|
$result = $this->evaluate_script("return window.behat.press($locator);");
|
||||||
|
|
||||||
if ($result !== 'OK') {
|
if ($result !== 'OK') {
|
||||||
throw new DriverException('Error pressing item - ' . $result);
|
throw new DriverException('Error pressing item - ' . $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that it worked as expected.
|
// 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) {
|
switch ($result) {
|
||||||
case 'YES':
|
case 'YES':
|
||||||
|
@ -1045,7 +1047,7 @@ class behat_app extends behat_base {
|
||||||
$this->getSession()->switchToWindow($names[1]);
|
$this->getSession()->switchToWindow($names[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->execute_script('window.close()');
|
$this->execute_script('window.close();');
|
||||||
$this->getSession()->switchToWindow($names[0]);
|
$this->getSession()->switchToWindow($names[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1064,10 +1066,14 @@ class behat_app extends behat_base {
|
||||||
* Parse an element locator string.
|
* Parse an element locator string.
|
||||||
*
|
*
|
||||||
* @param string $text Element locator string.
|
* @param string $text Element locator string.
|
||||||
* @return object
|
* @return JSON of the locator.
|
||||||
*/
|
*/
|
||||||
public function parse_element_locator(string $text): object {
|
public function parse_element_locator(string $text): string {
|
||||||
preg_match('/^"((?:[^"]|\\")*?)"(?: "([^"]*?)")?(?: (near|within) "((?:[^"]|\\")*?)"(?: "([^"]*?)")?)?$/', $text, $matches);
|
preg_match(
|
||||||
|
'/^"((?:[^"]|\\")*?)"(?: "([^"]*?)")?(?: (near|within) "((?:[^"]|\\")*?)"(?: "([^"]*?)")?)?$/',
|
||||||
|
$text,
|
||||||
|
$matches
|
||||||
|
);
|
||||||
|
|
||||||
$locator = [
|
$locator = [
|
||||||
'text' => str_replace('\\"', '"', $matches[1]),
|
'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
|
* @param string $script
|
||||||
* @return mixed Resolved promise result.
|
* @return mixed Resolved promise result.
|
||||||
|
|
|
@ -32,6 +32,7 @@ import { JitCompilerFactory } from '@angular/platform-browser-dynamic';
|
||||||
import { CoreCronDelegate } from '@services/cron';
|
import { CoreCronDelegate } from '@services/cron';
|
||||||
import { CoreSiteInfoCronHandler } from '@services/handlers/site-info-cron';
|
import { CoreSiteInfoCronHandler } from '@services/handlers/site-info-cron';
|
||||||
import { moodleTransitionAnimation } from '@classes/page-transition';
|
import { moodleTransitionAnimation } from '@classes/page-transition';
|
||||||
|
import { BehatTestingModule } from '@/testing/behat-testing.module';
|
||||||
|
|
||||||
// For translate loader. AoT requires an exported function for factories.
|
// For translate loader. AoT requires an exported function for factories.
|
||||||
export function createTranslateLoader(http: HttpClient): TranslateHttpLoader {
|
export function createTranslateLoader(http: HttpClient): TranslateHttpLoader {
|
||||||
|
@ -59,6 +60,7 @@ export function createTranslateLoader(http: HttpClient): TranslateHttpLoader {
|
||||||
AppRoutingModule,
|
AppRoutingModule,
|
||||||
CoreModule,
|
CoreModule,
|
||||||
AddonsModule,
|
AddonsModule,
|
||||||
|
BehatTestingModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
||||||
|
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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);
|
|
@ -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;
|
||||||
|
};
|
|
@ -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…
Reference in New Issue