diff --git a/local-moodleappbehat/tests/behat/behat_app.php b/local-moodleappbehat/tests/behat/behat_app.php index 2ff4b7890..8b648c99f 100644 --- a/local-moodleappbehat/tests/behat/behat_app.php +++ b/local-moodleappbehat/tests/behat/behat_app.php @@ -113,7 +113,7 @@ class behat_app extends behat_base { } /** - * Opens the Moodle app in the browser and introduces the enters the site. + * Opens the Moodle App in the browser and introduces the enters the site. * * @Given /^I enter the app$/ * @throws DriverException Issue with configuration or feature file @@ -126,7 +126,7 @@ class behat_app extends behat_base { } /** - * Opens the Moodle app in the browser logged in as a user. + * Opens the Moodle App in the browser logged in as a user. * * @Given /^I enter(ed)? the app as "(.+)"$/ * @throws DriverException Issue with configuration or feature file @@ -140,7 +140,7 @@ class behat_app extends behat_base { } /** - * Opens the Moodle app in the browser. + * Opens the Moodle App in the browser. * * @Given /^I launch the app( runtime)?$/ * @throws DriverException Issue with configuration or feature file @@ -269,6 +269,8 @@ class behat_app extends behat_base { $this->evaluate_script("window.behat.getAngularInstance('ion-content', 'CoreSwipeNavigationDirective').$method()"); + $this->wait_for_pending_js(); + // Wait swipe animation to finish. $this->getSession()->wait(300); } @@ -327,7 +329,7 @@ class behat_app extends behat_base { } /** - * Fixes the Moodle admin settings to allow mobile app use (if not already correct). + * Fixes the Moodle admin settings to allow Moodle App use (if not already correct). * * @throws dml_exception If there is any problem changing Moodle settings */ @@ -517,7 +519,7 @@ class behat_app extends behat_base { } } - throw new DriverException('Moodle app not found in browser'); + throw new DriverException('Moodle App not found in browser'); }, false, 60); try { @@ -529,7 +531,7 @@ class behat_app extends behat_base { $this->execute_script('window.behatInit(' . json_encode($initOptions) . ');'); } catch (Exception $error) { - throw new DriverException('Moodle app not running or not running on Automated mode.'); + throw new DriverException('Moodle App not running or not running on Automated mode.'); } if ($restart) { @@ -548,7 +550,7 @@ class behat_app extends behat_base { return true; } - throw new DriverException('Moodle app not launched properly'); + throw new DriverException('Moodle App not launched properly'); }, false, 60); } @@ -591,7 +593,7 @@ class behat_app extends behat_base { if ($mainmenu) { return 'mainpage'; } - throw new DriverException('Moodle app main page not loaded after login'); + throw new DriverException('Moodle App main page not loaded after login'); }, false, 30); // Wait for JS to finish as well. @@ -700,7 +702,7 @@ class behat_app extends behat_base { * @param TableNode $data */ public function i_open_a_custom_link(TableNode $data) { - global $DB, $CFG; + global $DB; $data = $data->getColumnsHash()[0]; $title = array_keys($data)[0]; @@ -713,10 +715,33 @@ class behat_app extends behat_base { break; + case 'assign': + case 'bigbluebuttonbn': + case 'book': + case 'chat': + case 'choice': + case 'data': + case 'feedback': + case 'folder': case 'forum': - $forumdata = $DB->get_record('forum', ['name' => $data->forum]); - $cm = get_coursemodule_from_instance('forum', $forumdata->id); - $pageurl = "/mod/forum/view.php?id={$cm->id}"; + case 'glossary': + case 'h5pactivity': + case 'imscp': + case 'label': + case 'lesson': + case 'lti': + case 'page': + case 'quiz': + case 'resource': + case 'scorm': + case 'survey': + case 'url': + case 'wiki': + case 'workshop': + $name = $data->$title; + $module = $DB->get_record($title, ['name' => $name]); + $cm = get_coursemodule_from_instance($title, $module->id); + $pageurl = "/mod/$title/view.php?id={$cm->id}"; break; default: @@ -913,8 +938,8 @@ class behat_app extends behat_base { */ public function the_app_should_have_opened_a_browser_tab(bool $not = false, ?string $urlpattern = null) { $this->spin(function() use ($not, $urlpattern) { - $windownames = $this->getSession()->getWindowNames(); - $openedbrowsertab = count($windownames) === 2; + $windowNames = $this->getSession()->getWindowNames(); + $openedbrowsertab = count($windowNames) === 2; if ((!$not && !$openedbrowsertab) || ($not && $openedbrowsertab && is_null($urlpattern))) { throw new ExpectationException( @@ -926,10 +951,10 @@ class behat_app extends behat_base { } if (!is_null($urlpattern)) { - $this->getSession()->switchToWindow($windownames[1]); + $this->getSession()->switchToWindow($windowNames[1]); $windowurl = $this->getSession()->getCurrentUrl(); $windowhaspattern = preg_match("/$urlpattern/", $windowurl); - $this->getSession()->switchToWindow($windownames[0]); + $this->getSession()->switchToWindow($windowNames[0]); if ($not === $windowhaspattern) { throw new ExpectationException( @@ -954,11 +979,11 @@ class behat_app extends behat_base { * @throws DriverException If there aren't exactly 2 tabs open */ public function i_switch_to_the_browser_tab_opened_by_the_app() { - $names = $this->getSession()->getWindowNames(); - if (count($names) !== 2) { - throw new DriverException('Expected to see 2 tabs open, not ' . count($names)); + $windowNames = $this->getSession()->getWindowNames(); + if (count($windowNames) !== 2) { + throw new DriverException('Expected to see 2 tabs open, not ' . count($windowNames)); } - $this->getSession()->switchToWindow($names[1]); + $this->getSession()->switchToWindow($windowNames[1]); } /** @@ -984,8 +1009,8 @@ class behat_app extends behat_base { new ExpectationException('Forced cron tasks in the app took too long to complete', $session) ); - // Trigger Angular change detection - $session->executeScript('ngZone.run(() => {});'); + // Trigger Angular change detection. + $this->trigger_angular_change_detection(); } /** @@ -998,7 +1023,7 @@ class behat_app extends behat_base { $this->spin( function() use ($session) { - $session->executeScript('ngZone.run(() => {});'); + $this->trigger_angular_change_detection(); $nodes = $this->find_all('css', 'core-loading ion-spinner'); @@ -1169,8 +1194,16 @@ class behat_app extends behat_base { return $result; } + /** - * Opens a custom URL for automatic login and redirect from the mobile app (and waits to finish.) + * Trigger Angular change detection. + */ + private function trigger_angular_change_detection() { + $this->getSession()->executeScript('ngZone.run(() => {});'); + } + + /** + * Opens a custom URL for automatic login and redirect from the Moodle App (and waits to finish.) * * @param string $username Of the user that needs to be logged in. * @param string $path To redirect the user. @@ -1221,7 +1254,7 @@ class behat_app extends behat_base { } /** - * Opens a custom URL on the mobile app (and waits to finish.) + * Opens a custom URL on the Moodle App (and waits to finish.) * * @param string $path To navigate. * @param string $successXPath The XPath of the element to lookat after navigation. @@ -1236,7 +1269,7 @@ class behat_app extends behat_base { } /** - * Handles the custom URL on the mobile app (and waits to finish.) + * Handles the custom URL on the Moodle App (and waits to finish.) * * @param string $customurl To navigate. * @param string $successXPath The XPath of the element to lookat after navigation. @@ -1245,6 +1278,8 @@ class behat_app extends behat_base { // Instead of using evaluate_async_script, we wait for the path to load. $this->evaluate_script("return window.behat.handleCustomURL('$customurl')"); + $this->wait_for_pending_js(); + if (!empty($successXPath)) { // Wait until the page appears. $this->spin( diff --git a/src/testing/services/behat-blocking.ts b/src/testing/services/behat-blocking.ts index dbffa5a16..913ba00ab 100644 --- a/src/testing/services/behat-blocking.ts +++ b/src/testing/services/behat-blocking.ts @@ -13,7 +13,8 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { makeSingleton } from '@singletons'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, NgZone } from '@singletons'; import { BehatTestsWindow, TestsBehatRuntime } from './behat-runtime'; /** @@ -26,6 +27,7 @@ export class TestsBehatBlockingService { protected recentMutation = false; protected lastMutation = 0; protected initialized = false; + protected keyIndex = 0; /** * Listen to mutations and override XML Requests. @@ -74,16 +76,23 @@ export class TestsBehatBlockingService { /** * Adds a pending key to the array. * - * @param key Key to add. + * @param key Key to add. It will be generated if none. + * @return Key name. */ - block(key: string): void { + block(key = ''): string { // Add a special DELAY entry whenever another entry is added. if (this.pendingList.length === 0) { this.pendingList.push('DELAY'); } + if (!key) { + key = 'generated-' + this.keyIndex; + this.keyIndex++; + } this.pendingList.push(key); TestsBehatRuntime.log('PENDING+: ' + this.pendingList); + + return key; } /** @@ -92,7 +101,7 @@ export class TestsBehatBlockingService { * * @param key Key to remove */ - unblock(key: string): void { + async unblock(key: string): Promise { // Remove the key immediately. this.pendingList = this.pendingList.filter((x) => x !== key); @@ -100,43 +109,32 @@ export class TestsBehatBlockingService { // 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(); + if (!document.hidden) { + // When tab is not active, ticks should be slower and may do Behat to fail. + // From Timers API: + // https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers + // "This API does not guarantee that timers will run exactly on schedule. + // Delays due to CPU load, other tasks, etc, are to be expected." + await CoreUtils.nextTicks(10); + } - // 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); - } - }); + // Check there isn't a spinner... + await 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. + * Adds a pending key to the array, but removes it after some ticks. */ - 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); + async delay(): Promise { + const key = this.block('forced-delay'); + this.unblock(key); } /** @@ -192,8 +190,9 @@ export class TestsBehatBlockingService { * 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('span.core-loading-spinner, ion-loading, .click-block-active'); + protected async checkUIBlocked(): Promise { + await CoreUtils.nextTick(); + const blocked = document.querySelector('div.core-loading-container, ion-loading, .click-block-active'); if (blocked?.offsetParent) { if (!this.waitingBlocked) { @@ -216,23 +215,25 @@ export class TestsBehatBlockingService { let requestIndex = 0; XMLHttpRequest.prototype.open = function(...args) { - const index = requestIndex++; - const key = 'httprequest-' + index; + NgZone.run(() => { + const index = requestIndex++; + const key = 'httprequest-' + index; - try { + try { // Add to the list of pending requests. - TestsBehatBlocking.block(key); + TestsBehatBlocking.block(key); - // Detect when it finishes and remove it from the list. - this.addEventListener('loadend', () => { + // 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); - }); - - return realOpen.apply(this, args); - } catch (error) { - TestsBehatBlocking.unblock(key); - throw error; - } + throw error; + } + }); }; } diff --git a/src/testing/services/behat-dom.ts b/src/testing/services/behat-dom.ts index eff06ab61..761806d69 100644 --- a/src/testing/services/behat-dom.ts +++ b/src/testing/services/behat-dom.ts @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { CoreUtils } from '@services/utils/utils'; +import { NgZone } from '@singletons'; import { TestsBehatBlocking } from './behat-blocking'; import { TestBehatElementLocator } from './behat-runtime'; @@ -409,30 +411,27 @@ export class TestsBehatDomUtils { * 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 { + protected static async ensureElementVisible(element: HTMLElement): Promise { const initialRect = element.getBoundingClientRect(); element.scrollIntoView(false); - requestAnimationFrame(() => { - const rect = element.getBoundingClientRect(); + return new Promise((resolve): void => { + requestAnimationFrame(() => { + const rect = element.getBoundingClientRect(); - if (initialRect.y !== rect.y) { - setTimeout(() => { - callback(rect); - }, 300); + if (initialRect.y !== rect.y) { + setTimeout(() => { + resolve(rect); + }, 300); - TestsBehatBlocking.delay(); + return; + } - return; - } - - callback(rect); + resolve(rect); + }); }); - - TestsBehatBlocking.delay(); }; /** @@ -440,16 +439,9 @@ export class TestsBehatDomUtils { * * @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, - }; + static async pressElement(element: HTMLElement): Promise { + NgZone.run(async () => { + const blockKey = TestsBehatBlocking.block(); // Events don't bubble up across Shadow DOM boundaries, and some buttons // may not work without doing this. @@ -459,6 +451,17 @@ export class TestsBehatDomUtils { element = parentElement; } + const rect = await this.ensureElementVisible(element); + + // Simulate a mouse click on the button. + const eventOptions: MouseEventInit = { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + bubbles: true, + view: window, + cancelable: true, + }; + // 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. @@ -467,10 +470,65 @@ export class TestsBehatDomUtils { setTimeout(() => { element.dispatchEvent(new MouseEvent('mouseup', eventOptions)); element.click(); - }, 300); - // Mark busy until the button click finishes processing. - TestsBehatBlocking.delay(); + TestsBehatBlocking.unblock(blockKey); + }, 300); + }); + } + + /** + * Set an element value. + * + * @param element HTML to set. + * @param value Value to be set. + */ + static async setElementValue(element: HTMLElement, value: string): Promise { + NgZone.run(async () => { + const blockKey = TestsBehatBlocking.block(); + + // Functions to get/set value depending on field type. + let setValue = (text: string) => { + element.innerHTML = text; + }; + let getValue = () => element.innerHTML; + + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + setValue = (text: string) => { + element.value = text; + }; + getValue = () => element.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', + }); + + await CoreUtils.nextTick(); + setValue(''); + element.dispatchEvent(event); + } + + if (value !== '') { + event = new InputEvent('input', { + bubbles: true, + view: window, + cancelable: true, + inputType: 'insertFromPaste', + data: value, + }); + + await CoreUtils.nextTick(); + setValue(value); + element.dispatchEvent(event); + } + + TestsBehatBlocking.unblock(blockKey); }); } diff --git a/src/testing/services/behat-runtime.ts b/src/testing/services/behat-runtime.ts index 25fb88f35..97ba8a313 100644 --- a/src/testing/services/behat-runtime.ts +++ b/src/testing/services/behat-runtime.ts @@ -69,12 +69,16 @@ export class TestsBehatRuntime { * @return OK if successful, or ERROR: followed by message. */ static async handleCustomURL(url: string): Promise { + const blockKey = TestsBehatBlocking.block(); + try { await CoreCustomURLSchemes.handleCustomURL(url); return 'OK'; } catch (error) { return 'ERROR: ' + error.message; + } finally { + TestsBehatBlocking.unblock(blockKey); } } @@ -330,49 +334,7 @@ export class TestsBehatRuntime { 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); - } + TestsBehatDomUtils.setElementValue(found, value); return 'OK'; }