b07b53e6373854e749691af1ec1deeb30f660dbe
[moodle.git] / lib / tests / behat / app_behat_runtime.js
1 (function() {
2     // Set up the M object - only pending_js is implemented.
3     window.M = window.M ? window.M : {};
4     var M = window.M;
5     M.util = M.util ? M.util : {};
6     M.util.pending_js = M.util.pending_js ? M.util.pending_js : []; // eslint-disable-line camelcase
8     /**
9      * Logs information from this Behat runtime JavaScript, including the time and the 'BEHAT'
10      * keyword so we can easily filter for it if needed.
11      *
12      * @param {string} text Information to log
13      */
14     var log = function(text) {
15         var now = new Date();
16         var nowFormatted = String(now.getHours()).padStart(2, '0') + ':' +
17                 String(now.getMinutes()).padStart(2, '0') + ':' +
18                 String(now.getSeconds()).padStart(2, '0') + '.' +
19                 String(now.getMilliseconds()).padStart(2, '0');
20         console.log('BEHAT: ' + nowFormatted + ' ' + text); // eslint-disable-line no-console
21     };
23     /**
24      * Run after several setTimeouts to ensure queued events are finished.
25      *
26      * @param {function} target function to run
27      * @param {number} count Number of times to do setTimeout (leave blank for 10)
28      */
29     var runAfterEverything = function(target, count) {
30         if (count === undefined) {
31             count = 10;
32         }
33         setTimeout(function() {
34             count--;
35             if (count == 0) {
36                 target();
37             } else {
38                 runAfterEverything(target, count);
39             }
40         }, 0);
41     };
43     /**
44      * Adds a pending key to the array.
45      *
46      * @param {string} key Key to add
47      */
48     var addPending = function(key) {
49         // Add a special DELAY entry whenever another entry is added.
50         if (window.M.util.pending_js.length == 0) {
51             window.M.util.pending_js.push('DELAY');
52         }
53         window.M.util.pending_js.push(key);
55         log('PENDING+: ' + window.M.util.pending_js);
56     };
58     /**
59      * Removes a pending key from the array. If this would clear the array, the actual clear only
60      * takes effect after the queued events are finished.
61      *
62      * @param {string} key Key to remove
63      */
64     var removePending = function(key) {
65         // Remove the key immediately.
66         window.M.util.pending_js = window.M.util.pending_js.filter(function(x) { // eslint-disable-line camelcase
67             return x !== key;
68         });
69         log('PENDING-: ' + window.M.util.pending_js);
71         // If the only thing left is DELAY, then remove that as well, later...
72         if (window.M.util.pending_js.length === 1) {
73             runAfterEverything(function() {
74                 // Check there isn't a spinner...
75                 updateSpinner();
77                 // Only remove it if the pending array is STILL empty after all that.
78                 if (window.M.util.pending_js.length === 1) {
79                     window.M.util.pending_js = []; // eslint-disable-line camelcase
80                     log('PENDING-: ' + window.M.util.pending_js);
81                 }
82             });
83         }
84     };
86     /**
87      * Adds a pending key to the array, but removes it after some setTimeouts finish.
88      */
89     var addPendingDelay = function() {
90         addPending('...');
91         removePending('...');
92     };
94     // Override XMLHttpRequest to mark things pending while there is a request waiting.
95     var realOpen = XMLHttpRequest.prototype.open;
96     var requestIndex = 0;
97     XMLHttpRequest.prototype.open = function() {
98         var index = requestIndex++;
99         var key = 'httprequest-' + index;
101         // Add to the list of pending requests.
102         addPending(key);
104         // Detect when it finishes and remove it from the list.
105         this.addEventListener('loadend', function() {
106             removePending(key);
107         });
109         return realOpen.apply(this, arguments);
110     };
112     var waitingSpinner = false;
114     /**
115      * Checks if a loading spinner is present and visible; if so, adds it to the pending array
116      * (and if not, removes it).
117      */
118     var updateSpinner = function() {
119         var spinner = document.querySelector('span.core-loading-spinner');
120         if (spinner && spinner.offsetParent) {
121             if (!waitingSpinner) {
122                 addPending('spinner');
123                 waitingSpinner = true;
124             }
125         } else {
126             if (waitingSpinner) {
127                 removePending('spinner');
128                 waitingSpinner = false;
129             }
130         }
131     };
133     // It would be really beautiful if you could detect CSS transitions and animations, that would
134     // cover almost everything, but sadly there is no way to do this because the transitionstart
135     // and animationcancel events are not implemented in Chrome, so we cannot detect either of
136     // these reliably. Instead, we have to look for any DOM changes and do horrible polling. Most
137     // of the animations are set to 500ms so we allow it to continue from 500ms after any DOM
138     // change.
140     var recentMutation = false;
141     var lastMutation;
143     /**
144      * Called from the mutation callback to remove the pending tag after 500ms if nothing else
145      * gets mutated.
146      *
147      * This will be called after 500ms, then every 100ms until there have been no mutation events
148      * for 500ms.
149      */
150     var pollRecentMutation = function() {
151         if (Date.now() - lastMutation > 500) {
152             recentMutation = false;
153             removePending('dom-mutation');
154         } else {
155             setTimeout(pollRecentMutation, 100);
156         }
157     };
159     /**
160      * Mutation callback, called whenever the DOM is mutated.
161      */
162     var mutationCallback = function() {
163         lastMutation = Date.now();
164         if (!recentMutation) {
165             recentMutation = true;
166             addPending('dom-mutation');
167             setTimeout(pollRecentMutation, 500);
168         }
169         // Also update the spinner presence if needed.
170         updateSpinner();
171     };
173     // Set listener using the mutation callback.
174     var observer = new MutationObserver(mutationCallback);
175     observer.observe(document, {attributes: true, childList: true, subtree: true});
177     /**
178      * Generic shared function to find possible xpath matches within the document, that are visible,
179      * and then process them using a callback function.
180      *
181      * @param {string} xpath Xpath to use
182      * @param {function} process Callback function that handles each matched node
183      */
184     var findPossibleMatches = function(xpath, process) {
185         var matches = document.evaluate(xpath, document);
186         while (true) {
187             var match = matches.iterateNext();
188             if (!match) {
189                 break;
190             }
191             // Skip invisible text nodes.
192             if (!match.offsetParent) {
193                 continue;
194             }
196             process(match);
197         }
198     };
200     /**
201      * Function to find an element based on its text or Aria label.
202      *
203      * @param {string} text Text (full or partial)
204      * @param {string} [near] Optional 'near' text - if specified, must have a single match on page
205      * @return {HTMLElement} Found element
206      * @throws {string} Error message beginning 'ERROR:' if something went wrong
207      */
208     var findElementBasedOnText = function(text, near) {
209         // Find all the elements that contain this text (and don't have a child element that
210         // contains it - i.e. the most specific elements).
211         var escapedText = text.replace('"', '""');
212         var exactMatches = [];
213         var anyMatches = [];
214         findPossibleMatches('//*[contains(normalize-space(.), "' + escapedText +
215                 '") and not(child::*[contains(normalize-space(.), "' + escapedText + '")])]',
216                 function(match) {
217                     // Get the text. Note that innerText returns capitalised values for Android buttons
218                     // for some reason, so we'll have to do a case-insensitive match.
219                     var matchText = match.innerText.trim().toLowerCase();
221                     // Let's just check - is this actually a label for something else? If so we will click
222                     // that other thing instead.
223                     var labelId = document.evaluate('string(ancestor-or-self::ion-label[@id][1]/@id)', match).stringValue;
224                     if (labelId) {
225                         var target = document.querySelector('*[aria-labelledby=' + labelId + ']');
226                         if (target) {
227                             match = target;
228                         }
229                     }
231                     // Add to array depending on if it's an exact or partial match.
232                     if (matchText === text.toLowerCase()) {
233                         exactMatches.push(match);
234                     } else {
235                         anyMatches.push(match);
236                     }
237                 });
239         // Find all the Aria labels that contain this text.
240         var exactLabelMatches = [];
241         var anyLabelMatches = [];
242         findPossibleMatches('//*[@aria-label and contains(@aria-label, "' + escapedText +
243                 '")]', function(match) {
244                     // Add to array depending on if it's an exact or partial match.
245                     if (match.getAttribute('aria-label').trim() === text) {
246                         exactLabelMatches.push(match);
247                     } else {
248                         anyLabelMatches.push(match);
249                     }
250                 });
252         // If the 'near' text is set, use it to filter results.
253         var nearAncestors = [];
254         if (near !== undefined) {
255             escapedText = near.replace('"', '""');
256             var exactNearMatches = [];
257             var anyNearMatches = [];
258             findPossibleMatches('//*[contains(normalize-space(.), "' + escapedText +
259                     '") and not(child::*[contains(normalize-space(.), "' + escapedText +
260                     '")])]', function(match) {
261                         // Get the text.
262                         var matchText = match.innerText.trim();
264                         // Add to array depending on if it's an exact or partial match.
265                         if (matchText === text) {
266                             exactNearMatches.push(match);
267                         } else {
268                             anyNearMatches.push(match);
269                         }
270                     });
272             var nearFound = null;
274             // If there is an exact text match, use that (regardless of other matches).
275             if (exactNearMatches.length > 1) {
276                 throw new Error('Too many exact matches for near text');
277             } else if (exactNearMatches.length) {
278                 nearFound = exactNearMatches[0];
279             }
281             if (nearFound === null) {
282                 // If there is one partial text match, use that.
283                 if (anyNearMatches.length > 1) {
284                     throw new Error('Too many partial matches for near text');
285                 } else if (anyNearMatches.length) {
286                     nearFound = anyNearMatches[0];
287                 }
288             }
290             if (!nearFound) {
291                 throw new Error('No matches for near text');
292             }
294             while (nearFound) {
295                 nearAncestors.push(nearFound);
296                 nearFound = nearFound.parentNode;
297             }
299             /**
300              * Checks the number of steps up the tree from a specified node before getting to an
301              * ancestor of the 'near' item
302              *
303              * @param {HTMLElement} node HTML node
304              * @returns {number} Number of steps up, or Number.MAX_SAFE_INTEGER if it never matched
305              */
306             var calculateNearDepth = function(node) {
307                 var depth = 0;
308                 while (node) {
309                     var ancestorDepth = nearAncestors.indexOf(node);
310                     if (ancestorDepth !== -1) {
311                         return depth + ancestorDepth;
312                     }
313                     node = node.parentNode;
314                     depth++;
315                 }
316                 return Number.MAX_SAFE_INTEGER;
317             };
319             /**
320              * Reduces an array to include only the nearest in each category.
321              *
322              * @param {Array} arr Array to
323              * @return {Array} Array including only the items with minimum 'near' depth
324              */
325             var filterNonNearest = function(arr) {
326                 var nearDepth = arr.map(function(node) {
327                     return calculateNearDepth(node);
328                 });
329                 var minDepth = Math.min.apply(null, nearDepth);
330                 return arr.filter(function(element, index) {
331                     return nearDepth[index] == minDepth;
332                 });
333             };
335             // Filter all the category arrays.
336             exactMatches = filterNonNearest(exactMatches);
337             exactLabelMatches = filterNonNearest(exactLabelMatches);
338             anyMatches = filterNonNearest(anyMatches);
339             anyLabelMatches = filterNonNearest(anyLabelMatches);
340         }
342         // Select the resulting match. Note this 'do' loop is not really a loop, it is just so we
343         // can easily break out of it as soon as we find a match.
344         var found = null;
345         do {
346             // If there is an exact text match, use that (regardless of other matches).
347             if (exactMatches.length > 1) {
348                 throw new Error('Too many exact matches for text');
349             } else if (exactMatches.length) {
350                 found = exactMatches[0];
351                 break;
352             }
354             // If there is an exact label match, use that.
355             if (exactLabelMatches.length > 1) {
356                 throw new Error('Too many exact label matches for text');
357             } else if (exactLabelMatches.length) {
358                 found = exactLabelMatches[0];
359                 break;
360             }
362             // If there is one partial text match, use that.
363             if (anyMatches.length > 1) {
364                 throw new Error('Too many partial matches for text');
365             } else if (anyMatches.length) {
366                 found = anyMatches[0];
367                 break;
368             }
370             // Finally if there is one partial label match, use that.
371             if (anyLabelMatches.length > 1) {
372                 throw new Error('Too many partial label matches for text');
373             } else if (anyLabelMatches.length) {
374                 found = anyLabelMatches[0];
375                 break;
376             }
377         } while (false);
379         if (!found) {
380             throw new Error('No matches for text');
381         }
383         return found;
384     };
386     /**
387      * Function to find and click an app standard button.
388      *
389      * @param {string} button Type of button to press
390      * @return {string} OK if successful, or ERROR: followed by message
391      */
392     window.behatPressStandard = function(button) {
393         log('Action - Click standard button: ' + button);
394         var selector;
395         switch (button) {
396             case 'back' :
397                 selector = 'ion-navbar > button.back-button-md';
398                 break;
399             case 'main menu' :
400                 selector = 'page-core-mainmenu .tab-button > ion-icon[aria-label=more]';
401                 break;
402             case 'page menu' :
403                 // This lang string was changed in app version 3.6.
404                 selector = 'core-context-menu > button[aria-label=Info], ' +
405                         'core-context-menu > button[aria-label=Information]';
406                 break;
407             default:
408                 return 'ERROR: Unsupported standard button type';
409         }
410         var buttons = Array.from(document.querySelectorAll(selector));
411         var foundButton = null;
412         var tooMany = false;
413         buttons.forEach(function(button) {
414             if (button.offsetParent) {
415                 if (foundButton === null) {
416                     foundButton = button;
417                 } else {
418                     tooMany = true;
419                 }
420             }
421         });
422         if (!foundButton) {
423             return 'ERROR: Could not find button';
424         }
425         if (tooMany) {
426             return 'ERROR: Found too many buttons';
427         }
428         foundButton.click();
430         // Mark busy until the button click finishes processing.
431         addPendingDelay();
433         return 'OK';
434     };
436     /**
437      * When there is a popup, clicks on the backdrop.
438      *
439      * @return {string} OK if successful, or ERROR: followed by message
440      */
441     window.behatClosePopup = function() {
442         log('Action - Close popup');
444         var backdrops = Array.from(document.querySelectorAll('ion-backdrop'));
445         var found = null;
446         var tooMany = false;
447         backdrops.forEach(function(backdrop) {
448             if (backdrop.offsetParent) {
449                 if (found === null) {
450                     found = backdrop;
451                 } else {
452                     tooMany = true;
453                 }
454             }
455         });
456         if (!found) {
457             return 'ERROR: Could not find backdrop';
458         }
459         if (tooMany) {
460             return 'ERROR: Found too many backdrops';
461         }
462         found.click();
464         // Mark busy until the click finishes processing.
465         addPendingDelay();
467         return 'OK';
468     };
470     /**
471      * Function to press arbitrary item based on its text or Aria label.
472      *
473      * @param {string} text Text (full or partial)
474      * @param {string} near Optional 'near' text - if specified, must have a single match on page
475      * @return {string} OK if successful, or ERROR: followed by message
476      */
477     window.behatPress = function(text, near) {
478         log('Action - Press ' + text + (near === undefined ? '' : ' - near ' + near));
480         var found;
481         try {
482             found = findElementBasedOnText(text, near);
483         } catch (error) {
484             return 'ERROR: ' + error.message;
485         }
487         // Simulate a mouse click on the button.
488         found.scrollIntoView();
489         var rect = found.getBoundingClientRect();
490         var eventOptions = {clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
491                 bubbles: true, view: window, cancelable: true};
492         setTimeout(function() {
493             found.dispatchEvent(new MouseEvent('mousedown', eventOptions));
494         }, 0);
495         setTimeout(function() {
496             found.dispatchEvent(new MouseEvent('mouseup', eventOptions));
497         }, 0);
498         setTimeout(function() {
499             found.dispatchEvent(new MouseEvent('click', eventOptions));
500         }, 0);
502         // Mark busy until the button click finishes processing.
503         addPendingDelay();
505         return 'OK';
506     };
508     /**
509      * Gets the currently displayed page header.
510      *
511      * @return {string} OK: followed by header text if successful, or ERROR: followed by message.
512      */
513     window.behatGetHeader = function() {
514         log('Action - Get header');
516         var result = null;
517         var resultCount = 0;
518         var titles = Array.from(document.querySelectorAll('ion-header ion-title'));
519         titles.forEach(function(title) {
520             if (title.offsetParent) {
521                 result = title.innerText.trim();
522                 resultCount++;
523             }
524         });
526         if (resultCount > 1) {
527             return 'ERROR: Too many possible titles';
528         } else if (!resultCount) {
529             return 'ERROR: No title found';
530         } else {
531             return 'OK:' + result;
532         }
533     };
535     /**
536      * Sets the text of a field to the specified value.
537      *
538      * This currently matches fields only based on the placeholder attribute.
539      *
540      * @param {string} field Field name
541      * @param {string} value New value
542      * @return {string} OK or ERROR: followed by message
543      */
544     window.behatSetField = function(field, value) {
545         log('Action - Set field ' + field + ' to: ' + value);
547         // Find input(s) with given placeholder.
548         var escapedText = field.replace('"', '""');
549         var exactMatches = [];
550         var anyMatches = [];
551         findPossibleMatches(
552                 '//input[contains(@placeholder, "' + escapedText + '")] |' +
553                 '//textarea[contains(@placeholder, "' + escapedText + '")] |' +
554                 '//core-rich-text-editor/descendant::div[contains(@data-placeholder-text, "' +
555                 escapedText + '")]', function(match) {
556                     // Add to array depending on if it's an exact or partial match.
557                     var placeholder;
558                     if (match.nodeName === 'DIV') {
559                         placeholder = match.getAttribute('data-placeholder-text');
560                     } else {
561                         placeholder = match.getAttribute('placeholder');
562                     }
563                     if (placeholder.trim() === field) {
564                         exactMatches.push(match);
565                     } else {
566                         anyMatches.push(match);
567                     }
568                 });
570         // Select the resulting match.
571         var found = null;
572         do {
573             // If there is an exact text match, use that (regardless of other matches).
574             if (exactMatches.length > 1) {
575                 return 'ERROR: Too many exact placeholder matches for text';
576             } else if (exactMatches.length) {
577                 found = exactMatches[0];
578                 break;
579             }
581             // If there is one partial text match, use that.
582             if (anyMatches.length > 1) {
583                 return 'ERROR: Too many partial placeholder matches for text';
584             } else if (anyMatches.length) {
585                 found = anyMatches[0];
586                 break;
587             }
588         } while (false);
590         if (!found) {
591             return 'ERROR: No matches for text';
592         }
594         // Functions to get/set value depending on field type.
595         var setValue;
596         var getValue;
597         switch (found.nodeName) {
598             case 'INPUT':
599             case 'TEXTAREA':
600                 setValue = function(text) {
601                     found.value = text;
602                 };
603                 getValue = function() {
604                     return found.value;
605                 };
606                 break;
607             case 'DIV':
608                 setValue = function(text) {
609                     found.innerHTML = text;
610                 };
611                 getValue = function() {
612                     return found.innerHTML;
613                 };
614                 break;
615         }
617         // Pretend we have cut and pasted the new text.
618         var event;
619         if (getValue() !== '') {
620             event = new InputEvent('input', {bubbles: true, view: window, cancelable: true,
621                 inputType: 'devareByCut'});
622             setTimeout(function() {
623                 setValue('');
624                 found.dispatchEvent(event);
625             }, 0);
626         }
627         if (value !== '') {
628             event = new InputEvent('input', {bubbles: true, view: window, cancelable: true,
629                 inputType: 'insertFromPaste', data: value});
630             setTimeout(function() {
631                 setValue(value);
632                 found.dispatchEvent(event);
633             }, 0);
634         }
636         return 'OK';
637     };
638 })();