Source code
Revision control
Copy as Markdown
Other Tools
/**
* @license
* Copyright 2022 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {ElementHandle} from '../api/ElementHandle.js';
import type {JSHandle} from '../api/JSHandle.js';
import type {Realm} from '../api/Realm.js';
import type {Poller} from '../injected/Poller.js';
import {Deferred} from '../util/Deferred.js';
import {isErrorLike} from '../util/ErrorLike.js';
import {stringifyFunction} from '../util/Function.js';
import {TimeoutError} from './Errors.js';
import {LazyArg} from './LazyArg.js';
import type {HandleFor} from './types.js';
/**
* @internal
*/
export interface WaitTaskOptions {
polling: 'raf' | 'mutation' | number;
root?: ElementHandle<Node>;
timeout: number;
signal?: AbortSignal;
}
/**
* @internal
*/
export class WaitTask<T = unknown> {
#world: Realm;
#polling: 'raf' | 'mutation' | number;
#root?: ElementHandle<Node>;
#fn: string;
#args: unknown[];
#timeout?: NodeJS.Timeout;
#timeoutError?: TimeoutError;
#result = Deferred.create<HandleFor<T>>();
#poller?: JSHandle<Poller<T>>;
#signal?: AbortSignal;
#reruns: AbortController[] = [];
constructor(
world: Realm,
options: WaitTaskOptions,
fn: ((...args: unknown[]) => Promise<T>) | string,
...args: unknown[]
) {
this.#world = world;
this.#polling = options.polling;
this.#root = options.root;
this.#signal = options.signal;
this.#signal?.addEventListener(
'abort',
() => {
void this.terminate(this.#signal?.reason);
},
{
once: true,
}
);
switch (typeof fn) {
case 'string':
this.#fn = `() => {return (${fn});}`;
break;
default:
this.#fn = stringifyFunction(fn);
break;
}
this.#args = args;
this.#world.taskManager.add(this);
if (options.timeout) {
this.#timeoutError = new TimeoutError(
`Waiting failed: ${options.timeout}ms exceeded`
);
this.#timeout = setTimeout(() => {
void this.terminate(this.#timeoutError);
}, options.timeout);
}
void this.rerun();
}
get result(): Promise<HandleFor<T>> {
return this.#result.valueOrThrow();
}
async rerun(): Promise<void> {
for (const prev of this.#reruns) {
prev.abort();
}
this.#reruns.length = 0;
const controller = new AbortController();
this.#reruns.push(controller);
try {
switch (this.#polling) {
case 'raf':
this.#poller = await this.#world.evaluateHandle(
({RAFPoller, createFunction}, fn, ...args) => {
const fun = createFunction(fn);
return new RAFPoller(() => {
return fun(...args) as Promise<T>;
});
},
LazyArg.create(context => {
return context.puppeteerUtil;
}),
this.#fn,
...this.#args
);
break;
case 'mutation':
this.#poller = await this.#world.evaluateHandle(
({MutationPoller, createFunction}, root, fn, ...args) => {
const fun = createFunction(fn);
return new MutationPoller(() => {
return fun(...args) as Promise<T>;
}, root || document);
},
LazyArg.create(context => {
return context.puppeteerUtil;
}),
this.#root,
this.#fn,
...this.#args
);
break;
default:
this.#poller = await this.#world.evaluateHandle(
({IntervalPoller, createFunction}, ms, fn, ...args) => {
const fun = createFunction(fn);
return new IntervalPoller(() => {
return fun(...args) as Promise<T>;
}, ms);
},
LazyArg.create(context => {
return context.puppeteerUtil;
}),
this.#polling,
this.#fn,
...this.#args
);
break;
}
await this.#poller.evaluate(poller => {
void poller.start();
});
const result = await this.#poller.evaluateHandle(poller => {
return poller.result();
});
this.#result.resolve(result);
await this.terminate();
} catch (error) {
if (controller.signal.aborted) {
return;
}
const badError = this.getBadError(error);
if (badError) {
await this.terminate(badError);
}
}
}
async terminate(error?: Error): Promise<void> {
this.#world.taskManager.delete(this);
clearTimeout(this.#timeout);
if (error && !this.#result.finished()) {
this.#result.reject(error);
}
if (this.#poller) {
try {
await this.#poller.evaluateHandle(async poller => {
await poller.stop();
});
if (this.#poller) {
await this.#poller.dispose();
this.#poller = undefined;
}
} catch {
// Ignore errors since they most likely come from low-level cleanup.
}
}
}
/**
* Not all errors lead to termination. They usually imply we need to rerun the task.
*/
getBadError(error: unknown): Error | undefined {
if (isErrorLike(error)) {
// When frame is detached the task should have been terminated by the IsolatedWorld.
// This can fail if we were adding this task while the frame was detached,
// so we terminate here instead.
if (
error.message.includes(
'Execution context is not available in detached frame'
)
) {
return new Error('Waiting failed: Frame detached');
}
// When the page is navigated, the promise is rejected.
// We will try again in the new execution context.
if (error.message.includes('Execution context was destroyed')) {
return;
}
// We could have tried to evaluate in a context which was already
// destroyed.
if (error.message.includes('Cannot find context with specified id')) {
return;
}
// Errors coming from WebDriver BiDi. TODO: Adjust messages after
if (
error.message.includes(
"AbortError: Actor 'MessageHandlerFrame' destroyed"
)
) {
return;
}
return error;
}
return new Error('WaitTask failed with an error', {
cause: error,
});
}
}
/**
* @internal
*/
export class TaskManager {
#tasks: Set<WaitTask> = new Set<WaitTask>();
add(task: WaitTask<any>): void {
this.#tasks.add(task);
}
delete(task: WaitTask<any>): void {
this.#tasks.delete(task);
}
terminateAll(error?: Error): void {
for (const task of this.#tasks) {
void task.terminate(error);
}
this.#tasks.clear();
}
async rerunAll(): Promise<void> {
await Promise.all(
[...this.#tasks].map(task => {
return task.rerun();
})
);
}
}