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$/
* @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(

View File

@ -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<void> {
// 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<void> {
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<HTMLElement>('span.core-loading-spinner, ion-loading, .click-block-active');
protected async checkUIBlocked(): Promise<void> {
await CoreUtils.nextTick();
const blocked = document.querySelector<HTMLElement>('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;
}
});
};
}

View File

@ -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<DOMRect> {
const initialRect = element.getBoundingClientRect();
element.scrollIntoView(false);
requestAnimationFrame(() => {
const rect = element.getBoundingClientRect();
return new Promise<DOMRect>((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<void> {
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<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.
*/
static async handleCustomURL(url: string): Promise<string> {
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';
}