// (C) Copyright 2015 Moodle Pty Ltd. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { Injectable } from '@angular/core'; import { makeSingleton } from '@singletons'; import { BehatTestsWindow, TestsBehatRuntime } from './behat-runtime'; /** * Behat block JS manager. */ @Injectable({ providedIn: 'root' }) export class TestsBehatBlockingService { protected waitingBlocked = false; protected recentMutation = false; protected lastMutation = 0; protected initialized = false; /** * Listen to mutations and override XML Requests. */ init(): void { if (this.initialized) { return; } this.initialized = true; this.listenToMutations(); this.xmlRequestOverride(); const win = window as BehatTestsWindow; // Set up the M object - only pending_js is implemented. win.M = win.M ?? {}; win.M.util = win.M.util ?? {}; win.M.util.pending_js = win.M.util.pending_js ?? []; TestsBehatRuntime.log('Initialized!'); } /** * Get pending list on window M object. */ protected get pendingList(): string[] { const win = window as BehatTestsWindow; return win.M?.util?.pending_js || []; } /** * Set pending list on window M object. */ protected set pendingList(values: string[]) { const win = window as BehatTestsWindow; if (!win.M?.util?.pending_js) { return; } win.M.util.pending_js = values; } /** * Adds a pending key to the array. * * @param key Key to add. */ block(key: string): void { // Add a special DELAY entry whenever another entry is added. if (this.pendingList.length === 0) { this.pendingList.push('DELAY'); } this.pendingList.push(key); TestsBehatRuntime.log('PENDING+: ' + this.pendingList); } /** * Removes a pending key from the array. If this would clear the array, the actual clear only * takes effect after the queued events are finished. * * @param key Key to remove */ unblock(key: string): void { // Remove the key immediately. this.pendingList = this.pendingList.filter((x) => x !== key); TestsBehatRuntime.log('PENDING-: ' + this.pendingList); // 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(); // 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. */ 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); } /** * It would be really beautiful if you could detect CSS transitions and animations, that would * cover almost everything, but sadly there is no way to do this because the transitionstart * and animationcancel events are not implemented in Chrome, so we cannot detect either of * these reliably. Instead, we have to look for any DOM changes and do horrible polling. Most * of the animations are set to 500ms so we allow it to continue from 500ms after any DOM * change. */ protected listenToMutations(): void { // Set listener using the mutation callback. const observer = new MutationObserver(() => { this.lastMutation = Date.now(); if (!this.recentMutation) { this.recentMutation = true; this.block('dom-mutation'); setTimeout(() => { this.pollRecentMutation(); }, 500); } // Also update the spinner presence if needed. this.checkUIBlocked(); }); observer.observe(document, { attributes: true, childList: true, subtree: true }); } /** * Called from the mutation callback to remove the pending tag after 500ms if nothing else * gets mutated. * * This will be called after 500ms, then every 100ms until there have been no mutation events * for 500ms. */ protected pollRecentMutation(): void { if (Date.now() - this.lastMutation > 500) { this.recentMutation = false; this.unblock('dom-mutation'); return; } setTimeout(() => { this.pollRecentMutation(); }, 100); } /** * 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'); if (blocked?.offsetParent) { if (!this.waitingBlocked) { this.block('blocked'); this.waitingBlocked = true; } } else { if (this.waitingBlocked) { this.unblock('blocked'); this.waitingBlocked = false; } } } /** * Override XMLHttpRequest to mark things pending while there is a request waiting. */ protected xmlRequestOverride(): void { const realOpen = XMLHttpRequest.prototype.open; let requestIndex = 0; XMLHttpRequest.prototype.open = function(...args) { const index = requestIndex++; const key = 'httprequest-' + index; try { // Add to the list of pending requests. TestsBehatBlocking.block(key); // 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); throw error; } }; } } export const TestsBehatBlocking = makeSingleton(TestsBehatBlockingService);