2
0
Fork 0

MOBILE-4061 behat: Use angular zone and improve blocking system

main
Pau Ferrer Ocaña 2022-05-09 18:00:30 +02:00
parent a94da4d804
commit 136fc96711
4 changed files with 203 additions and 147 deletions

View File

@ -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$/ * @Given /^I enter the app$/
* @throws DriverException Issue with configuration or feature file * @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 "(.+)"$/ * @Given /^I enter(ed)? the app as "(.+)"$/
* @throws DriverException Issue with configuration or feature file * @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)?$/ * @Given /^I launch the app( runtime)?$/
* @throws DriverException Issue with configuration or feature file * @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->evaluate_script("window.behat.getAngularInstance('ion-content', 'CoreSwipeNavigationDirective').$method()");
$this->wait_for_pending_js();
// Wait swipe animation to finish. // Wait swipe animation to finish.
$this->getSession()->wait(300); $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 * @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); }, false, 60);
try { try {
@ -529,7 +531,7 @@ class behat_app extends behat_base {
$this->execute_script('window.behatInit(' . json_encode($initOptions) . ');'); $this->execute_script('window.behatInit(' . json_encode($initOptions) . ');');
} catch (Exception $error) { } 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) { if ($restart) {
@ -548,7 +550,7 @@ class behat_app extends behat_base {
return true; return true;
} }
throw new DriverException('Moodle app not launched properly'); throw new DriverException('Moodle App not launched properly');
}, false, 60); }, false, 60);
} }
@ -591,7 +593,7 @@ class behat_app extends behat_base {
if ($mainmenu) { if ($mainmenu) {
return 'mainpage'; 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); }, false, 30);
// Wait for JS to finish as well. // Wait for JS to finish as well.
@ -700,7 +702,7 @@ class behat_app extends behat_base {
* @param TableNode $data * @param TableNode $data
*/ */
public function i_open_a_custom_link(TableNode $data) { public function i_open_a_custom_link(TableNode $data) {
global $DB, $CFG; global $DB;
$data = $data->getColumnsHash()[0]; $data = $data->getColumnsHash()[0];
$title = array_keys($data)[0]; $title = array_keys($data)[0];
@ -713,10 +715,33 @@ class behat_app extends behat_base {
break; break;
case 'assign':
case 'bigbluebuttonbn':
case 'book':
case 'chat':
case 'choice':
case 'data':
case 'feedback':
case 'folder':
case 'forum': case 'forum':
$forumdata = $DB->get_record('forum', ['name' => $data->forum]); case 'glossary':
$cm = get_coursemodule_from_instance('forum', $forumdata->id); case 'h5pactivity':
$pageurl = "/mod/forum/view.php?id={$cm->id}"; 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; break;
default: 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) { public function the_app_should_have_opened_a_browser_tab(bool $not = false, ?string $urlpattern = null) {
$this->spin(function() use ($not, $urlpattern) { $this->spin(function() use ($not, $urlpattern) {
$windownames = $this->getSession()->getWindowNames(); $windowNames = $this->getSession()->getWindowNames();
$openedbrowsertab = count($windownames) === 2; $openedbrowsertab = count($windowNames) === 2;
if ((!$not && !$openedbrowsertab) || ($not && $openedbrowsertab && is_null($urlpattern))) { if ((!$not && !$openedbrowsertab) || ($not && $openedbrowsertab && is_null($urlpattern))) {
throw new ExpectationException( throw new ExpectationException(
@ -926,10 +951,10 @@ class behat_app extends behat_base {
} }
if (!is_null($urlpattern)) { if (!is_null($urlpattern)) {
$this->getSession()->switchToWindow($windownames[1]); $this->getSession()->switchToWindow($windowNames[1]);
$windowurl = $this->getSession()->getCurrentUrl(); $windowurl = $this->getSession()->getCurrentUrl();
$windowhaspattern = preg_match("/$urlpattern/", $windowurl); $windowhaspattern = preg_match("/$urlpattern/", $windowurl);
$this->getSession()->switchToWindow($windownames[0]); $this->getSession()->switchToWindow($windowNames[0]);
if ($not === $windowhaspattern) { if ($not === $windowhaspattern) {
throw new ExpectationException( throw new ExpectationException(
@ -954,11 +979,11 @@ class behat_app extends behat_base {
* @throws DriverException If there aren't exactly 2 tabs open * @throws DriverException If there aren't exactly 2 tabs open
*/ */
public function i_switch_to_the_browser_tab_opened_by_the_app() { public function i_switch_to_the_browser_tab_opened_by_the_app() {
$names = $this->getSession()->getWindowNames(); $windowNames = $this->getSession()->getWindowNames();
if (count($names) !== 2) { if (count($windowNames) !== 2) {
throw new DriverException('Expected to see 2 tabs open, not ' . count($names)); 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) new ExpectationException('Forced cron tasks in the app took too long to complete', $session)
); );
// Trigger Angular change detection // Trigger Angular change detection.
$session->executeScript('ngZone.run(() => {});'); $this->trigger_angular_change_detection();
} }
/** /**
@ -998,7 +1023,7 @@ class behat_app extends behat_base {
$this->spin( $this->spin(
function() use ($session) { function() use ($session) {
$session->executeScript('ngZone.run(() => {});'); $this->trigger_angular_change_detection();
$nodes = $this->find_all('css', 'core-loading ion-spinner'); $nodes = $this->find_all('css', 'core-loading ion-spinner');
@ -1169,8 +1194,16 @@ class behat_app extends behat_base {
return $result; 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 $username Of the user that needs to be logged in.
* @param string $path To redirect the user. * @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 $path To navigate.
* @param string $successXPath The XPath of the element to lookat after navigation. * @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 $customurl To navigate.
* @param string $successXPath The XPath of the element to lookat after navigation. * @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. // Instead of using evaluate_async_script, we wait for the path to load.
$this->evaluate_script("return window.behat.handleCustomURL('$customurl')"); $this->evaluate_script("return window.behat.handleCustomURL('$customurl')");
$this->wait_for_pending_js();
if (!empty($successXPath)) { if (!empty($successXPath)) {
// Wait until the page appears. // Wait until the page appears.
$this->spin( $this->spin(

View File

@ -13,7 +13,8 @@
// limitations under the License. // limitations under the License.
import { Injectable } from '@angular/core'; 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'; import { BehatTestsWindow, TestsBehatRuntime } from './behat-runtime';
/** /**
@ -26,6 +27,7 @@ export class TestsBehatBlockingService {
protected recentMutation = false; protected recentMutation = false;
protected lastMutation = 0; protected lastMutation = 0;
protected initialized = false; protected initialized = false;
protected keyIndex = 0;
/** /**
* Listen to mutations and override XML Requests. * Listen to mutations and override XML Requests.
@ -74,16 +76,23 @@ export class TestsBehatBlockingService {
/** /**
* Adds a pending key to the array. * 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. // Add a special DELAY entry whenever another entry is added.
if (this.pendingList.length === 0) { if (this.pendingList.length === 0) {
this.pendingList.push('DELAY'); this.pendingList.push('DELAY');
} }
if (!key) {
key = 'generated-' + this.keyIndex;
this.keyIndex++;
}
this.pendingList.push(key); this.pendingList.push(key);
TestsBehatRuntime.log('PENDING+: ' + this.pendingList); TestsBehatRuntime.log('PENDING+: ' + this.pendingList);
return key;
} }
/** /**
@ -92,7 +101,7 @@ export class TestsBehatBlockingService {
* *
* @param key Key to remove * @param key Key to remove
*/ */
unblock(key: string): void { async unblock(key: string): Promise<void> {
// Remove the key immediately. // Remove the key immediately.
this.pendingList = this.pendingList.filter((x) => x !== key); 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 the only thing left is DELAY, then remove that as well, later...
if (this.pendingList.length === 1) { if (this.pendingList.length === 1) {
this.runAfterEverything(() => { 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);
}
// Check there isn't a spinner... // Check there isn't a spinner...
this.checkUIBlocked(); await this.checkUIBlocked();
// Only remove it if the pending array is STILL empty after all that. // Only remove it if the pending array is STILL empty after all that.
if (this.pendingList.length === 1) { if (this.pendingList.length === 1) {
this.pendingList = []; this.pendingList = [];
TestsBehatRuntime.log('PENDING-: ' + 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 { async delay(): Promise<void> {
this.block('...'); const key = this.block('forced-delay');
this.unblock('...'); this.unblock(key);
}
/**
* 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);
} }
/** /**
@ -192,8 +190,9 @@ export class TestsBehatBlockingService {
* Checks if a loading spinner is present and visible; if so, adds it to the pending array * Checks if a loading spinner is present and visible; if so, adds it to the pending array
* (and if not, removes it). * (and if not, removes it).
*/ */
protected checkUIBlocked(): void { protected async checkUIBlocked(): Promise<void> {
const blocked = document.querySelector<HTMLElement>('span.core-loading-spinner, ion-loading, .click-block-active'); await CoreUtils.nextTick();
const blocked = document.querySelector<HTMLElement>('div.core-loading-container, ion-loading, .click-block-active');
if (blocked?.offsetParent) { if (blocked?.offsetParent) {
if (!this.waitingBlocked) { if (!this.waitingBlocked) {
@ -216,6 +215,7 @@ export class TestsBehatBlockingService {
let requestIndex = 0; let requestIndex = 0;
XMLHttpRequest.prototype.open = function(...args) { XMLHttpRequest.prototype.open = function(...args) {
NgZone.run(() => {
const index = requestIndex++; const index = requestIndex++;
const key = 'httprequest-' + index; const key = 'httprequest-' + index;
@ -233,6 +233,7 @@ export class TestsBehatBlockingService {
TestsBehatBlocking.unblock(key); TestsBehatBlocking.unblock(key);
throw error; throw error;
} }
});
}; };
} }

View File

@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { CoreUtils } from '@services/utils/utils';
import { NgZone } from '@singletons';
import { TestsBehatBlocking } from './behat-blocking'; import { TestsBehatBlocking } from './behat-blocking';
import { TestBehatElementLocator } from './behat-runtime'; 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. * Make sure that an element is visible and wait to trigger the callback.
* *
* @param element Element. * @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<DOMRect> {
const initialRect = element.getBoundingClientRect(); const initialRect = element.getBoundingClientRect();
element.scrollIntoView(false); element.scrollIntoView(false);
return new Promise<DOMRect>((resolve): void => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
if (initialRect.y !== rect.y) { if (initialRect.y !== rect.y) {
setTimeout(() => { setTimeout(() => {
callback(rect); resolve(rect);
}, 300); }, 300);
TestsBehatBlocking.delay();
return; return;
} }
callback(rect); resolve(rect);
});
}); });
TestsBehatBlocking.delay();
}; };
/** /**
@ -440,16 +439,9 @@ export class TestsBehatDomUtils {
* *
* @param element Element to press. * @param element Element to press.
*/ */
static pressElement(element: HTMLElement): void { static async pressElement(element: HTMLElement): Promise<void> {
this.ensureElementVisible(element, (rect) => { NgZone.run(async () => {
// Simulate a mouse click on the button. const blockKey = TestsBehatBlocking.block();
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 // Events don't bubble up across Shadow DOM boundaries, and some buttons
// may not work without doing this. // may not work without doing this.
@ -459,6 +451,17 @@ export class TestsBehatDomUtils {
element = parentElement; 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 // 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 // buttons using the core-supress-events directive. That's why we need to send both
// click and mouse events. // click and mouse events.
@ -467,10 +470,65 @@ export class TestsBehatDomUtils {
setTimeout(() => { setTimeout(() => {
element.dispatchEvent(new MouseEvent('mouseup', eventOptions)); element.dispatchEvent(new MouseEvent('mouseup', eventOptions));
element.click(); element.click();
}, 300);
// Mark busy until the button click finishes processing. TestsBehatBlocking.unblock(blockKey);
TestsBehatBlocking.delay(); }, 300);
});
}
/**
* Set an element value.
*
* @param element HTML to set.
* @param value Value to be set.
*/
static async setElementValue(element: HTMLElement, value: string): Promise<void> {
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);
}); });
} }

View File

@ -69,12 +69,16 @@ export class TestsBehatRuntime {
* @return OK if successful, or ERROR: followed by message. * @return OK if successful, or ERROR: followed by message.
*/ */
static async handleCustomURL(url: string): Promise<string> { static async handleCustomURL(url: string): Promise<string> {
const blockKey = TestsBehatBlocking.block();
try { try {
await CoreCustomURLSchemes.handleCustomURL(url); await CoreCustomURLSchemes.handleCustomURL(url);
return 'OK'; return 'OK';
} catch (error) { } catch (error) {
return 'ERROR: ' + error.message; return 'ERROR: ' + error.message;
} finally {
TestsBehatBlocking.unblock(blockKey);
} }
} }
@ -330,49 +334,7 @@ export class TestsBehatRuntime {
return 'ERROR: No element matches field to set.'; return 'ERROR: No element matches field to set.';
} }
// Functions to get/set value depending on field type. TestsBehatDomUtils.setElementValue(found, value);
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'; return 'OK';
} }