UNPKG

chrome-devtools-frontend

Version:
277 lines (255 loc) • 9.31 kB
// Copyright 2022 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. interface AsyncActivity { type: 'promise'|'requestAnimationFrame'|'setTimeout'|'setInterval'|'requestIdleCallback'; pending: boolean; cancelDelayed?: () => void; id?: string; runImmediate?: () => void; stack?: string; promise?: Promise<unknown>; } const asyncActivity: AsyncActivity[] = []; export function startTrackingAsyncActivity() { // We are tracking all asynchronous activity but let it run normally during // the test. stub('requestAnimationFrame', trackingRequestAnimationFrame); stub('setTimeout', trackingSetTimeout as unknown as typeof setTimeout); stub('setInterval', trackingSetInterval as unknown as typeof setInterval); stub('requestIdleCallback', trackingRequestIdleCallback); stub('cancelAnimationFrame', id => cancelTrackingActivity('a' + id)); stub('clearTimeout', id => cancelTrackingActivity('t' + id)); stub('clearInterval', id => cancelTrackingActivity('i' + id)); stub('cancelIdleCallback', id => cancelTrackingActivity('d' + id)); stub('Promise', TrackingPromise); } export async function checkForPendingActivity() { let stillPending: AsyncActivity[] = []; const wait = 5; let retries = 20; // We will perform multiple iteration of waiting and forced completions to see // if all promises are eventually resolved. while (retries > 0) { const pendingCount = asyncActivity.filter(a => a.pending).length; const totalCount = asyncActivity.length; try { // First we wait for the pending async activity to finish normally await original(Promise).all(asyncActivity.filter(a => a.pending).map(a => original(Promise).race([ a.promise, new (original(Promise))( (_, reject) => original(setTimeout)( () => { if (!a.pending) { return; } // If something is still pending after some time, we try to // force the completion by running timeout and animation frame // handlers if (a.cancelDelayed && a.runImmediate) { a.cancelDelayed(); a.runImmediate(); } else { reject(); } }, wait)), ]))); // If the above didn't throw, all the original pending activity has // completed, but it could have triggered more stillPending = asyncActivity.filter(a => a.pending); if (!stillPending.length) { break; } --retries; } catch { stillPending = asyncActivity.filter(a => a.pending); const newTotalCount = asyncActivity.length; // Something is still pending. It might get resolved by force completion // of new activity added during the iteration, so let's retry a couple of // times. if (newTotalCount === totalCount && stillPending.length === pendingCount) { --retries; } } } if (stillPending.length) { throw new Error( 'The test has completed, but there are still pending async operations\n' + stillPending.map(a => `Pending '${a.type}' created at: \n${a.stack}`).join('\n\n')); } } export function stopTrackingAsyncActivity() { asyncActivity.length = 0; restoreAll(); } function trackingRequestAnimationFrame(fn: FrameRequestCallback) { const activity: AsyncActivity = {type: 'requestAnimationFrame', pending: true, stack: getStack(new Error())}; let id = 0; activity.promise = new (original(Promise<void>))(resolve => { activity.runImmediate = () => { fn(performance.now()); activity.pending = false; resolve(); }; id = original(requestAnimationFrame)(activity.runImmediate); activity.id = 'a' + id; activity.cancelDelayed = () => { original(cancelAnimationFrame)(id); activity.pending = false; resolve(); }; }); asyncActivity.push(activity); return id; } function trackingRequestIdleCallback(fn: IdleRequestCallback, opts?: IdleRequestOptions): number { const activity: AsyncActivity = {type: 'requestIdleCallback', pending: true, stack: getStack(new Error())}; let id = 0; activity.promise = new (original(Promise<void>))(resolve => { activity.runImmediate = (idleDeadline?: IdleDeadline) => { fn(idleDeadline ?? {didTimeout: true, timeRemaining: () => 0} as IdleDeadline); activity.pending = false; resolve(); }; id = original(requestIdleCallback)(activity.runImmediate, opts); activity.id = 'd' + id; activity.cancelDelayed = () => { original(cancelIdleCallback)(id); activity.pending = false; resolve(); }; }); asyncActivity.push(activity); return id; } function trackingSetTimeout(arg: TimerHandler, time?: number, ...params: unknown[]) { const activity: AsyncActivity = {type: 'setTimeout', pending: true, stack: getStack(new Error())}; let id: ReturnType<typeof setTimeout>|undefined; activity.promise = new (original(Promise<void>))(resolve => { activity.runImmediate = () => { if (typeof (arg) === 'function') { arg(...params); } else { eval(arg); } activity.pending = false; resolve(); }; id = original(setTimeout)(activity.runImmediate, time); activity.id = 't' + id; activity.cancelDelayed = () => { original(clearTimeout)(id); activity.pending = false; resolve(); }; }); asyncActivity.push(activity); return id; } function trackingSetInterval(arg: TimerHandler, time?: number, ...params: unknown[]) { const activity: AsyncActivity = { type: 'setInterval', pending: true, stack: getStack(new Error()), }; let id = 0; activity.promise = new (original(Promise<void>))(resolve => { id = original(setInterval)(arg, time, ...params); activity.id = 'i' + id; activity.cancelDelayed = () => { original(clearInterval)(id); activity.pending = false; resolve(); }; }); asyncActivity.push(activity); return id; } function cancelTrackingActivity(id: string) { const activity = asyncActivity.find(a => a.id === id); if (activity?.cancelDelayed) { activity.cancelDelayed(); } } type UntrackedPromiseMethod = 'prototype'|typeof Symbol.species; /** * Extracted into separate object which will make TypeScript * check fail if new properties are added. */ const BasePromise: Omit<PromiseConstructor, UntrackedPromiseMethod> = { all: Promise.all, allSettled: Promise.allSettled, any: Promise.any, race: Promise.race, reject: Promise.reject, resolve: Promise.resolve, withResolvers: Promise.withResolvers, try: Promise.try, }; // We can't subclass native Promise here as this will cause all derived promises // (e.g. those returned by `then`) to also be subclass instances. This results // in a new asyncActivity entry on each iteration of checkForPendingActivity // which never settles. const TrackingPromise: PromiseConstructor = Object.assign( function<T>(arg: (resolve: (value: T|PromiseLike<T>) => void, reject: (reason?: unknown) => void) => void) { const originalPromiseType = original(Promise); const promise = new (originalPromiseType)(arg); const activity: AsyncActivity = { type: 'promise', promise, stack: getStack(new Error()), pending: false, }; promise.then = function<TResult1 = T, TResult2 = never>( onFullfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>)|undefined|null, onRejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>)|undefined| null): Promise<TResult1|TResult2> { activity.pending = true; return originalPromiseType.prototype.then.apply(this, [ result => { if (!onFullfilled) { return this; } activity.pending = false; return onFullfilled(result); }, result => { if (!onRejected) { return this; } activity.pending = false; return onRejected(result); }, ]) as Promise<TResult1|TResult2>; }; asyncActivity.push(activity); return promise; }, BasePromise as PromiseConstructor, ); function getStack(error: Error): string { return (error.stack ?? 'No stack').split('\n').slice(2).join('\n'); } // We can't use Sinon for stubbing as 1) we need to double wrap sometimes and 2) // we need to access original values. interface Stub<TKey extends keyof typeof window> { name: TKey; original: (typeof window)[TKey]; stubWith: (typeof window)[TKey]; } const stubs: Array<Stub<keyof typeof window>> = []; function stub<T extends keyof typeof window>(name: T, stubWith: (typeof window)[T]) { const original = window[name]; window[name] = stubWith; stubs.push({name, original, stubWith}); } function original<T>(stubWith: T): T { return stubs.find(s => s.stubWith === stubWith)?.original; } function restoreAll() { for (const {name, original} of stubs) { (window[name] as typeof original) = original; } stubs.length = 0; }