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