221 lines
7 KiB
TypeScript
221 lines
7 KiB
TypeScript
// Copyright (c) Microsoft Corporation.
|
|
// Licensed under the MIT license.
|
|
|
|
import { assert, helper } from './helper';
|
|
import * as types from './types';
|
|
import * as js from './javascript';
|
|
import { TimeoutError } from './Errors';
|
|
|
|
export type WaitTaskParams = {
|
|
// TODO: ensure types.
|
|
predicateBody: Function | string;
|
|
title: string;
|
|
polling: string | number;
|
|
timeout: number;
|
|
args: any[];
|
|
};
|
|
|
|
export class WaitTask<ElementHandle extends types.ElementHandle<ElementHandle>> {
|
|
readonly promise: Promise<js.JSHandle<ElementHandle>>;
|
|
private _cleanup: () => void;
|
|
private _params: WaitTaskParams & { predicateBody: string };
|
|
private _runCount: number;
|
|
private _resolve: (result: js.JSHandle<ElementHandle>) => void;
|
|
private _reject: (reason: Error) => void;
|
|
private _timeoutTimer: NodeJS.Timer;
|
|
private _terminated: boolean;
|
|
|
|
constructor(params: WaitTaskParams, cleanup: () => void) {
|
|
if (helper.isString(params.polling))
|
|
assert(params.polling === 'raf' || params.polling === 'mutation', 'Unknown polling option: ' + params.polling);
|
|
else if (helper.isNumber(params.polling))
|
|
assert(params.polling > 0, 'Cannot poll with non-positive interval: ' + params.polling);
|
|
else
|
|
throw new Error('Unknown polling options: ' + params.polling);
|
|
|
|
this._params = {
|
|
...params,
|
|
predicateBody: helper.isString(params.predicateBody) ? 'return (' + params.predicateBody + ')' : 'return (' + params.predicateBody + ')(...args)'
|
|
};
|
|
this._cleanup = cleanup;
|
|
this._runCount = 0;
|
|
this.promise = new Promise<js.JSHandle<ElementHandle>>((resolve, reject) => {
|
|
this._resolve = resolve;
|
|
this._reject = reject;
|
|
});
|
|
// Since page navigation requires us to re-install the pageScript, we should track
|
|
// timeout on our end.
|
|
if (params.timeout) {
|
|
const timeoutError = new TimeoutError(`waiting for ${params.title} failed: timeout ${params.timeout}ms exceeded`);
|
|
this._timeoutTimer = setTimeout(() => this.terminate(timeoutError), params.timeout);
|
|
}
|
|
}
|
|
|
|
terminate(error: Error) {
|
|
this._terminated = true;
|
|
this._reject(error);
|
|
this._doCleanup();
|
|
}
|
|
|
|
async rerun(context: js.ExecutionContext<ElementHandle>) {
|
|
const runCount = ++this._runCount;
|
|
let success: js.JSHandle<ElementHandle> | null = null;
|
|
let error = null;
|
|
try {
|
|
success = await context.evaluateHandle(waitForPredicatePageFunction, this._params.predicateBody, this._params.polling, this._params.timeout, ...this._params.args);
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
|
|
if (this._terminated || runCount !== this._runCount) {
|
|
if (success)
|
|
await success.dispose();
|
|
return;
|
|
}
|
|
|
|
// Ignore timeouts in pageScript - we track timeouts ourselves.
|
|
// If execution context has been already destroyed, `context.evaluate` will
|
|
// throw an error - ignore this predicate run altogether.
|
|
if (!error && await context.evaluate(s => !s, success).catch(e => true)) {
|
|
await success.dispose();
|
|
return;
|
|
}
|
|
|
|
// When the page is navigated, the promise is rejected.
|
|
// We will try again in the new execution context.
|
|
if (error && error.message.includes('Execution context was destroyed'))
|
|
return;
|
|
|
|
// We could have tried to evaluate in a context which was already
|
|
// destroyed.
|
|
if (error && error.message.includes('Cannot find context with specified id'))
|
|
return;
|
|
|
|
if (error)
|
|
this._reject(error);
|
|
else
|
|
this._resolve(success);
|
|
|
|
this._doCleanup();
|
|
}
|
|
|
|
_doCleanup() {
|
|
clearTimeout(this._timeoutTimer);
|
|
this._cleanup();
|
|
}
|
|
}
|
|
|
|
export function waitForSelectorOrXPath(
|
|
selectorOrXPath: string,
|
|
isXPath: boolean,
|
|
options: { visible?: boolean, hidden?: boolean, timeout: number }): WaitTaskParams {
|
|
const { visible: waitForVisible = false, hidden: waitForHidden = false, timeout } = options;
|
|
const polling = waitForVisible || waitForHidden ? 'raf' : 'mutation';
|
|
const title = `${isXPath ? 'XPath' : 'selector'} "${selectorOrXPath}"${waitForHidden ? ' to be hidden' : ''}`;
|
|
const params: WaitTaskParams = {
|
|
predicateBody: predicate,
|
|
title,
|
|
polling,
|
|
timeout,
|
|
args: [selectorOrXPath, isXPath, waitForVisible, waitForHidden]
|
|
};
|
|
return params;
|
|
|
|
function predicate(selectorOrXPath: string, isXPath: boolean, waitForVisible: boolean, waitForHidden: boolean): (Node | boolean) | null {
|
|
const node = isXPath
|
|
? document.evaluate(selectorOrXPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
|
|
: document.querySelector(selectorOrXPath);
|
|
if (!node)
|
|
return waitForHidden;
|
|
if (!waitForVisible && !waitForHidden)
|
|
return node;
|
|
const element = (node.nodeType === Node.TEXT_NODE ? node.parentElement : node) as Element;
|
|
const style = window.getComputedStyle(element);
|
|
const isVisible = style && style.visibility !== 'hidden' && hasVisibleBoundingBox();
|
|
const success = (waitForVisible === isVisible || waitForHidden === !isVisible);
|
|
return success ? node : null;
|
|
|
|
function hasVisibleBoundingBox(): boolean {
|
|
const rect = element.getBoundingClientRect();
|
|
return !!(rect.top || rect.bottom || rect.width || rect.height);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function waitForPredicatePageFunction(predicateBody: string, polling: string | number, timeout: number, ...args): Promise<any> {
|
|
const predicate = new Function('...args', predicateBody);
|
|
let timedOut = false;
|
|
if (timeout)
|
|
setTimeout(() => timedOut = true, timeout);
|
|
if (polling === 'raf')
|
|
return await pollRaf();
|
|
if (polling === 'mutation')
|
|
return await pollMutation();
|
|
if (typeof polling === 'number')
|
|
return await pollInterval(polling);
|
|
|
|
function pollMutation(): Promise<any> {
|
|
const success = predicate.apply(null, args);
|
|
if (success)
|
|
return Promise.resolve(success);
|
|
|
|
let fulfill;
|
|
const result = new Promise(x => fulfill = x);
|
|
const observer = new MutationObserver(mutations => {
|
|
if (timedOut) {
|
|
observer.disconnect();
|
|
fulfill();
|
|
}
|
|
const success = predicate.apply(null, args);
|
|
if (success) {
|
|
observer.disconnect();
|
|
fulfill(success);
|
|
}
|
|
});
|
|
observer.observe(document, {
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: true
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function pollRaf(): Promise<any> {
|
|
let fulfill;
|
|
const result = new Promise(x => fulfill = x);
|
|
onRaf();
|
|
return result;
|
|
|
|
function onRaf() {
|
|
if (timedOut) {
|
|
fulfill();
|
|
return;
|
|
}
|
|
const success = predicate.apply(null, args);
|
|
if (success)
|
|
fulfill(success);
|
|
else
|
|
requestAnimationFrame(onRaf);
|
|
}
|
|
}
|
|
|
|
function pollInterval(pollInterval: number): Promise<any> {
|
|
let fulfill;
|
|
const result = new Promise(x => fulfill = x);
|
|
onTimeout();
|
|
return result;
|
|
|
|
function onTimeout() {
|
|
if (timedOut) {
|
|
fulfill();
|
|
return;
|
|
}
|
|
const success = predicate.apply(null, args);
|
|
if (success)
|
|
fulfill(success);
|
|
else
|
|
setTimeout(onTimeout, pollInterval);
|
|
}
|
|
}
|
|
}
|