MOBILE-4061 behat: Include Behat runtime in the app

main
Pau Ferrer Ocaña 2022-05-12 15:47:23 +02:00
parent 8abfed60a6
commit 064ea15f8b
9 changed files with 1218 additions and 949 deletions

View File

@ -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'));
});

View File

@ -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,
};
})();

View File

@ -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.

View File

@ -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 },

View File

@ -0,0 +1,21 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
/**
* Stub used in production to avoid including testing code in production bundles.
*/
@NgModule({})
export class BehatTestingModule {}

View File

@ -0,0 +1,34 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreAppProvider } from '@services/app';
import { TestsBehatBlockingService } from './services/behat-blocking';
import { BehatTestsWindow, TestsBehatRuntime } from './services/behat-runtime';
function initializeBehatTestsWindow(window: BehatTestsWindow) {
// Make functions publicly available for Behat to call.
window.behatInit = TestsBehatRuntime.init;
}
@NgModule({
providers:
CoreAppProvider.isAutomated()
? [
{ provide: APP_INITIALIZER, multi: true, useValue: () => initializeBehatTestsWindow(window) },
TestsBehatBlockingService,
]
: [],
})
export class BehatTestingModule {}

View File

@ -0,0 +1,241 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { makeSingleton } from '@singletons';
import { BehatTestsWindow, TestsBehatRuntime } from './behat-runtime';
/**
* Behat block JS manager.
*/
@Injectable({ providedIn: 'root' })
export class TestsBehatBlockingService {
protected waitingBlocked = false;
protected recentMutation = false;
protected lastMutation = 0;
protected initialized = false;
/**
* Listen to mutations and override XML Requests.
*/
init(): void {
if (this.initialized) {
return;
}
this.initialized = true;
this.listenToMutations();
this.xmlRequestOverride();
const win = window as BehatTestsWindow;
// Set up the M object - only pending_js is implemented.
win.M = win.M ?? {};
win.M.util = win.M.util ?? {};
win.M.util.pending_js = win.M.util.pending_js ?? [];
TestsBehatRuntime.log('Initialized!');
}
/**
* Get pending list on window M object.
*/
protected get pendingList(): string[] {
const win = window as BehatTestsWindow;
return win.M?.util?.pending_js || [];
}
/**
* Set pending list on window M object.
*/
protected set pendingList(values: string[]) {
const win = window as BehatTestsWindow;
if (!win.M?.util?.pending_js) {
return;
}
win.M.util.pending_js = values;
}
/**
* Adds a pending key to the array.
*
* @param key Key to add.
*/
block(key: string): void {
// Add a special DELAY entry whenever another entry is added.
if (this.pendingList.length === 0) {
this.pendingList.push('DELAY');
}
this.pendingList.push(key);
TestsBehatRuntime.log('PENDING+: ' + this.pendingList);
}
/**
* Removes a pending key from the array. If this would clear the array, the actual clear only
* takes effect after the queued events are finished.
*
* @param key Key to remove
*/
unblock(key: string): void {
// Remove the key immediately.
this.pendingList = this.pendingList.filter((x) => x !== key);
TestsBehatRuntime.log('PENDING-: ' + this.pendingList);
// If the only thing left is DELAY, then remove that as well, later...
if (this.pendingList.length === 1) {
this.runAfterEverything(() => {
// Check there isn't a spinner...
this.checkUIBlocked();
// Only remove it if the pending array is STILL empty after all that.
if (this.pendingList.length === 1) {
this.pendingList = [];
TestsBehatRuntime.log('PENDING-: ' + this.pendingList);
}
});
}
}
/**
* Adds a pending key to the array, but removes it after some setTimeouts finish.
*/
delay(): void {
this.block('...');
this.unblock('...');
}
/**
* Run after several setTimeouts to ensure queued events are finished.
*
* @param target Function to run.
* @param count Number of times to do setTimeout (leave blank for 10).
*/
protected runAfterEverything(target: () => void, count = 10): void {
setTimeout(() => {
count--;
if (count === 0) {
target();
return;
}
this.runAfterEverything(target, count);
}, 0);
}
/**
* It would be really beautiful if you could detect CSS transitions and animations, that would
* cover almost everything, but sadly there is no way to do this because the transitionstart
* and animationcancel events are not implemented in Chrome, so we cannot detect either of
* these reliably. Instead, we have to look for any DOM changes and do horrible polling. Most
* of the animations are set to 500ms so we allow it to continue from 500ms after any DOM
* change.
*/
protected listenToMutations(): void {
// Set listener using the mutation callback.
const observer = new MutationObserver(() => {
this.lastMutation = Date.now();
if (!this.recentMutation) {
this.recentMutation = true;
this.block('dom-mutation');
setTimeout(() => {
this.pollRecentMutation();
}, 500);
}
// Also update the spinner presence if needed.
this.checkUIBlocked();
});
observer.observe(document, { attributes: true, childList: true, subtree: true });
}
/**
* Called from the mutation callback to remove the pending tag after 500ms if nothing else
* gets mutated.
*
* This will be called after 500ms, then every 100ms until there have been no mutation events
* for 500ms.
*/
protected pollRecentMutation(): void {
if (Date.now() - this.lastMutation > 500) {
this.recentMutation = false;
this.unblock('dom-mutation');
return;
}
setTimeout(() => {
this.pollRecentMutation();
}, 100);
}
/**
* Checks if a loading spinner is present and visible; if so, adds it to the pending array
* (and if not, removes it).
*/
protected checkUIBlocked(): void {
const blocked = document.querySelector<HTMLElement>('span.core-loading-spinner, ion-loading, .click-block-active');
if (blocked?.offsetParent) {
if (!this.waitingBlocked) {
this.block('blocked');
this.waitingBlocked = true;
}
} else {
if (this.waitingBlocked) {
this.unblock('blocked');
this.waitingBlocked = false;
}
}
}
/**
* Override XMLHttpRequest to mark things pending while there is a request waiting.
*/
protected xmlRequestOverride(): void {
const realOpen = XMLHttpRequest.prototype.open;
let requestIndex = 0;
XMLHttpRequest.prototype.open = function(...args) {
const index = requestIndex++;
const key = 'httprequest-' + index;
try {
// Add to the list of pending requests.
TestsBehatBlocking.block(key);
// Detect when it finishes and remove it from the list.
this.addEventListener('loadend', () => {
TestsBehatBlocking.unblock(key);
});
return realOpen.apply(this, args);
} catch (error) {
TestsBehatBlocking.unblock(key);
throw error;
}
};
}
}
export const TestsBehatBlocking = makeSingleton(TestsBehatBlockingService);

View File

@ -0,0 +1,482 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { TestsBehatBlocking } from './behat-blocking';
import { TestBehatElementLocator } from './behat-runtime';
/**
* Behat Dom Utils helper functions.
*/
export class TestsBehatDomUtils {
/**
* Check if an element is visible.
*
* @param element Element.
* @param container Container.
* @return Whether the element is visible or not.
*/
static isElementVisible(element: HTMLElement, container: HTMLElement): boolean {
if (element.getAttribute('aria-hidden') === 'true' || getComputedStyle(element).display === 'none') {
return false;
}
const parentElement = this.getParentElement(element);
if (parentElement === container) {
return true;
}
if (!parentElement) {
return false;
}
return this.isElementVisible(parentElement, container);
}
/**
* Check if an element is selected.
*
* @param element Element.
* @param container Container.
* @return Whether the element is selected or not.
*/
static isElementSelected(element: HTMLElement, container: HTMLElement): boolean {
const ariaCurrent = element.getAttribute('aria-current');
if (
(ariaCurrent && ariaCurrent !== 'false') ||
(element.getAttribute('aria-selected') === 'true') ||
(element.getAttribute('aria-checked') === 'true')
) {
return true;
}
const parentElement = this.getParentElement(element);
if (!parentElement || parentElement === container) {
return false;
}
return this.isElementSelected(parentElement, container);
};
/**
* Finds elements within a given container with exact info.
*
* @param container Parent element to search the element within
* @param text Text to look for
* @return Elements containing the given text with exact boolean.
*/
protected static findElementsBasedOnTextWithinWithExact(container: HTMLElement, text: string): ElementsWithExact[] {
const attributesSelector = `[aria-label*="${text}"], a[title*="${text}"], img[alt*="${text}"]`;
const elements = Array.from(container.querySelectorAll<HTMLElement>(attributesSelector))
.filter((element => this.isElementVisible(element, container)))
.map((element) => {
const exact = this.checkElementLabel(element, text);
return { element, exact };
});
const treeWalker = document.createTreeWalker(
container,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_DOCUMENT_FRAGMENT | NodeFilter.SHOW_TEXT, // eslint-disable-line no-bitwise
{
acceptNode: node => {
if (node instanceof HTMLStyleElement ||
node instanceof HTMLLinkElement ||
node instanceof HTMLScriptElement) {
return NodeFilter.FILTER_REJECT;
}
if (node instanceof HTMLElement &&
(node.getAttribute('aria-hidden') === 'true' || getComputedStyle(node).display === 'none')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
},
);
let currentNode: Node | null = null;
// eslint-disable-next-line no-cond-assign
while (currentNode = treeWalker.nextNode()) {
if (currentNode instanceof Text) {
if (currentNode.textContent?.includes(text) && currentNode.parentElement) {
elements.push({
element: currentNode.parentElement,
exact: currentNode.textContent.trim() === text,
});
}
continue;
}
if (currentNode instanceof HTMLElement) {
const labelledBy = currentNode.getAttribute('aria-labelledby');
const labelElement = labelledBy && container.querySelector<HTMLElement>(`#${labelledBy}`);
if (labelElement && labelElement.innerText && labelElement.innerText.includes(text)) {
elements.push({
element: currentNode,
exact: labelElement.innerText.trim() == text,
});
continue;
}
}
if (currentNode instanceof Element && currentNode.shadowRoot) {
for (const childNode of Array.from(currentNode.shadowRoot.childNodes)) {
if (!(childNode instanceof HTMLElement) || (
childNode instanceof HTMLStyleElement ||
childNode instanceof HTMLLinkElement ||
childNode instanceof HTMLScriptElement)) {
continue;
}
if (childNode.matches(attributesSelector)) {
elements.push({
element: childNode,
exact: this.checkElementLabel(childNode, text),
});
continue;
}
elements.push(...this.findElementsBasedOnTextWithinWithExact(childNode, text));
}
}
}
return elements;
};
/**
* Checks an element has exactly the same label (title, alt or aria-label).
*
* @param element Element to check.
* @param text Text to check.
* @return If text matches any of the label attributes.
*/
protected static checkElementLabel(element: HTMLElement, text: string): boolean {
return element.title === text ||
element.getAttribute('alt') === text ||
element.getAttribute('aria-label') === text;
}
/**
* Finds elements within a given container.
*
* @param container Parent element to search the element within.
* @param text Text to look for.
* @return Elements containing the given text.
*/
protected static findElementsBasedOnTextWithin(container: HTMLElement, text: string): HTMLElement[] {
const elements = this.findElementsBasedOnTextWithinWithExact(container, text);
// Give more relevance to exact matches.
elements.sort((a, b) => Number(b.exact) - Number(a.exact));
return elements.map(element => element.element);
};
/**
* Given a list of elements, get the top ancestors among all of them.
*
* This will remote duplicates and drop any elements nested within each other.
*
* @param elements Elements list.
* @return Top ancestors.
*/
protected static getTopAncestors(elements: HTMLElement[]): HTMLElement[] {
const uniqueElements = new Set(elements);
for (const element of uniqueElements) {
for (const otherElement of uniqueElements) {
if (otherElement === element) {
continue;
}
if (element.contains(otherElement)) {
uniqueElements.delete(otherElement);
}
}
}
return Array.from(uniqueElements);
};
/**
* Get parent element, including Shadow DOM parents.
*
* @param element Element.
* @return Parent element.
*/
protected static getParentElement(element: HTMLElement): HTMLElement | null {
return element.parentElement ||
(element.getRootNode() && (element.getRootNode() as ShadowRoot).host as HTMLElement) ||
null;
}
/**
* Get closest element matching a selector, without traversing up a given container.
*
* @param element Element.
* @param selector Selector.
* @param container Topmost container to search within.
* @return Closest matching element.
*/
protected static getClosestMatching(element: HTMLElement, selector: string, container: HTMLElement | null): HTMLElement | null {
if (element.matches(selector)) {
return element;
}
if (element === container || !element.parentElement) {
return null;
}
return this.getClosestMatching(element.parentElement, selector, container);
};
/**
* Function to find top container element.
*
* @param containerName Whether to search inside the a container name.
* @return Found top container element.
*/
protected static getCurrentTopContainerElement(containerName: string): HTMLElement | null {
let topContainer: HTMLElement | null = null;
let containers: HTMLElement[] = [];
const nonImplementedSelectors =
'ion-alert, ion-popover, ion-action-sheet, ion-modal, core-user-tours-user-tour.is-active, page-core-mainmenu, ion-app';
switch (containerName) {
case 'html':
containers = Array.from(document.querySelectorAll<HTMLElement>('html'));
break;
case 'toast':
containers = Array.from(document.querySelectorAll('ion-app ion-toast.hydrated'));
containers = containers.map(container => container?.shadowRoot?.querySelector('.toast-container') || container);
break;
case 'alert':
containers = Array.from(document.querySelectorAll('ion-app ion-alert.hydrated'));
break;
case 'action-sheet':
containers = Array.from(document.querySelectorAll('ion-app ion-action-sheet.hydrated'));
break;
case 'modal':
containers = Array.from(document.querySelectorAll('ion-app ion-modal.hydrated'));
break;
case 'popover':
containers = Array.from(document.querySelectorAll('ion-app ion-popover.hydrated'));
break;
case 'user-tour':
containers = Array.from(document.querySelectorAll('core-user-tours-user-tour.is-active'));
break;
default:
// Other containerName or not implemented.
containers = Array.from(document.querySelectorAll<HTMLElement>(nonImplementedSelectors));
}
if (containers.length > 0) {
// Get the one with more zIndex.
topContainer =
containers.reduce((a, b) => getComputedStyle(a).zIndex > getComputedStyle(b).zIndex ? a : b, containers[0]);
}
if (!topContainer) {
return null;
}
if (containerName == 'page' || containerName == 'split-view content') {
// Find non hidden pages inside the container.
let pageContainers = Array.from(topContainer.querySelectorAll<HTMLElement>('.ion-page:not(.ion-page-hidden)'));
pageContainers = pageContainers.filter((page) => !page.closest('.ion-page.ion-page-hidden'));
if (pageContainers.length > 0) {
// Get the more general one to avoid failing.
topContainer = pageContainers[0];
}
if (containerName == 'split-view content') {
topContainer = topContainer.querySelector<HTMLElement>('core-split-view ion-router-outlet');
}
}
return topContainer;
};
/**
* Function to find element based on their text or Aria label.
*
* @param locator Element locator.
* @param containerName Whether to search only inside a specific container.
* @return First found element.
*/
static findElementBasedOnText(locator: TestBehatElementLocator, containerName = ''): HTMLElement {
return this.findElementsBasedOnText(locator, containerName)[0];
}
/**
* Function to find elements based on their text or Aria label.
*
* @param locator Element locator.
* @param containerName Whether to search only inside a specific container.
* @return Found elements
*/
protected static findElementsBasedOnText(locator: TestBehatElementLocator, containerName = ''): HTMLElement[] {
let topContainer = this.getCurrentTopContainerElement(containerName);
let container = topContainer;
if (locator.within) {
const withinElements = this.findElementsBasedOnText(locator.within);
if (withinElements.length === 0) {
throw new Error('There was no match for within text');
} else if (withinElements.length > 1) {
const withinElementsAncestors = this.getTopAncestors(withinElements);
if (withinElementsAncestors.length > 1) {
throw new Error('Too many matches for within text');
}
topContainer = container = withinElementsAncestors[0];
} else {
topContainer = container = withinElements[0];
}
}
if (topContainer && locator.near) {
const nearElements = this.findElementsBasedOnText(locator.near);
if (nearElements.length === 0) {
throw new Error('There was no match for near text');
} else if (nearElements.length > 1) {
const nearElementsAncestors = this.getTopAncestors(nearElements);
if (nearElementsAncestors.length > 1) {
throw new Error('Too many matches for near text');
}
container = this.getParentElement(nearElementsAncestors[0]);
} else {
container = this.getParentElement(nearElements[0]);
}
}
do {
if (!container) {
break;
}
const elements = this.findElementsBasedOnTextWithin(container, locator.text);
let filteredElements: HTMLElement[] = elements;
if (locator.selector) {
filteredElements = [];
const selector = locator.selector;
elements.forEach((element) => {
const closest = this.getClosestMatching(element, selector, container);
if (closest) {
filteredElements.push(closest);
}
});
}
if (filteredElements.length > 0) {
return filteredElements;
}
} while (container !== topContainer && (container = this.getParentElement(container)) && container !== topContainer);
return [];
};
/**
* Make sure that an element is visible and wait to trigger the callback.
*
* @param element Element.
* @param callback Callback called when the element is visible, passing bounding box parameter.
*/
protected static ensureElementVisible(element: HTMLElement, callback: (rect: DOMRect) => void): void {
const initialRect = element.getBoundingClientRect();
element.scrollIntoView(false);
requestAnimationFrame(() => {
const rect = element.getBoundingClientRect();
if (initialRect.y !== rect.y) {
setTimeout(() => {
callback(rect);
}, 300);
TestsBehatBlocking.delay();
return;
}
callback(rect);
});
TestsBehatBlocking.delay();
};
/**
* Press an element.
*
* @param element Element to press.
*/
static pressElement(element: HTMLElement): void {
this.ensureElementVisible(element, (rect) => {
// Simulate a mouse click on the button.
const eventOptions = {
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
bubbles: true,
view: window,
cancelable: true,
};
// Events don't bubble up across Shadow DOM boundaries, and some buttons
// may not work without doing this.
const parentElement = this.getParentElement(element);
if (parentElement && parentElement.matches('ion-button, ion-back-button')) {
element = parentElement;
}
// There are some buttons in the app that don't respond to click events, for example
// buttons using the core-supress-events directive. That's why we need to send both
// click and mouse events.
element.dispatchEvent(new MouseEvent('mousedown', eventOptions));
setTimeout(() => {
element.dispatchEvent(new MouseEvent('mouseup', eventOptions));
element.click();
}, 300);
// Mark busy until the button click finishes processing.
TestsBehatBlocking.delay();
});
}
}
type ElementsWithExact = {
element: HTMLElement;
exact: boolean;
};

View File

@ -0,0 +1,391 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { TestsBehatDomUtils } from './behat-dom';
import { TestsBehatBlocking } from './behat-blocking';
/**
* Behat runtime servive with public API.
*/
export class TestsBehatRuntime {
static init(): void {
TestsBehatBlocking.init();
(window as BehatTestsWindow).behat = {
closePopup: TestsBehatRuntime.closePopup,
find: TestsBehatRuntime.find,
getAngularInstance: TestsBehatRuntime.getAngularInstance,
getHeader: TestsBehatRuntime.getHeader,
isSelected: TestsBehatRuntime.isSelected,
loadMoreItems: TestsBehatRuntime.loadMoreItems,
log: TestsBehatRuntime.log,
press: TestsBehatRuntime.press,
pressStandard: TestsBehatRuntime.pressStandard,
scrollTo: TestsBehatRuntime.scrollTo,
setField: TestsBehatRuntime.setField,
};
}
/**
* Function to find and click an app standard button.
*
* @param button Type of button to press
* @return OK if successful, or ERROR: followed by message
*/
static pressStandard(button: string): string {
this.log('Action - Click standard button: ' + button);
// Find button
let foundButton: HTMLElement | undefined;
switch (button) {
case 'back':
foundButton = TestsBehatDomUtils.findElementBasedOnText({ text: 'Back' });
break;
case 'main menu': // Deprecated name.
case 'more menu':
foundButton = TestsBehatDomUtils.findElementBasedOnText({
text: 'More',
selector: 'ion-tab-button',
});
break;
case 'user menu' :
foundButton = TestsBehatDomUtils.findElementBasedOnText({ text: 'User account' });
break;
case 'page menu':
foundButton = TestsBehatDomUtils.findElementBasedOnText({ text: 'Display options' });
break;
default:
return 'ERROR: Unsupported standard button type';
}
if (!foundButton) {
return `ERROR: Button '${button}' not found`;
}
// Click button
TestsBehatDomUtils.pressElement(foundButton);
return 'OK';
}
/**
* When there is a popup, clicks on the backdrop.
*
* @return OK if successful, or ERROR: followed by message
*/
static closePopup(): string {
this.log('Action - Close popup');
let backdrops = Array.from(document.querySelectorAll('ion-backdrop'));
backdrops = backdrops.filter((backdrop) => !!backdrop.offsetParent);
if (!backdrops.length) {
return 'ERROR: Could not find backdrop';
}
if (backdrops.length > 1) {
return 'ERROR: Found too many backdrops';
}
const backdrop = backdrops[0];
backdrop.click();
// Mark busy until the click finishes processing.
TestsBehatBlocking.delay();
return 'OK';
}
/**
* Function to find an arbitrary element based on its text or aria label.
*
* @param locator Element locator.
* @param containerName Whether to search only inside a specific container content.
* @return OK if successful, or ERROR: followed by message
*/
static find(locator: TestBehatElementLocator, containerName: string): string {
this.log('Action - Find', { locator, containerName });
try {
const element = TestsBehatDomUtils.findElementBasedOnText(locator, containerName);
if (!element) {
return 'ERROR: No element matches locator to find.';
}
this.log('Action - Found', { locator, containerName, element });
return 'OK';
} catch (error) {
return 'ERROR: ' + error.message;
}
}
/**
* Scroll an element into view.
*
* @param locator Element locator.
* @return OK if successful, or ERROR: followed by message
*/
static scrollTo(locator: TestBehatElementLocator): string {
this.log('Action - scrollTo', { locator });
try {
let element = TestsBehatDomUtils.findElementBasedOnText(locator);
if (!element) {
return 'ERROR: No element matches element to scroll to.';
}
element = element.closest('ion-item') ?? element.closest('button') ?? element;
element.scrollIntoView();
this.log('Action - Scrolled to', { locator, element });
return 'OK';
} catch (error) {
return 'ERROR: ' + error.message;
}
}
/**
* Load more items form an active list with infinite loader.
*
* @return OK if successful, or ERROR: followed by message
*/
static async loadMoreItems(): Promise<string> {
this.log('Action - loadMoreItems');
try {
const infiniteLoading = Array
.from(document.querySelectorAll<HTMLElement>('core-infinite-loading'))
.find(element => !element.closest('.ion-page-hidden'));
if (!infiniteLoading) {
return 'ERROR: There isn\'t an infinite loader in the current page.';
}
const initialOffset = infiniteLoading.offsetTop;
const isLoading = () => !!infiniteLoading.querySelector('ion-spinner[aria-label]');
const isCompleted = () => !isLoading() && !infiniteLoading.querySelector('ion-button');
const hasMoved = () => infiniteLoading.offsetTop !== initialOffset;
if (isCompleted()) {
return 'ERROR: All items are already loaded.';
}
infiniteLoading.scrollIntoView({ behavior: 'smooth' });
// Wait 100ms
await new Promise(resolve => setTimeout(resolve, 100));
if (isLoading() || isCompleted() || hasMoved()) {
return 'OK';
}
infiniteLoading.querySelector<HTMLElement>('ion-button')?.click();
// Wait 100ms
await new Promise(resolve => setTimeout(resolve, 100));
return (isLoading() || isCompleted() || hasMoved()) ? 'OK' : 'ERROR: Couldn\'t load more items.';
} catch (error) {
return 'ERROR: ' + error.message;
}
}
/**
* Check whether an item is selected or not.
*
* @param locator Element locator.
* @return YES or NO if successful, or ERROR: followed by message
*/
static isSelected(locator: TestBehatElementLocator): string {
this.log('Action - Is Selected', locator);
try {
const element = TestsBehatDomUtils.findElementBasedOnText(locator);
return TestsBehatDomUtils.isElementSelected(element, document.body) ? 'YES' : 'NO';
} catch (error) {
return 'ERROR: ' + error.message;
}
}
/**
* Function to press arbitrary item based on its text or Aria label.
*
* @param locator Element locator.
* @return OK if successful, or ERROR: followed by message
*/
static press(locator: TestBehatElementLocator): string {
this.log('Action - Press', locator);
try {
const found = TestsBehatDomUtils.findElementBasedOnText(locator);
if (!found) {
return 'ERROR: No element matches locator to press.';
}
TestsBehatDomUtils.pressElement(found);
return 'OK';
} catch (error) {
return 'ERROR: ' + error.message;
}
}
/**
* Gets the currently displayed page header.
*
* @return OK: followed by header text if successful, or ERROR: followed by message.
*/
static getHeader(): string {
this.log('Action - Get header');
let titles = Array.from(document.querySelectorAll<HTMLElement>('.ion-page:not(.ion-page-hidden) > ion-header h1'));
titles = titles.filter((title) => TestsBehatDomUtils.isElementVisible(title, document.body));
if (titles.length > 1) {
return 'ERROR: Too many possible titles.';
} else if (!titles.length) {
return 'ERROR: No title found.';
} else {
const title = titles[0].innerText.trim();
return 'OK:' + title;
}
}
/**
* Sets the text of a field to the specified value.
*
* This currently matches fields only based on the placeholder attribute.
*
* @param field Field name
* @param value New value
* @return OK or ERROR: followed by message
*/
static setField(field: string, value: string): string {
this.log('Action - Set field ' + field + ' to: ' + value);
const found: HTMLElement | HTMLInputElement | HTMLTextAreaElement =TestsBehatDomUtils.findElementBasedOnText(
{ text: field, selector: 'input, textarea, [contenteditable="true"]' },
);
if (!found) {
return 'ERROR: No element matches field to set.';
}
// Functions to get/set value depending on field type.
let setValue = (text: string) => {
found.innerHTML = text;
};
let getValue = () => found.innerHTML;
if (found instanceof HTMLInputElement || found instanceof HTMLTextAreaElement) {
setValue = (text: string) => {
found.value = text;
};
getValue = () => found.value;
}
// Pretend we have cut and pasted the new text.
let event: InputEvent;
if (getValue() !== '') {
event = new InputEvent('input', {
bubbles: true,
view: window,
cancelable: true,
inputType: 'deleteByCut',
});
setTimeout(() => {
setValue('');
found.dispatchEvent(event);
}, 0);
}
if (value !== '') {
event = new InputEvent('input', {
bubbles: true,
view: window,
cancelable: true,
inputType: 'insertFromPaste',
data: value,
});
setTimeout(() => {
setValue(value);
found.dispatchEvent(event);
}, 0);
}
return 'OK';
}
/**
* Get an Angular component instance.
*
* @param selector Element selector
* @param className Constructor class name
* @return Component instance
*/
static getAngularInstance(selector: string, className: string): unknown {
this.log('Action - Get Angular instance ' + selector + ', ' + className);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeElement = Array.from(document.querySelectorAll<any>(`.ion-page:not(.ion-page-hidden) ${selector}`)).pop();
if (!activeElement || !activeElement.__ngContext__) {
return null;
}
return activeElement.__ngContext__.find(node => node?.constructor?.name === className);
}
/**
* Logs information from this Behat runtime JavaScript, including the time and the 'BEHAT'
* keyword so we can easily filter for it if needed.
*/
static log(...args: unknown[]): void {
const now = new Date();
const nowFormatted = String(now.getHours()).padStart(2, '0') + ':' +
String(now.getMinutes()).padStart(2, '0') + ':' +
String(now.getSeconds()).padStart(2, '0') + '.' +
String(now.getMilliseconds()).padStart(2, '0');
console.log('BEHAT: ' + nowFormatted, ...args); // eslint-disable-line no-console
}
}
export type BehatTestsWindow = Window & {
M?: { // eslint-disable-line @typescript-eslint/naming-convention
util?: {
pending_js?: string[]; // eslint-disable-line @typescript-eslint/naming-convention
};
};
behatInit?: () => void;
behat?: unknown;
};
export type TestBehatElementLocator = {
text: string;
within?: TestBehatElementLocator;
near?: TestBehatElementLocator;
selector?: string;
};