MDL-63977 Behat: Organise app functions in window.behat object
[moodle.git] / lib / tests / behat / app_behat_runtime.js
CommitLineData
1959e164 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
7
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 };
22
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 };
42
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);
54
55 log('PENDING+: ' + window.M.util.pending_js);
56 };
57
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);
70
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();
76
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 };
85
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 };
93
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;
100
101 // Add to the list of pending requests.
102 addPending(key);
103
104 // Detect when it finishes and remove it from the list.
105 this.addEventListener('loadend', function() {
106 removePending(key);
107 });
108
109 return realOpen.apply(this, arguments);
110 };
111
112 var waitingSpinner = false;
113
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 };
132
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.
139
140 var recentMutation = false;
141 var lastMutation;
142
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 };
158
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 };
172
173 // Set listener using the mutation callback.
174 var observer = new MutationObserver(mutationCallback);
175 observer.observe(document, {attributes: true, childList: true, subtree: true});
176
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 }
195
196 process(match);
197 }
198 };
199
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();
220
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 }
230
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 });
238
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 });
251
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();
263
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 });
271
272 var nearFound = null;
273
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 }
280
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 }
289
290 if (!nearFound) {
291 throw new Error('No matches for near text');
292 }
293
294 while (nearFound) {
295 nearAncestors.push(nearFound);
296 nearFound = nearFound.parentNode;
297 }
298
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) {
d8218a3f 309 var ancestorDepth = nearAncestors.indexOf(node);
310 if (ancestorDepth !== -1) {
311 return depth + ancestorDepth;
1959e164 312 }
313 node = node.parentNode;
314 depth++;
315 }
316 return Number.MAX_SAFE_INTEGER;
317 };
318
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 };
334
335 // Filter all the category arrays.
336 exactMatches = filterNonNearest(exactMatches);
337 exactLabelMatches = filterNonNearest(exactLabelMatches);
338 anyMatches = filterNonNearest(anyMatches);
339 anyLabelMatches = filterNonNearest(anyLabelMatches);
340 }
341
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 }
353
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 }
361
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 }
369
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);
378
379 if (!found) {
380 throw new Error('No matches for text');
381 }
382
383 return found;
384 };
385
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 */
d178865b 392 var behatPressStandard = function(button) {
1959e164 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' :
e0564a32 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]';
1959e164 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();
429
430 // Mark busy until the button click finishes processing.
431 addPendingDelay();
432
433 return 'OK';
434 };
435
436 /**
437 * When there is a popup, clicks on the backdrop.
438 *
439 * @return {string} OK if successful, or ERROR: followed by message
440 */
d178865b 441 var behatClosePopup = function() {
1959e164 442 log('Action - Close popup');
443
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();
463
464 // Mark busy until the click finishes processing.
465 addPendingDelay();
466
467 return 'OK';
468 };
469
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 */
d178865b 477 var behatPress = function(text, near) {
1959e164 478 log('Action - Press ' + text + (near === undefined ? '' : ' - near ' + near));
479
480 var found;
481 try {
482 found = findElementBasedOnText(text, near);
483 } catch (error) {
484 return 'ERROR: ' + error.message;
485 }
486
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);
501
502 // Mark busy until the button click finishes processing.
503 addPendingDelay();
504
505 return 'OK';
506 };
507
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 */
d178865b 513 var behatGetHeader = function() {
1959e164 514 log('Action - Get header');
515
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 });
525
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 };
534
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 */
d178865b 544 var behatSetField = function(field, value) {
1959e164 545 log('Action - Set field ' + field + ' to: ' + value);
546
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 });
569
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 }
580
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);
589
590 if (!found) {
591 return 'ERROR: No matches for text';
592 }
593
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 }
616
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 }
635
636 return 'OK';
637 };
d178865b 638
639 // Make some functions publicly available for Behat to call.
640 window.behat = {
641 pressStandard : behatPressStandard,
642 closePopup : behatClosePopup,
643 press : behatPress,
644 setField : behatSetField,
645 getHeader : behatGetHeader,
646 };
1959e164 647})();