You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
648 lines
23 KiB
648 lines
23 KiB
(function() {
|
|
// Set up the M object - only pending_js is implemented.
|
|
window.M = window.M ? window.M : {};
|
|
var 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
|
|
*/
|
|
var log = function(text) {
|
|
var now = new Date();
|
|
var 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 + ' ' + text); // 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)
|
|
*/
|
|
var 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
|
|
*/
|
|
var 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
|
|
*/
|
|
var 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...
|
|
updateSpinner();
|
|
|
|
// 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.
|
|
*/
|
|
var addPendingDelay = function() {
|
|
addPending('...');
|
|
removePending('...');
|
|
};
|
|
|
|
// Override XMLHttpRequest to mark things pending while there is a request waiting.
|
|
var realOpen = XMLHttpRequest.prototype.open;
|
|
var requestIndex = 0;
|
|
XMLHttpRequest.prototype.open = function() {
|
|
var index = requestIndex++;
|
|
var key = 'httprequest-' + index;
|
|
|
|
// 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);
|
|
};
|
|
|
|
var waitingSpinner = false;
|
|
|
|
/**
|
|
* Checks if a loading spinner is present and visible; if so, adds it to the pending array
|
|
* (and if not, removes it).
|
|
*/
|
|
var updateSpinner = function() {
|
|
var spinner = document.querySelector('span.core-loading-spinner');
|
|
if (spinner && spinner.offsetParent) {
|
|
if (!waitingSpinner) {
|
|
addPending('spinner');
|
|
waitingSpinner = true;
|
|
}
|
|
} else {
|
|
if (waitingSpinner) {
|
|
removePending('spinner');
|
|
waitingSpinner = 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.
|
|
|
|
var recentMutation = false;
|
|
var 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.
|
|
*/
|
|
var pollRecentMutation = function() {
|
|
if (Date.now() - lastMutation > 500) {
|
|
recentMutation = false;
|
|
removePending('dom-mutation');
|
|
} else {
|
|
setTimeout(pollRecentMutation, 100);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Mutation callback, called whenever the DOM is mutated.
|
|
*/
|
|
var mutationCallback = function() {
|
|
lastMutation = Date.now();
|
|
if (!recentMutation) {
|
|
recentMutation = true;
|
|
addPending('dom-mutation');
|
|
setTimeout(pollRecentMutation, 500);
|
|
}
|
|
// Also update the spinner presence if needed.
|
|
updateSpinner();
|
|
};
|
|
|
|
// Set listener using the mutation callback.
|
|
var observer = new MutationObserver(mutationCallback);
|
|
observer.observe(document, {attributes: true, childList: true, subtree: true});
|
|
|
|
/**
|
|
* Generic shared function to find possible xpath matches within the document, that are visible,
|
|
* and then process them using a callback function.
|
|
*
|
|
* @param {string} xpath Xpath to use
|
|
* @param {function} process Callback function that handles each matched node
|
|
*/
|
|
var findPossibleMatches = function(xpath, process) {
|
|
var matches = document.evaluate(xpath, document);
|
|
while (true) {
|
|
var match = matches.iterateNext();
|
|
if (!match) {
|
|
break;
|
|
}
|
|
// Skip invisible text nodes.
|
|
if (!match.offsetParent) {
|
|
continue;
|
|
}
|
|
|
|
process(match);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Function to find an element based on its text or Aria label.
|
|
*
|
|
* @param {string} text Text (full or partial)
|
|
* @param {string} [near] Optional 'near' text - if specified, must have a single match on page
|
|
* @return {HTMLElement} Found element
|
|
* @throws {string} Error message beginning 'ERROR:' if something went wrong
|
|
*/
|
|
var findElementBasedOnText = function(text, near) {
|
|
// Find all the elements that contain this text (and don't have a child element that
|
|
// contains it - i.e. the most specific elements).
|
|
var escapedText = text.replace('"', '""');
|
|
var exactMatches = [];
|
|
var anyMatches = [];
|
|
findPossibleMatches('//*[contains(normalize-space(.), "' + escapedText +
|
|
'") and not(child::*[contains(normalize-space(.), "' + escapedText + '")])]',
|
|
function(match) {
|
|
// Get the text. Note that innerText returns capitalised values for Android buttons
|
|
// for some reason, so we'll have to do a case-insensitive match.
|
|
var matchText = match.innerText.trim().toLowerCase();
|
|
|
|
// Let's just check - is this actually a label for something else? If so we will click
|
|
// that other thing instead.
|
|
var labelId = document.evaluate('string(ancestor-or-self::ion-label[@id][1]/@id)', match).stringValue;
|
|
if (labelId) {
|
|
var target = document.querySelector('*[aria-labelledby=' + labelId + ']');
|
|
if (target) {
|
|
match = target;
|
|
}
|
|
}
|
|
|
|
// Add to array depending on if it's an exact or partial match.
|
|
if (matchText === text.toLowerCase()) {
|
|
exactMatches.push(match);
|
|
} else {
|
|
anyMatches.push(match);
|
|
}
|
|
});
|
|
|
|
// Find all the Aria labels that contain this text.
|
|
var exactLabelMatches = [];
|
|
var anyLabelMatches = [];
|
|
findPossibleMatches('//*[@aria-label and contains(@aria-label, "' + escapedText + '")]' +
|
|
'| //img[@alt and contains(@alt, "' + escapedText + '")]', function(match) {
|
|
// Add to array depending on if it's an exact or partial match.
|
|
var attributeData = match.getAttribute('aria-label') || match.getAttribute('alt');
|
|
if (attributeData.trim() === text) {
|
|
exactLabelMatches.push(match);
|
|
} else {
|
|
anyLabelMatches.push(match);
|
|
}
|
|
});
|
|
|
|
// If the 'near' text is set, use it to filter results.
|
|
var nearAncestors = [];
|
|
if (near !== undefined) {
|
|
escapedText = near.replace('"', '""');
|
|
var exactNearMatches = [];
|
|
var anyNearMatches = [];
|
|
findPossibleMatches('//*[contains(normalize-space(.), "' + escapedText +
|
|
'") and not(child::*[contains(normalize-space(.), "' + escapedText +
|
|
'")])]', function(match) {
|
|
// Get the text.
|
|
var matchText = match.innerText.trim();
|
|
|
|
// Add to array depending on if it's an exact or partial match.
|
|
if (matchText === text) {
|
|
exactNearMatches.push(match);
|
|
} else {
|
|
anyNearMatches.push(match);
|
|
}
|
|
});
|
|
|
|
var nearFound = null;
|
|
|
|
// If there is an exact text match, use that (regardless of other matches).
|
|
if (exactNearMatches.length > 1) {
|
|
throw new Error('Too many exact matches for near text');
|
|
} else if (exactNearMatches.length) {
|
|
nearFound = exactNearMatches[0];
|
|
}
|
|
|
|
if (nearFound === null) {
|
|
// If there is one partial text match, use that.
|
|
if (anyNearMatches.length > 1) {
|
|
throw new Error('Too many partial matches for near text');
|
|
} else if (anyNearMatches.length) {
|
|
nearFound = anyNearMatches[0];
|
|
}
|
|
}
|
|
|
|
if (!nearFound) {
|
|
throw new Error('No matches for near text');
|
|
}
|
|
|
|
while (nearFound) {
|
|
nearAncestors.push(nearFound);
|
|
nearFound = nearFound.parentNode;
|
|
}
|
|
|
|
/**
|
|
* Checks the number of steps up the tree from a specified node before getting to an
|
|
* ancestor of the 'near' item
|
|
*
|
|
* @param {HTMLElement} node HTML node
|
|
* @returns {number} Number of steps up, or Number.MAX_SAFE_INTEGER if it never matched
|
|
*/
|
|
var calculateNearDepth = function(node) {
|
|
var depth = 0;
|
|
while (node) {
|
|
var ancestorDepth = nearAncestors.indexOf(node);
|
|
if (ancestorDepth !== -1) {
|
|
return depth + ancestorDepth;
|
|
}
|
|
node = node.parentNode;
|
|
depth++;
|
|
}
|
|
return Number.MAX_SAFE_INTEGER;
|
|
};
|
|
|
|
/**
|
|
* Reduces an array to include only the nearest in each category.
|
|
*
|
|
* @param {Array} arr Array to
|
|
* @return {Array} Array including only the items with minimum 'near' depth
|
|
*/
|
|
var filterNonNearest = function(arr) {
|
|
var nearDepth = arr.map(function(node) {
|
|
return calculateNearDepth(node);
|
|
});
|
|
var minDepth = Math.min.apply(null, nearDepth);
|
|
return arr.filter(function(element, index) {
|
|
return nearDepth[index] == minDepth;
|
|
});
|
|
};
|
|
|
|
// Filter all the category arrays.
|
|
exactMatches = filterNonNearest(exactMatches);
|
|
exactLabelMatches = filterNonNearest(exactLabelMatches);
|
|
anyMatches = filterNonNearest(anyMatches);
|
|
anyLabelMatches = filterNonNearest(anyLabelMatches);
|
|
}
|
|
|
|
// Select the resulting match. Note this 'do' loop is not really a loop, it is just so we
|
|
// can easily break out of it as soon as we find a match.
|
|
var found = null;
|
|
do {
|
|
// If there is an exact text match, use that (regardless of other matches).
|
|
if (exactMatches.length > 1) {
|
|
throw new Error('Too many exact matches for text');
|
|
} else if (exactMatches.length) {
|
|
found = exactMatches[0];
|
|
break;
|
|
}
|
|
|
|
// If there is an exact label match, use that.
|
|
if (exactLabelMatches.length > 1) {
|
|
throw new Error('Too many exact label matches for text');
|
|
} else if (exactLabelMatches.length) {
|
|
found = exactLabelMatches[0];
|
|
break;
|
|
}
|
|
|
|
// If there is one partial text match, use that.
|
|
if (anyMatches.length > 1) {
|
|
throw new Error('Too many partial matches for text');
|
|
} else if (anyMatches.length) {
|
|
found = anyMatches[0];
|
|
break;
|
|
}
|
|
|
|
// Finally if there is one partial label match, use that.
|
|
if (anyLabelMatches.length > 1) {
|
|
throw new Error('Too many partial label matches for text');
|
|
} else if (anyLabelMatches.length) {
|
|
found = anyLabelMatches[0];
|
|
break;
|
|
}
|
|
} while (false);
|
|
|
|
if (!found) {
|
|
throw new Error('No matches for text');
|
|
}
|
|
|
|
return found;
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
var behatPressStandard = function(button) {
|
|
log('Action - Click standard button: ' + button);
|
|
var selector;
|
|
switch (button) {
|
|
case 'back' :
|
|
selector = 'ion-navbar > button.back-button-md';
|
|
break;
|
|
case 'main menu' :
|
|
selector = 'page-core-mainmenu .tab-button > ion-icon[aria-label=more]';
|
|
break;
|
|
case 'page menu' :
|
|
// This lang string was changed in app version 3.6.
|
|
selector = 'core-context-menu > button[aria-label=Info], ' +
|
|
'core-context-menu > button[aria-label=Information]';
|
|
break;
|
|
default:
|
|
return 'ERROR: Unsupported standard button type';
|
|
}
|
|
var buttons = Array.from(document.querySelectorAll(selector));
|
|
var foundButton = null;
|
|
var tooMany = false;
|
|
buttons.forEach(function(button) {
|
|
if (button.offsetParent) {
|
|
if (foundButton === null) {
|
|
foundButton = button;
|
|
} else {
|
|
tooMany = true;
|
|
}
|
|
}
|
|
});
|
|
if (!foundButton) {
|
|
return 'ERROR: Could not find button';
|
|
}
|
|
if (tooMany) {
|
|
return 'ERROR: Found too many buttons';
|
|
}
|
|
foundButton.click();
|
|
|
|
// Mark busy until the button click finishes processing.
|
|
addPendingDelay();
|
|
|
|
return 'OK';
|
|
};
|
|
|
|
/**
|
|
* When there is a popup, clicks on the backdrop.
|
|
*
|
|
* @return {string} OK if successful, or ERROR: followed by message
|
|
*/
|
|
var behatClosePopup = function() {
|
|
log('Action - Close popup');
|
|
|
|
var backdrops = Array.from(document.querySelectorAll('ion-backdrop'));
|
|
var found = null;
|
|
var tooMany = false;
|
|
backdrops.forEach(function(backdrop) {
|
|
if (backdrop.offsetParent) {
|
|
if (found === null) {
|
|
found = backdrop;
|
|
} else {
|
|
tooMany = true;
|
|
}
|
|
}
|
|
});
|
|
if (!found) {
|
|
return 'ERROR: Could not find backdrop';
|
|
}
|
|
if (tooMany) {
|
|
return 'ERROR: Found too many backdrops';
|
|
}
|
|
found.click();
|
|
|
|
// Mark busy until the click finishes processing.
|
|
addPendingDelay();
|
|
|
|
return 'OK';
|
|
};
|
|
|
|
/**
|
|
* Function to press arbitrary item based on its text or Aria label.
|
|
*
|
|
* @param {string} text Text (full or partial)
|
|
* @param {string} near Optional 'near' text - if specified, must have a single match on page
|
|
* @return {string} OK if successful, or ERROR: followed by message
|
|
*/
|
|
var behatPress = function(text, near) {
|
|
log('Action - Press ' + text + (near === undefined ? '' : ' - near ' + near));
|
|
|
|
var found;
|
|
try {
|
|
found = findElementBasedOnText(text, near);
|
|
} catch (error) {
|
|
return 'ERROR: ' + error.message;
|
|
}
|
|
|
|
// Simulate a mouse click on the button.
|
|
found.scrollIntoView();
|
|
var rect = found.getBoundingClientRect();
|
|
var eventOptions = {clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
|
bubbles: true, view: window, cancelable: true};
|
|
setTimeout(function() {
|
|
found.dispatchEvent(new MouseEvent('mousedown', eventOptions));
|
|
}, 0);
|
|
setTimeout(function() {
|
|
found.dispatchEvent(new MouseEvent('mouseup', eventOptions));
|
|
}, 0);
|
|
setTimeout(function() {
|
|
found.dispatchEvent(new MouseEvent('click', eventOptions));
|
|
}, 0);
|
|
|
|
// Mark busy until the button click finishes processing.
|
|
addPendingDelay();
|
|
|
|
return 'OK';
|
|
};
|
|
|
|
/**
|
|
* Gets the currently displayed page header.
|
|
*
|
|
* @return {string} OK: followed by header text if successful, or ERROR: followed by message.
|
|
*/
|
|
var behatGetHeader = function() {
|
|
log('Action - Get header');
|
|
|
|
var result = null;
|
|
var resultCount = 0;
|
|
var titles = Array.from(document.querySelectorAll('ion-header ion-title'));
|
|
titles.forEach(function(title) {
|
|
if (title.offsetParent) {
|
|
result = title.innerText.trim();
|
|
resultCount++;
|
|
}
|
|
});
|
|
|
|
if (resultCount > 1) {
|
|
return 'ERROR: Too many possible titles';
|
|
} else if (!resultCount) {
|
|
return 'ERROR: No title found';
|
|
} else {
|
|
return 'OK:' + result;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
var behatSetField = function(field, value) {
|
|
log('Action - Set field ' + field + ' to: ' + value);
|
|
|
|
// Find input(s) with given placeholder.
|
|
var escapedText = field.replace('"', '""');
|
|
var exactMatches = [];
|
|
var anyMatches = [];
|
|
findPossibleMatches(
|
|
'//input[contains(@placeholder, "' + escapedText + '")] |' +
|
|
'//textarea[contains(@placeholder, "' + escapedText + '")] |' +
|
|
'//core-rich-text-editor/descendant::div[contains(@data-placeholder-text, "' +
|
|
escapedText + '")]', function(match) {
|
|
// Add to array depending on if it's an exact or partial match.
|
|
var placeholder;
|
|
if (match.nodeName === 'DIV') {
|
|
placeholder = match.getAttribute('data-placeholder-text');
|
|
} else {
|
|
placeholder = match.getAttribute('placeholder');
|
|
}
|
|
if (placeholder.trim() === field) {
|
|
exactMatches.push(match);
|
|
} else {
|
|
anyMatches.push(match);
|
|
}
|
|
});
|
|
|
|
// Select the resulting match.
|
|
var found = null;
|
|
do {
|
|
// If there is an exact text match, use that (regardless of other matches).
|
|
if (exactMatches.length > 1) {
|
|
return 'ERROR: Too many exact placeholder matches for text';
|
|
} else if (exactMatches.length) {
|
|
found = exactMatches[0];
|
|
break;
|
|
}
|
|
|
|
// If there is one partial text match, use that.
|
|
if (anyMatches.length > 1) {
|
|
return 'ERROR: Too many partial placeholder matches for text';
|
|
} else if (anyMatches.length) {
|
|
found = anyMatches[0];
|
|
break;
|
|
}
|
|
} while (false);
|
|
|
|
if (!found) {
|
|
return 'ERROR: No matches for text';
|
|
}
|
|
|
|
// Functions to get/set value depending on field type.
|
|
var setValue;
|
|
var 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.
|
|
var 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';
|
|
};
|
|
|
|
// Make some functions publicly available for Behat to call.
|
|
window.behat = {
|
|
pressStandard : behatPressStandard,
|
|
closePopup : behatClosePopup,
|
|
press : behatPress,
|
|
setField : behatSetField,
|
|
getHeader : behatGetHeader,
|
|
};
|
|
})();
|
|
|