chore: migrate navigations to Progress (#2463)
This commit is contained in:
parent
724d73c03b
commit
1d37a10558
85
src/dom.ts
85
src/dom.ts
|
|
@ -28,8 +28,8 @@ import { Page } from './page';
|
|||
import { selectors } from './selectors';
|
||||
import * as types from './types';
|
||||
import { NotConnectedError } from './errors';
|
||||
import { Log, logError } from './logger';
|
||||
import { Progress } from './progress';
|
||||
import { logError, apiLog } from './logger';
|
||||
import { Progress, runAbortableTask } from './progress';
|
||||
|
||||
export type PointerActionOptions = {
|
||||
modifiers?: input.Modifier[];
|
||||
|
|
@ -40,11 +40,6 @@ export type ClickOptions = PointerActionOptions & input.MouseClickOptions;
|
|||
|
||||
export type MultiClickOptions = PointerActionOptions & input.MouseMultiClickOptions;
|
||||
|
||||
export const inputLog: Log = {
|
||||
name: 'input',
|
||||
color: 'cyan'
|
||||
};
|
||||
|
||||
export class FrameExecutionContext extends js.ExecutionContext {
|
||||
readonly frame: frames.Frame;
|
||||
private _injectedPromise?: Promise<js.JSHandle>;
|
||||
|
|
@ -251,7 +246,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||
}
|
||||
|
||||
async _retryPointerAction(progress: Progress, action: (point: types.Point) => Promise<void>, options: PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<void> {
|
||||
while (!progress.isCanceled()) {
|
||||
while (progress.isRunning()) {
|
||||
const result = await this._performPointerAction(progress, action, options);
|
||||
if (result === 'done')
|
||||
return;
|
||||
|
|
@ -263,27 +258,27 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||
if (!force)
|
||||
await this._waitForDisplayedAtStablePositionAndEnabled(progress);
|
||||
|
||||
progress.log(inputLog, 'scrolling into view if needed...');
|
||||
progress.log(apiLog, 'scrolling into view if needed...');
|
||||
const scrolled = await this._scrollRectIntoViewIfNeeded(position ? { x: position.x, y: position.y, width: 0, height: 0 } : undefined);
|
||||
if (scrolled === 'invisible') {
|
||||
if (force)
|
||||
throw new Error('Element is not visible');
|
||||
progress.log(inputLog, '...element is not visible, retrying input action');
|
||||
progress.log(apiLog, '...element is not visible, retrying input action');
|
||||
return 'retry';
|
||||
}
|
||||
progress.log(inputLog, '...done scrolling');
|
||||
progress.log(apiLog, '...done scrolling');
|
||||
|
||||
const maybePoint = position ? await this._offsetPoint(position) : await this._clickablePoint();
|
||||
if (maybePoint === 'invisible') {
|
||||
if (force)
|
||||
throw new Error('Element is not visible');
|
||||
progress.log(inputLog, 'element is not visibile, retrying input action');
|
||||
progress.log(apiLog, 'element is not visibile, retrying input action');
|
||||
return 'retry';
|
||||
}
|
||||
if (maybePoint === 'outsideviewport') {
|
||||
if (force)
|
||||
throw new Error('Element is outside of the viewport');
|
||||
progress.log(inputLog, 'element is outside of the viewport, retrying input action');
|
||||
progress.log(apiLog, 'element is outside of the viewport, retrying input action');
|
||||
return 'retry';
|
||||
}
|
||||
const point = roundPoint(maybePoint);
|
||||
|
|
@ -291,33 +286,35 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||
if (!force) {
|
||||
if ((options as any).__testHookBeforeHitTarget)
|
||||
await (options as any).__testHookBeforeHitTarget();
|
||||
progress.log(inputLog, `checking that element receives pointer events at (${point.x},${point.y})...`);
|
||||
progress.log(apiLog, `checking that element receives pointer events at (${point.x},${point.y})...`);
|
||||
const matchesHitTarget = await this._checkHitTargetAt(point);
|
||||
if (!matchesHitTarget) {
|
||||
progress.log(inputLog, '...element does not receive pointer events, retrying input action');
|
||||
progress.log(apiLog, '...element does not receive pointer events, retrying input action');
|
||||
return 'retry';
|
||||
}
|
||||
progress.log(inputLog, `...element does receive pointer events, continuing input action`);
|
||||
progress.log(apiLog, `...element does receive pointer events, continuing input action`);
|
||||
}
|
||||
|
||||
await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
|
||||
let restoreModifiers: input.Modifier[] | undefined;
|
||||
if (options && options.modifiers)
|
||||
restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers);
|
||||
progress.log(inputLog, `performing ${progress.apiName} action...`);
|
||||
progress.log(apiLog, `performing ${progress.apiName} action...`);
|
||||
await action(point);
|
||||
progress.log(inputLog, `...${progress.apiName} action done`);
|
||||
progress.log(inputLog, 'waiting for scheduled navigations to finish...');
|
||||
progress.log(apiLog, `...${progress.apiName} action done`);
|
||||
progress.log(apiLog, 'waiting for scheduled navigations to finish...');
|
||||
if ((options as any).__testHookAfterPointerAction)
|
||||
await (options as any).__testHookAfterPointerAction();
|
||||
if (restoreModifiers)
|
||||
await this._page.keyboard._ensureModifiers(restoreModifiers);
|
||||
}, 'input');
|
||||
progress.log(inputLog, '...navigations have finished');
|
||||
progress.log(apiLog, '...navigations have finished');
|
||||
|
||||
return 'done';
|
||||
}
|
||||
|
||||
hover(options: PointerActionOptions & types.PointerActionWaitOptions = {}): Promise<void> {
|
||||
return Progress.runCancelableTask(progress => this._hover(progress, options), options, this._page, this._page._timeoutSettings);
|
||||
return runAbortableTask(progress => this._hover(progress, options), options, this._page, this._page._timeoutSettings);
|
||||
}
|
||||
|
||||
_hover(progress: Progress, options: PointerActionOptions & types.PointerActionWaitOptions): Promise<void> {
|
||||
|
|
@ -325,7 +322,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||
}
|
||||
|
||||
click(options: ClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise<void> {
|
||||
return Progress.runCancelableTask(progress => this._click(progress, options), options, this._page, this._page._timeoutSettings);
|
||||
return runAbortableTask(progress => this._click(progress, options), options, this._page, this._page._timeoutSettings);
|
||||
}
|
||||
|
||||
_click(progress: Progress, options: ClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<void> {
|
||||
|
|
@ -333,7 +330,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||
}
|
||||
|
||||
dblclick(options: MultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise<void> {
|
||||
return Progress.runCancelableTask(progress => this._dblclick(progress, options), options, this._page, this._page._timeoutSettings);
|
||||
return runAbortableTask(progress => this._dblclick(progress, options), options, this._page, this._page._timeoutSettings);
|
||||
}
|
||||
|
||||
_dblclick(progress: Progress, options: MultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<void> {
|
||||
|
|
@ -341,11 +338,11 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||
}
|
||||
|
||||
async selectOption(values: string | ElementHandle | types.SelectOption | string[] | ElementHandle[] | types.SelectOption[], options: types.NavigatingActionWaitOptions = {}): Promise<string[]> {
|
||||
return Progress.runCancelableTask(progress => this._selectOption(progress, values, options), options, this._page, this._page._timeoutSettings);
|
||||
return runAbortableTask(progress => this._selectOption(progress, values, options), options, this._page, this._page._timeoutSettings);
|
||||
}
|
||||
|
||||
async _selectOption(progress: Progress, values: string | ElementHandle | types.SelectOption | string[] | ElementHandle[] | types.SelectOption[], options: types.NavigatingActionWaitOptions): Promise<string[]> {
|
||||
progress.log(inputLog, progress.apiName);
|
||||
progress.log(apiLog, progress.apiName);
|
||||
let vals: string[] | ElementHandle[] | types.SelectOption[];
|
||||
if (!Array.isArray(values))
|
||||
vals = [ values ] as (string[] | ElementHandle[] | types.SelectOption[]);
|
||||
|
|
@ -369,11 +366,11 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||
}
|
||||
|
||||
async fill(value: string, options: types.NavigatingActionWaitOptions = {}): Promise<void> {
|
||||
return Progress.runCancelableTask(progress => this._fill(progress, value, options), options, this._page, this._page._timeoutSettings);
|
||||
return runAbortableTask(progress => this._fill(progress, value, options), options, this._page, this._page._timeoutSettings);
|
||||
}
|
||||
|
||||
async _fill(progress: Progress, value: string, options: types.NavigatingActionWaitOptions): Promise<void> {
|
||||
progress.log(inputLog, `elementHandle.fill("${value}")`);
|
||||
progress.log(apiLog, `elementHandle.fill("${value}")`);
|
||||
assert(helper.isString(value), 'Value must be string. Found value "' + value + '" of type "' + (typeof value) + '"');
|
||||
await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
|
||||
const poll = await this._evaluateHandleInUtility(([injected, node, value]) => {
|
||||
|
|
@ -392,17 +389,17 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||
}
|
||||
|
||||
async selectText(): Promise<void> {
|
||||
this._page._log(inputLog, `elementHandle.selectText()`);
|
||||
this._page._log(apiLog, `elementHandle.selectText()`);
|
||||
const injectedResult = await this._evaluateInUtility(([injected, node]) => injected.selectText(node), {});
|
||||
handleInjectedResult(injectedResult);
|
||||
}
|
||||
|
||||
async setInputFiles(files: string | types.FilePayload | string[] | types.FilePayload[], options: types.NavigatingActionWaitOptions = {}) {
|
||||
return Progress.runCancelableTask(async progress => this._setInputFiles(progress, files, options), options, this._page, this._page._timeoutSettings);
|
||||
return runAbortableTask(async progress => this._setInputFiles(progress, files, options), options, this._page, this._page._timeoutSettings);
|
||||
}
|
||||
|
||||
async _setInputFiles(progress: Progress, files: string | types.FilePayload | string[] | types.FilePayload[], options: types.NavigatingActionWaitOptions) {
|
||||
progress.log(inputLog, progress.apiName);
|
||||
progress.log(apiLog, progress.apiName);
|
||||
const injectedResult = await this._evaluateInUtility(([injected, node]): types.InjectedScriptResult<boolean> => {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE || (node as Node as Element).tagName !== 'INPUT')
|
||||
return { status: 'error', error: 'Node is not an HTMLInputElement' };
|
||||
|
|
@ -437,17 +434,17 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||
}
|
||||
|
||||
async focus() {
|
||||
this._page._log(inputLog, `elementHandle.focus()`);
|
||||
this._page._log(apiLog, `elementHandle.focus()`);
|
||||
const injectedResult = await this._evaluateInUtility(([injected, node]) => injected.focusNode(node), {});
|
||||
handleInjectedResult(injectedResult);
|
||||
}
|
||||
|
||||
async type(text: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) {
|
||||
return Progress.runCancelableTask(progress => this._type(progress, text, options), options, this._page, this._page._timeoutSettings);
|
||||
return runAbortableTask(progress => this._type(progress, text, options), options, this._page, this._page._timeoutSettings);
|
||||
}
|
||||
|
||||
async _type(progress: Progress, text: string, options: { delay?: number } & types.NavigatingActionWaitOptions) {
|
||||
progress.log(inputLog, `elementHandle.type("${text}")`);
|
||||
progress.log(apiLog, `elementHandle.type("${text}")`);
|
||||
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
|
||||
await this.focus();
|
||||
await this._page.keyboard.type(text, options);
|
||||
|
|
@ -455,11 +452,11 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||
}
|
||||
|
||||
async press(key: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) {
|
||||
return Progress.runCancelableTask(progress => this._press(progress, key, options), options, this._page, this._page._timeoutSettings);
|
||||
return runAbortableTask(progress => this._press(progress, key, options), options, this._page, this._page._timeoutSettings);
|
||||
}
|
||||
|
||||
async _press(progress: Progress, key: string, options: { delay?: number } & types.NavigatingActionWaitOptions) {
|
||||
progress.log(inputLog, `elementHandle.press("${key}")`);
|
||||
progress.log(apiLog, `elementHandle.press("${key}")`);
|
||||
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
|
||||
await this.focus();
|
||||
await this._page.keyboard.press(key, options);
|
||||
|
|
@ -467,15 +464,15 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||
}
|
||||
|
||||
async check(options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
|
||||
return Progress.runCancelableTask(async progress => {
|
||||
progress.log(inputLog, `elementHandle.check()`);
|
||||
return runAbortableTask(async progress => {
|
||||
progress.log(apiLog, `elementHandle.check()`);
|
||||
await this._setChecked(progress, true, options);
|
||||
}, options, this._page, this._page._timeoutSettings);
|
||||
}
|
||||
|
||||
async uncheck(options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
|
||||
return Progress.runCancelableTask(async progress => {
|
||||
progress.log(inputLog, `elementHandle.uncheck()`);
|
||||
return runAbortableTask(async progress => {
|
||||
progress.log(apiLog, `elementHandle.uncheck()`);
|
||||
await this._setChecked(progress, false, options);
|
||||
}, options, this._page, this._page._timeoutSettings);
|
||||
}
|
||||
|
|
@ -525,7 +522,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||
}
|
||||
|
||||
async _waitForDisplayedAtStablePositionAndEnabled(progress: Progress): Promise<void> {
|
||||
progress.log(inputLog, 'waiting for element to be displayed, enabled and not moving...');
|
||||
progress.log(apiLog, 'waiting for element to be displayed, enabled and not moving...');
|
||||
const rafCount = this._page._delegate.rafCountForStablePosition();
|
||||
const poll = await this._evaluateHandleInUtility(([injected, node, rafCount]) => {
|
||||
return injected.waitForDisplayedAtStablePositionAndEnabled(node, rafCount);
|
||||
|
|
@ -533,7 +530,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||
new InjectedScriptPollHandler(progress, poll);
|
||||
const injectedResult = await poll.evaluate(poll => poll.result);
|
||||
handleInjectedResult(injectedResult);
|
||||
progress.log(inputLog, '...element is displayed and does not move');
|
||||
progress.log(apiLog, '...element is displayed and does not move');
|
||||
}
|
||||
|
||||
async _checkHitTargetAt(point: types.Point): Promise<boolean> {
|
||||
|
|
@ -563,18 +560,18 @@ export class InjectedScriptPollHandler {
|
|||
constructor(progress: Progress, poll: js.JSHandle<types.InjectedScriptPoll<any>>) {
|
||||
this._progress = progress;
|
||||
this._poll = poll;
|
||||
this._progress.cleanupWhenCanceled(() => this.cancel());
|
||||
this._progress.cleanupWhenAborted(() => this.cancel());
|
||||
this._streamLogs(poll.evaluateHandle(poll => poll.logs));
|
||||
}
|
||||
|
||||
private _streamLogs(logsPromise: Promise<js.JSHandle<types.InjectedScriptLogs>>) {
|
||||
// We continuously get a chunk of logs, stream them to the progress and wait for the next chunk.
|
||||
logsPromise.catch(e => null).then(logs => {
|
||||
if (!logs || !this._poll || this._progress.isCanceled())
|
||||
if (!logs || !this._poll || !this._progress.isRunning())
|
||||
return;
|
||||
logs.evaluate(logs => logs.current).catch(e => [] as string[]).then(messages => {
|
||||
for (const message of messages)
|
||||
this._progress.log(inputLog, message);
|
||||
this._progress.log(apiLog, message);
|
||||
});
|
||||
this._streamLogs(logs.evaluateHandle(logs => logs.next));
|
||||
});
|
||||
|
|
|
|||
293
src/frames.ts
293
src/frames.ts
|
|
@ -19,7 +19,7 @@ import * as fs from 'fs';
|
|||
import * as util from 'util';
|
||||
import { ConsoleMessage } from './console';
|
||||
import * as dom from './dom';
|
||||
import { TimeoutError, NotConnectedError } from './errors';
|
||||
import { NotConnectedError } from './errors';
|
||||
import { Events } from './events';
|
||||
import { assert, helper, RegisteredListener, assertMaxArguments, debugAssert } from './helper';
|
||||
import * as js from './javascript';
|
||||
|
|
@ -29,8 +29,8 @@ import { selectors } from './selectors';
|
|||
import * as types from './types';
|
||||
import { waitForTimeoutWasUsed } from './hints';
|
||||
import { BrowserContext } from './browserContext';
|
||||
import { rewriteErrorMessage } from './debug/stackTrace';
|
||||
import { Progress } from './progress';
|
||||
import { Progress, ProgressController, runAbortableTask } from './progress';
|
||||
import { apiLog } from './logger';
|
||||
|
||||
type ContextType = 'main' | 'utility';
|
||||
type ContextData = {
|
||||
|
|
@ -111,7 +111,7 @@ export class FrameManager {
|
|||
const barrier = new SignalBarrier(progress);
|
||||
this._signalBarriers.add(barrier);
|
||||
if (progress)
|
||||
progress.cleanupWhenCanceled(() => this._signalBarriers.delete(barrier));
|
||||
progress.cleanupWhenAborted(() => this._signalBarriers.delete(barrier));
|
||||
const result = await action();
|
||||
if (source === 'input')
|
||||
await this._page._delegate.inputActionEpilogue();
|
||||
|
|
@ -180,26 +180,18 @@ export class FrameManager {
|
|||
}
|
||||
|
||||
frameStoppedLoading(frameId: string) {
|
||||
const frame = this._frames.get(frameId);
|
||||
if (!frame)
|
||||
return;
|
||||
const hasDOMContentLoaded = frame._firedLifecycleEvents.has('domcontentloaded');
|
||||
const hasLoad = frame._firedLifecycleEvents.has('load');
|
||||
frame._firedLifecycleEvents.add('domcontentloaded');
|
||||
frame._firedLifecycleEvents.add('load');
|
||||
this._notifyLifecycle(frame);
|
||||
if (frame === this.mainFrame() && !hasDOMContentLoaded)
|
||||
this._page.emit(Events.Page.DOMContentLoaded);
|
||||
if (frame === this.mainFrame() && !hasLoad)
|
||||
this._page.emit(Events.Page.Load);
|
||||
this.frameLifecycleEvent(frameId, 'domcontentloaded');
|
||||
this.frameLifecycleEvent(frameId, 'load');
|
||||
}
|
||||
|
||||
frameLifecycleEvent(frameId: string, event: types.LifecycleEvent) {
|
||||
const frame = this._frames.get(frameId);
|
||||
if (!frame)
|
||||
return;
|
||||
if (frame._firedLifecycleEvents.has(event))
|
||||
return;
|
||||
frame._firedLifecycleEvents.add(event);
|
||||
this._notifyLifecycle(frame);
|
||||
this._notifyLifecycle(frame, event);
|
||||
if (frame === this._mainFrame && event === 'load')
|
||||
this._page.emit(Events.Page.Load);
|
||||
if (frame === this._mainFrame && event === 'domcontentloaded')
|
||||
|
|
@ -261,10 +253,10 @@ export class FrameManager {
|
|||
task.onNewDocument(documentId, new Error(error));
|
||||
}
|
||||
|
||||
private _notifyLifecycle(frame: Frame) {
|
||||
private _notifyLifecycle(frame: Frame, lifecycleEvent: types.LifecycleEvent) {
|
||||
for (let parent: Frame | null = frame; parent; parent = parent.parentFrame()) {
|
||||
for (const frameTask of parent._frameTasks)
|
||||
frameTask.onLifecycle();
|
||||
frameTask.onLifecycle(frame, lifecycleEvent);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -350,55 +342,67 @@ export class Frame {
|
|||
}
|
||||
|
||||
async goto(url: string, options: GotoOptions = {}): Promise<network.Response | null> {
|
||||
const headers = (this._page._state.extraHTTPHeaders || {});
|
||||
let referer = headers['referer'] || headers['Referer'];
|
||||
if (options.referer !== undefined) {
|
||||
if (referer !== undefined && referer !== options.referer)
|
||||
throw new Error('"referer" is already specified as extra HTTP header');
|
||||
referer = options.referer;
|
||||
}
|
||||
url = helper.completeUserURL(url);
|
||||
const progressController = new ProgressController(options, this._page, this._page._timeoutSettings.navigationTimeout());
|
||||
abortProgressOnFrameDetach(progressController, this);
|
||||
return progressController.run(async progress => {
|
||||
progress.log(apiLog, `${progress.apiName}("${url}"), waiting until "${options.waitUntil || 'load'}"`);
|
||||
const headers = (this._page._state.extraHTTPHeaders || {});
|
||||
let referer = headers['referer'] || headers['Referer'];
|
||||
if (options.referer !== undefined) {
|
||||
if (referer !== undefined && referer !== options.referer)
|
||||
throw new Error('"referer" is already specified as extra HTTP header');
|
||||
referer = options.referer;
|
||||
}
|
||||
url = helper.completeUserURL(url);
|
||||
|
||||
const frameTask = new FrameTask(this, options, url);
|
||||
const sameDocumentPromise = frameTask.waitForSameDocumentNavigation();
|
||||
const navigateResult = await frameTask.raceAgainstFailures(this._page._delegate.navigateFrame(this, url, referer)).catch(e => {
|
||||
// Do not leave sameDocumentPromise unhandled.
|
||||
sameDocumentPromise.catch(e => {});
|
||||
throw e;
|
||||
const frameTask = new FrameTask(this, progress);
|
||||
const sameDocumentPromise = frameTask.waitForSameDocumentNavigation();
|
||||
const navigateResult = await this._page._delegate.navigateFrame(this, url, referer).catch(e => {
|
||||
// Do not leave sameDocumentPromise unhandled.
|
||||
sameDocumentPromise.catch(e => {});
|
||||
throw e;
|
||||
});
|
||||
if (navigateResult.newDocumentId) {
|
||||
// Do not leave sameDocumentPromise unhandled.
|
||||
sameDocumentPromise.catch(e => {});
|
||||
await frameTask.waitForSpecificDocument(navigateResult.newDocumentId);
|
||||
} else {
|
||||
await sameDocumentPromise;
|
||||
}
|
||||
const request = (navigateResult && navigateResult.newDocumentId) ? frameTask.request(navigateResult.newDocumentId) : null;
|
||||
await frameTask.waitForLifecycle(options.waitUntil === undefined ? 'load' : options.waitUntil);
|
||||
frameTask.done();
|
||||
return request ? request._finalRequest().response() : null;
|
||||
});
|
||||
if (navigateResult.newDocumentId) {
|
||||
// Do not leave sameDocumentPromise unhandled.
|
||||
sameDocumentPromise.catch(e => {});
|
||||
await frameTask.waitForSpecificDocument(navigateResult.newDocumentId);
|
||||
} else {
|
||||
await sameDocumentPromise;
|
||||
}
|
||||
const request = (navigateResult && navigateResult.newDocumentId) ? frameTask.request(navigateResult.newDocumentId) : null;
|
||||
await frameTask.waitForLifecycle(options.waitUntil === undefined ? 'load' : options.waitUntil);
|
||||
frameTask.done();
|
||||
return request ? request._finalRequest().response() : null;
|
||||
}
|
||||
|
||||
async waitForNavigation(options: types.WaitForNavigationOptions = {}): Promise<network.Response | null> {
|
||||
return this._waitForNavigation(options);
|
||||
}
|
||||
|
||||
async _waitForNavigation(options: types.ExtendedWaitForNavigationOptions = {}): Promise<network.Response | null> {
|
||||
const frameTask = new FrameTask(this, options);
|
||||
let documentId: string | undefined;
|
||||
await Promise.race([
|
||||
frameTask.waitForNewDocument(options.url).then(id => documentId = id),
|
||||
frameTask.waitForSameDocumentNavigation(options.url),
|
||||
]);
|
||||
const request = documentId ? frameTask.request(documentId) : null;
|
||||
if (options.waitUntil !== 'commit')
|
||||
const progressController = new ProgressController(options, this._page, this._page._timeoutSettings.navigationTimeout());
|
||||
abortProgressOnFrameDetach(progressController, this);
|
||||
return progressController.run(async progress => {
|
||||
const toUrl = typeof options.url === 'string' ? ` to "${options.url}"` : '';
|
||||
progress.log(apiLog, `waiting for navigation${toUrl} until "${options.waitUntil || 'load'}"`);
|
||||
const frameTask = new FrameTask(this, progress);
|
||||
let documentId: string | undefined;
|
||||
await Promise.race([
|
||||
frameTask.waitForNewDocument(options.url).then(id => documentId = id),
|
||||
frameTask.waitForSameDocumentNavigation(options.url),
|
||||
]);
|
||||
const request = documentId ? frameTask.request(documentId) : null;
|
||||
await frameTask.waitForLifecycle(options.waitUntil === undefined ? 'load' : options.waitUntil);
|
||||
frameTask.done();
|
||||
return request ? request._finalRequest().response() : null;
|
||||
frameTask.done();
|
||||
return request ? request._finalRequest().response() : null;
|
||||
});
|
||||
}
|
||||
|
||||
async waitForLoadState(state: types.LifecycleEvent = 'load', options: types.TimeoutOptions = {}): Promise<void> {
|
||||
const frameTask = new FrameTask(this, options);
|
||||
const progressController = new ProgressController(options, this._page, this._page._timeoutSettings.navigationTimeout());
|
||||
abortProgressOnFrameDetach(progressController, this);
|
||||
return progressController.run(progress => this._waitForLoadState(progress, state));
|
||||
}
|
||||
|
||||
async _waitForLoadState(progress: Progress, state: types.LifecycleEvent): Promise<void> {
|
||||
const frameTask = new FrameTask(this, progress);
|
||||
await frameTask.waitForLifecycle(state);
|
||||
frameTask.done();
|
||||
}
|
||||
|
|
@ -450,8 +454,8 @@ export class Frame {
|
|||
if (!['attached', 'detached', 'visible', 'hidden'].includes(state))
|
||||
throw new Error(`Unsupported waitFor option "${state}"`);
|
||||
const { world, task } = selectors._waitForSelectorTask(selector, state);
|
||||
return Progress.runCancelableTask(async progress => {
|
||||
progress.log(dom.inputLog, `Waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}...`);
|
||||
return runAbortableTask(async progress => {
|
||||
progress.log(apiLog, `Waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}...`);
|
||||
const result = await this._scheduleRerunnableTask(progress, world, task);
|
||||
if (!result.asElement()) {
|
||||
result.dispose();
|
||||
|
|
@ -470,8 +474,8 @@ export class Frame {
|
|||
|
||||
async dispatchEvent(selector: string, type: string, eventInit?: Object, options?: types.TimeoutOptions): Promise<void> {
|
||||
const task = selectors._dispatchEventTask(selector, type, eventInit || {});
|
||||
return Progress.runCancelableTask(async progress => {
|
||||
progress.log(dom.inputLog, `Dispatching "${type}" event on selector "${selector}"...`);
|
||||
return runAbortableTask(async progress => {
|
||||
progress.log(apiLog, `Dispatching "${type}" event on selector "${selector}"...`);
|
||||
const result = await this._scheduleRerunnableTask(progress, 'main', task);
|
||||
result.dispose();
|
||||
}, options || {}, this._page, this._page._timeoutSettings);
|
||||
|
|
@ -515,24 +519,31 @@ export class Frame {
|
|||
});
|
||||
}
|
||||
|
||||
async setContent(html: string, options?: types.NavigateOptions): Promise<void> {
|
||||
const tag = `--playwright--set--content--${this._id}--${++this._setContentCounter}--`;
|
||||
const context = await this._utilityContext();
|
||||
const lifecyclePromise = new Promise((resolve, reject) => {
|
||||
this._page._frameManager._consoleMessageTags.set(tag, () => {
|
||||
// Clear lifecycle right after document.open() - see 'tag' below.
|
||||
this._page._frameManager.clearFrameLifecycle(this);
|
||||
this.waitForLoadState(options ? options.waitUntil : 'load', options).then(resolve).catch(reject);
|
||||
async setContent(html: string, options: types.NavigateOptions = {}): Promise<void> {
|
||||
const progressController = new ProgressController(options, this._page, this._page._timeoutSettings.navigationTimeout());
|
||||
abortProgressOnFrameDetach(progressController, this);
|
||||
return progressController.run(async progress => {
|
||||
const waitUntil = options.waitUntil === undefined ? 'load' : options.waitUntil;
|
||||
progress.log(apiLog, `${progress.apiName}(), waiting until "${waitUntil}"`);
|
||||
const tag = `--playwright--set--content--${this._id}--${++this._setContentCounter}--`;
|
||||
const context = await this._utilityContext();
|
||||
const lifecyclePromise = new Promise((resolve, reject) => {
|
||||
this._page._frameManager._consoleMessageTags.set(tag, () => {
|
||||
progress.log(apiLog, 'content written');
|
||||
// Clear lifecycle right after document.open() - see 'tag' below.
|
||||
this._page._frameManager.clearFrameLifecycle(this);
|
||||
this._waitForLoadState(progress, waitUntil).then(resolve).catch(reject);
|
||||
});
|
||||
});
|
||||
const contentPromise = context.evaluateInternal(({ html, tag }) => {
|
||||
window.stop();
|
||||
document.open();
|
||||
console.debug(tag); // eslint-disable-line no-console
|
||||
document.write(html);
|
||||
document.close();
|
||||
}, { html, tag });
|
||||
await Promise.all([contentPromise, lifecyclePromise]);
|
||||
});
|
||||
const contentPromise = context.evaluateInternal(({ html, tag }) => {
|
||||
window.stop();
|
||||
document.open();
|
||||
console.debug(tag); // eslint-disable-line no-console
|
||||
document.write(html);
|
||||
document.close();
|
||||
}, { html, tag });
|
||||
await Promise.all([contentPromise, lifecyclePromise]);
|
||||
}
|
||||
|
||||
name(): string {
|
||||
|
|
@ -698,23 +709,23 @@ export class Frame {
|
|||
private async _retryWithSelectorIfNotConnected<R>(
|
||||
selector: string, options: types.TimeoutOptions,
|
||||
action: (progress: Progress, handle: dom.ElementHandle<Element>) => Promise<R>): Promise<R> {
|
||||
return Progress.runCancelableTask(async progress => {
|
||||
progress.log(dom.inputLog, `${progress.apiName}("${selector}")`);
|
||||
while (!progress.isCanceled()) {
|
||||
return runAbortableTask(async progress => {
|
||||
progress.log(apiLog, `${progress.apiName}("${selector}")`);
|
||||
while (progress.isRunning()) {
|
||||
try {
|
||||
const { world, task } = selectors._waitForSelectorTask(selector, 'attached');
|
||||
progress.log(dom.inputLog, `waiting for the selector "${selector}"`);
|
||||
progress.log(apiLog, `waiting for the selector "${selector}"`);
|
||||
const handle = await this._scheduleRerunnableTask(progress, world, task);
|
||||
progress.log(dom.inputLog, `...got element for the selector`);
|
||||
progress.log(apiLog, `...got element for the selector`);
|
||||
const element = handle.asElement() as dom.ElementHandle<Element>;
|
||||
progress.cleanupWhenCanceled(() => element.dispose());
|
||||
progress.cleanupWhenAborted(() => element.dispose());
|
||||
const result = await action(progress, element);
|
||||
element.dispose();
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (!(e instanceof NotConnectedError))
|
||||
throw e;
|
||||
progress.log(dom.inputLog, 'element was detached from the DOM, retrying');
|
||||
progress.log(apiLog, 'element was detached from the DOM, retrying');
|
||||
}
|
||||
}
|
||||
return undefined as any;
|
||||
|
|
@ -804,7 +815,7 @@ export class Frame {
|
|||
return injectedScript.poll(polling, () => innerPredicate(arg));
|
||||
}, { injectedScript, predicateBody, polling, arg });
|
||||
};
|
||||
return Progress.runCancelableTask(
|
||||
return runAbortableTask(
|
||||
progress => this._scheduleRerunnableTask(progress, 'main', task),
|
||||
options, this._page, this._page._timeoutSettings);
|
||||
}
|
||||
|
|
@ -892,11 +903,11 @@ class RerunnableTask<T> {
|
|||
this._task = task;
|
||||
this._progress = progress;
|
||||
data.rerunnableTasks.add(this);
|
||||
this.promise = progress.race(new Promise<types.SmartHandle<T>>((resolve, reject) => {
|
||||
this.promise = new Promise<types.SmartHandle<T>>((resolve, reject) => {
|
||||
// The task is either resolved with a value, or rejected with a meaningful evaluation error.
|
||||
this._resolve = resolve;
|
||||
this._reject = reject;
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
terminate(error: Error) {
|
||||
|
|
@ -948,8 +959,14 @@ export class SignalBarrier {
|
|||
|
||||
async addFrameNavigation(frame: Frame) {
|
||||
this.retain();
|
||||
const timeout = this._progress ? helper.timeUntilDeadline(this._progress.deadline) : undefined;
|
||||
await frame._waitForNavigation({timeout, waitUntil: 'commit'}).catch(e => {});
|
||||
const frameTask = new FrameTask(frame, this._progress);
|
||||
await Promise.race([
|
||||
frame._page._disconnectedPromise,
|
||||
frame._detachedPromise,
|
||||
frameTask.waitForNewDocument(),
|
||||
frameTask.waitForSameDocumentNavigation(),
|
||||
]).catch(e => {});
|
||||
frameTask.done();
|
||||
this.release();
|
||||
}
|
||||
|
||||
|
|
@ -970,36 +987,32 @@ export class SignalBarrier {
|
|||
|
||||
export class FrameTask {
|
||||
private _frame: Frame;
|
||||
private _failurePromise: Promise<Error>;
|
||||
private _requestMap = new Map<string, network.Request>();
|
||||
private _timer?: NodeJS.Timer;
|
||||
private _url: string | undefined;
|
||||
private readonly _progress: Progress | null = null;
|
||||
|
||||
onNewDocument: (documentId: string, error?: Error) => void = () => {};
|
||||
onSameDocument = () => {};
|
||||
onLifecycle = () => {};
|
||||
onNewDocument: (documentId: string, error?: Error) => void;
|
||||
onSameDocument: (documentId?: string, error?: Error) => void;
|
||||
onLifecycle: (frame: Frame, lifecycleEvent?: types.LifecycleEvent) => void;
|
||||
|
||||
constructor(frame: Frame, options: types.TimeoutOptions, url?: string) {
|
||||
constructor(frame: Frame, progress: Progress | null) {
|
||||
this._frame = frame;
|
||||
this._url = url;
|
||||
|
||||
// Process timeouts
|
||||
let timeoutPromise = new Promise<TimeoutError>(() => {});
|
||||
const { timeout = frame._page._timeoutSettings.navigationTimeout() } = options;
|
||||
if (timeout) {
|
||||
const errorMessage = 'Navigation timeout exceeded';
|
||||
timeoutPromise = new Promise(fulfill => this._timer = setTimeout(fulfill, timeout))
|
||||
.then(() => { throw new TimeoutError(errorMessage); });
|
||||
}
|
||||
|
||||
// Process detached frames
|
||||
this._failurePromise = Promise.race([
|
||||
timeoutPromise,
|
||||
this._frame._page._disconnectedPromise.then(() => { throw new Error('Navigation failed because browser has disconnected!'); }),
|
||||
this._frame._detachedPromise.then(() => { throw new Error('Navigating frame was detached!'); }),
|
||||
]);
|
||||
|
||||
frame._frameTasks.add(this);
|
||||
this._progress = progress;
|
||||
this.onSameDocument = this._logUrl.bind(this);
|
||||
this.onNewDocument = this._logUrl.bind(this);
|
||||
this.onLifecycle = this._logLifecycle.bind(this);
|
||||
if (progress)
|
||||
progress.cleanupWhenAborted(() => this.done());
|
||||
}
|
||||
|
||||
private _logUrl(documentId?: string, error?: Error) {
|
||||
if (this._progress && !error)
|
||||
this._progress.log(apiLog, `navigated to "${this._frame._url}"`);
|
||||
}
|
||||
|
||||
private _logLifecycle(frame: Frame, lifecycleEvent?: types.LifecycleEvent) {
|
||||
if (this._progress && frame === this._frame && lifecycleEvent && frame._url !== 'about:blank')
|
||||
this._progress.log(apiLog, `"${lifecycleEvent}" event fired`);
|
||||
}
|
||||
|
||||
onRequest(request: network.Request) {
|
||||
|
|
@ -1008,38 +1021,24 @@ export class FrameTask {
|
|||
this._requestMap.set(request._documentId, request);
|
||||
}
|
||||
|
||||
async raceAgainstFailures<T>(promise: Promise<T>): Promise<T> {
|
||||
let result: T;
|
||||
let error: Error | undefined;
|
||||
await Promise.race([
|
||||
this._failurePromise.catch(e => error = e),
|
||||
promise.then(r => result = r).catch(e => error = e)
|
||||
]);
|
||||
|
||||
if (!error)
|
||||
return result!;
|
||||
this.done();
|
||||
if (this._url)
|
||||
rewriteErrorMessage(error, error.message + ` while navigating to ${this._url}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
request(documentId: string): network.Request | undefined {
|
||||
return this._requestMap.get(documentId);
|
||||
}
|
||||
|
||||
waitForSameDocumentNavigation(url?: types.URLMatch): Promise<void> {
|
||||
return this.raceAgainstFailures(new Promise((resolve, reject) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.onSameDocument = () => {
|
||||
this._logUrl();
|
||||
if (helper.urlMatches(this._frame.url(), url))
|
||||
resolve();
|
||||
};
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
waitForSpecificDocument(expectedDocumentId: string): Promise<void> {
|
||||
return this.raceAgainstFailures(new Promise((resolve, reject) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.onNewDocument = (documentId: string, error?: Error) => {
|
||||
this._logUrl(documentId, error);
|
||||
if (documentId === expectedDocumentId) {
|
||||
if (!error)
|
||||
resolve();
|
||||
|
|
@ -1049,12 +1048,13 @@ export class FrameTask {
|
|||
reject(new Error('Navigation interrupted by another one'));
|
||||
}
|
||||
};
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
waitForNewDocument(url?: types.URLMatch): Promise<string> {
|
||||
return this.raceAgainstFailures(new Promise((resolve, reject) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.onNewDocument = (documentId: string, error?: Error) => {
|
||||
this._logUrl(documentId, error);
|
||||
if (!error && !helper.urlMatches(this._frame.url(), url))
|
||||
return;
|
||||
if (error)
|
||||
|
|
@ -1062,7 +1062,7 @@ export class FrameTask {
|
|||
else
|
||||
resolve(documentId);
|
||||
};
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
waitForLifecycle(waitUntil: types.LifecycleEvent): Promise<void> {
|
||||
|
|
@ -1070,14 +1070,15 @@ export class FrameTask {
|
|||
waitUntil = 'networkidle';
|
||||
if (!types.kLifecycleEvents.has(waitUntil))
|
||||
throw new Error(`Unsupported waitUntil option ${String(waitUntil)}`);
|
||||
return this.raceAgainstFailures(new Promise((resolve, reject) => {
|
||||
this.onLifecycle = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.onLifecycle = (frame: Frame, lifecycleEvent?: types.LifecycleEvent) => {
|
||||
this._logLifecycle(frame, lifecycleEvent);
|
||||
if (!checkLifecycleRecursively(this._frame))
|
||||
return;
|
||||
resolve();
|
||||
};
|
||||
this.onLifecycle();
|
||||
}));
|
||||
this.onLifecycle(this._frame);
|
||||
});
|
||||
|
||||
function checkLifecycleRecursively(frame: Frame): boolean {
|
||||
if (!frame._firedLifecycleEvents.has(waitUntil))
|
||||
|
|
@ -1092,8 +1093,10 @@ export class FrameTask {
|
|||
|
||||
done() {
|
||||
this._frame._frameTasks.delete(this);
|
||||
if (this._timer)
|
||||
clearTimeout(this._timer);
|
||||
this._failurePromise.catch(e => {});
|
||||
}
|
||||
}
|
||||
|
||||
function abortProgressOnFrameDetach(controller: ProgressController, frame: Frame) {
|
||||
frame._page._disconnectedPromise.then(() => controller.abort(new Error('Navigation failed because browser has disconnected!')));
|
||||
frame._detachedPromise.then(() => controller.abort(new Error('Navigating frame was detached!')));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export interface InnerLogger {
|
|||
}
|
||||
|
||||
export const errorLog: Log = { name: 'generic', severity: 'error' };
|
||||
export const apiLog: Log = { name: 'api', color: 'cyan' };
|
||||
|
||||
export function logError(logger: InnerLogger): (error: Error) => void {
|
||||
return error => logger._log(errorLog, error, []);
|
||||
|
|
|
|||
156
src/progress.ts
156
src/progress.ts
|
|
@ -16,97 +16,109 @@
|
|||
|
||||
import { InnerLogger, Log } from './logger';
|
||||
import { TimeoutError } from './errors';
|
||||
import { helper } from './helper';
|
||||
import { helper, assert } from './helper';
|
||||
import * as types from './types';
|
||||
import { DEFAULT_TIMEOUT, TimeoutSettings } from './timeoutSettings';
|
||||
import { getCurrentApiCall, rewriteErrorMessage } from './debug/stackTrace';
|
||||
|
||||
class AbortError extends Error {}
|
||||
export interface Progress {
|
||||
readonly apiName: string;
|
||||
readonly deadline: number; // To be removed?
|
||||
readonly aborted: Promise<void>;
|
||||
isRunning(): boolean;
|
||||
cleanupWhenAborted(cleanup: () => any): void;
|
||||
log(log: Log, message: string | Error): void;
|
||||
}
|
||||
|
||||
export class Progress {
|
||||
static async runCancelableTask<T>(task: (progress: Progress) => Promise<T>, timeoutOptions: types.TimeoutOptions, logger: InnerLogger, timeoutSettings?: TimeoutSettings, apiName?: string): Promise<T> {
|
||||
apiName = apiName || getCurrentApiCall();
|
||||
export async function runAbortableTask<T>(task: (progress: Progress) => Promise<T>, timeoutOptions: types.TimeoutOptions, logger: InnerLogger, timeoutSettingsOrDefaultTimeout?: TimeoutSettings | number, apiName?: string): Promise<T> {
|
||||
const controller = new ProgressController(timeoutOptions, logger, timeoutSettingsOrDefaultTimeout, apiName);
|
||||
return controller.run(task);
|
||||
}
|
||||
|
||||
const defaultTimeout = timeoutSettings ? timeoutSettings.timeout() : DEFAULT_TIMEOUT;
|
||||
export class ProgressController {
|
||||
// Promise and callback that forcefully abort the progress.
|
||||
// This promise always rejects.
|
||||
private _forceAbort: (error: Error) => void = () => {};
|
||||
private _forceAbortPromise: Promise<any>;
|
||||
|
||||
// Promise and callback that resolve once the progress is aborted.
|
||||
// This includes the force abort and also rejection of the task itself (failure).
|
||||
private _aborted = () => {};
|
||||
private _abortedPromise: Promise<void>;
|
||||
|
||||
// Cleanups to be run only in the case of abort.
|
||||
private _cleanups: (() => any)[] = [];
|
||||
|
||||
private _logger: InnerLogger;
|
||||
private _logRecording: string[] = [];
|
||||
private _state: 'before' | 'running' | 'aborted' | 'finished' = 'before';
|
||||
private _apiName: string;
|
||||
private _deadline: number;
|
||||
private _timeout: number;
|
||||
|
||||
constructor(timeoutOptions: types.TimeoutOptions, logger: InnerLogger, timeoutSettingsOrDefaultTimeout?: TimeoutSettings | number, apiName?: string) {
|
||||
this._apiName = apiName || getCurrentApiCall();
|
||||
this._logger = logger;
|
||||
|
||||
// TODO: figure out nice timeout parameters.
|
||||
let defaultTimeout = DEFAULT_TIMEOUT;
|
||||
if (typeof timeoutSettingsOrDefaultTimeout === 'number')
|
||||
defaultTimeout = timeoutSettingsOrDefaultTimeout;
|
||||
if (timeoutSettingsOrDefaultTimeout instanceof TimeoutSettings)
|
||||
defaultTimeout = timeoutSettingsOrDefaultTimeout.timeout();
|
||||
const { timeout = defaultTimeout } = timeoutOptions;
|
||||
const deadline = TimeoutSettings.computeDeadline(timeout);
|
||||
this._timeout = timeout;
|
||||
this._deadline = TimeoutSettings.computeDeadline(timeout);
|
||||
|
||||
let rejectCancelPromise: (error: Error) => void = () => {};
|
||||
const cancelPromise = new Promise<T>((resolve, x) => rejectCancelPromise = x);
|
||||
const timeoutError = new TimeoutError(`Timeout ${timeout}ms exceeded during ${apiName}.`);
|
||||
const timer = setTimeout(() => rejectCancelPromise(timeoutError), helper.timeUntilDeadline(deadline));
|
||||
this._forceAbortPromise = new Promise((resolve, reject) => this._forceAbort = reject);
|
||||
this._forceAbortPromise.catch(e => null); // Prevent unhandle promsie rejection.
|
||||
this._abortedPromise = new Promise(resolve => this._aborted = resolve);
|
||||
}
|
||||
|
||||
let resolveCancelation = () => {};
|
||||
const progress = new Progress(deadline, logger, new Promise(resolve => resolveCancelation = resolve), rejectCancelPromise, apiName);
|
||||
async run<T>(task: (progress: Progress) => Promise<T>): Promise<T> {
|
||||
assert(this._state === 'before');
|
||||
this._state = 'running';
|
||||
|
||||
const progress: Progress = {
|
||||
apiName: this._apiName,
|
||||
deadline: this._deadline,
|
||||
aborted: this._abortedPromise,
|
||||
isRunning: () => this._state === 'running',
|
||||
cleanupWhenAborted: (cleanup: () => any) => {
|
||||
if (this._state === 'running')
|
||||
this._cleanups.push(cleanup);
|
||||
else
|
||||
runCleanup(cleanup);
|
||||
},
|
||||
log: (log: Log, message: string | Error) => {
|
||||
if (this._state === 'running')
|
||||
this._logRecording.push(message.toString());
|
||||
this._logger._log(log, message);
|
||||
},
|
||||
};
|
||||
|
||||
const timeoutError = new TimeoutError(`Timeout ${this._timeout}ms exceeded during ${this._apiName}.`);
|
||||
const timer = setTimeout(() => this._forceAbort(timeoutError), helper.timeUntilDeadline(this._deadline));
|
||||
try {
|
||||
const promise = task(progress);
|
||||
const result = await Promise.race([promise, cancelPromise]);
|
||||
const result = await Promise.race([promise, this._forceAbortPromise]);
|
||||
clearTimeout(timer);
|
||||
progress._running = false;
|
||||
progress._logRecording = [];
|
||||
this._state = 'finished';
|
||||
this._logRecording = [];
|
||||
return result;
|
||||
} catch (e) {
|
||||
resolveCancelation();
|
||||
rewriteErrorMessage(e, e.message + formatLogRecording(progress._logRecording, apiName));
|
||||
this._aborted();
|
||||
rewriteErrorMessage(e, e.message + formatLogRecording(this._logRecording, this._apiName));
|
||||
clearTimeout(timer);
|
||||
progress._running = false;
|
||||
progress._logRecording = [];
|
||||
await Promise.all(progress._cleanups.splice(0).map(cleanup => runCleanup(cleanup)));
|
||||
this._state = 'aborted';
|
||||
this._logRecording = [];
|
||||
await Promise.all(this._cleanups.splice(0).map(cleanup => runCleanup(cleanup)));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
readonly apiName: string;
|
||||
readonly deadline: number; // To be removed?
|
||||
readonly cancel: (error: Error) => void;
|
||||
readonly _canceled: Promise<any>;
|
||||
|
||||
private _logger: InnerLogger;
|
||||
private _logRecording: string[] = [];
|
||||
private _cleanups: (() => any)[] = [];
|
||||
private _running = true;
|
||||
|
||||
constructor(deadline: number, logger: InnerLogger, canceled: Promise<any>, cancel: (error: Error) => void, apiName: string) {
|
||||
this.deadline = deadline;
|
||||
this.apiName = apiName;
|
||||
this.cancel = cancel;
|
||||
this._canceled = canceled;
|
||||
this._logger = logger;
|
||||
}
|
||||
|
||||
isCanceled(): boolean {
|
||||
return !this._running;
|
||||
}
|
||||
|
||||
cleanupWhenCanceled(cleanup: () => any) {
|
||||
if (this._running)
|
||||
this._cleanups.push(cleanup);
|
||||
else
|
||||
runCleanup(cleanup);
|
||||
}
|
||||
|
||||
throwIfCanceled() {
|
||||
if (!this._running)
|
||||
throw new AbortError();
|
||||
}
|
||||
|
||||
race<T>(promise: Promise<T>, cleanup?: () => any): Promise<T> {
|
||||
const canceled = this._canceled.then(async error => {
|
||||
if (cleanup)
|
||||
await runCleanup(cleanup);
|
||||
throw error;
|
||||
});
|
||||
const success = promise.then(result => {
|
||||
cleanup = undefined;
|
||||
return result;
|
||||
});
|
||||
return Promise.race<T>([success, canceled]);
|
||||
}
|
||||
|
||||
log(log: Log, message: string | Error): void {
|
||||
if (this._running)
|
||||
this._logRecording.push(message.toString());
|
||||
this._logger._log(log, message);
|
||||
abort(error: Error) {
|
||||
this._forceAbort(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { assert } from '../helper';
|
|||
import { launchProcess, Env, waitForLine } from './processLauncher';
|
||||
import { Events } from '../events';
|
||||
import { PipeTransport } from './pipeTransport';
|
||||
import { Progress } from '../progress';
|
||||
import { Progress, runAbortableTask } from '../progress';
|
||||
|
||||
export type BrowserArgOptions = {
|
||||
headless?: boolean,
|
||||
|
|
@ -107,7 +107,7 @@ export abstract class BrowserTypeBase implements BrowserType {
|
|||
assert(!(options as any).userDataDir, 'userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistentContext` instead');
|
||||
assert(!(options as any).port, 'Cannot specify a port without launching as a server.');
|
||||
const logger = new RootLogger(options.logger);
|
||||
const browser = await Progress.runCancelableTask(progress => this._innerLaunch(progress, options, logger, undefined), options, logger);
|
||||
const browser = await runAbortableTask(progress => this._innerLaunch(progress, options, logger, undefined), options, logger);
|
||||
return browser;
|
||||
}
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ export abstract class BrowserTypeBase implements BrowserType {
|
|||
assert(!(options as any).port, 'Cannot specify a port without launching as a server.');
|
||||
const persistent = validatePersistentContextOptions(options);
|
||||
const logger = new RootLogger(options.logger);
|
||||
const browser = await Progress.runCancelableTask(progress => this._innerLaunch(progress, options, logger, persistent, userDataDir), options, logger);
|
||||
const browser = await runAbortableTask(progress => this._innerLaunch(progress, options, logger, persistent, userDataDir), options, logger);
|
||||
return browser._defaultContext!;
|
||||
}
|
||||
|
||||
|
|
@ -144,7 +144,7 @@ export abstract class BrowserTypeBase implements BrowserType {
|
|||
assert(!(options as any).userDataDir, 'userDataDir option is not supported in `browserType.launchServer`. Use `browserType.launchPersistentContext` instead');
|
||||
const { port = 0 } = options;
|
||||
const logger = new RootLogger(options.logger);
|
||||
return Progress.runCancelableTask(async progress => {
|
||||
return runAbortableTask(async progress => {
|
||||
const { browserServer, transport } = await this._launchServer(progress, options, false, logger);
|
||||
browserServer._webSocketWrapper = this._wrapTransportWithWebSocket(transport, logger, port);
|
||||
return browserServer;
|
||||
|
|
@ -153,9 +153,9 @@ export abstract class BrowserTypeBase implements BrowserType {
|
|||
|
||||
async connect(options: ConnectOptions): Promise<Browser> {
|
||||
const logger = new RootLogger(options.logger);
|
||||
return Progress.runCancelableTask(async progress => {
|
||||
return runAbortableTask(async progress => {
|
||||
const transport = await WebSocketTransport.connect(progress, options.wsEndpoint);
|
||||
progress.cleanupWhenCanceled(() => transport.closeAndWait());
|
||||
progress.cleanupWhenAborted(() => transport.closeAndWait());
|
||||
if ((options as any).__testHookBeforeCreateBrowser)
|
||||
await (options as any).__testHookBeforeCreateBrowser();
|
||||
const browser = await this._connectToTransport(transport, { slowMo: options.slowMo, logger });
|
||||
|
|
@ -221,7 +221,7 @@ export abstract class BrowserTypeBase implements BrowserType {
|
|||
},
|
||||
});
|
||||
browserServer = new BrowserServer(launchedProcess, gracefullyClose, kill);
|
||||
progress.cleanupWhenCanceled(() => browserServer && browserServer._closeOrKill(progress.deadline));
|
||||
progress.cleanupWhenAborted(() => browserServer && browserServer._closeOrKill(progress.deadline));
|
||||
|
||||
if (this._webSocketNotPipe) {
|
||||
const match = await waitForLine(progress, launchedProcess, this._webSocketNotPipe.stream === 'stdout' ? launchedProcess.stdout : launchedProcess.stderr, this._webSocketNotPipe.webSocketRegex);
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import { BrowserServer } from './browserServer';
|
|||
import { launchProcess, waitForLine } from './processLauncher';
|
||||
import { BrowserContext } from '../browserContext';
|
||||
import type {BrowserWindow} from 'electron';
|
||||
import { Progress } from '../progress';
|
||||
import { runAbortableTask } from '../progress';
|
||||
|
||||
type ElectronLaunchOptions = {
|
||||
args?: string[],
|
||||
|
|
@ -168,7 +168,7 @@ export class Electron {
|
|||
handleSIGHUP = true,
|
||||
} = options;
|
||||
const logger = new RootLogger(options.logger);
|
||||
return Progress.runCancelableTask(async progress => {
|
||||
return runAbortableTask(async progress => {
|
||||
let app: ElectronApplication | undefined = undefined;
|
||||
const electronArguments = ['--inspect=0', '--remote-debugging-port=0', '--require', path.join(__dirname, 'electronLoader.js'), ...args];
|
||||
const { launchedProcess, gracefullyClose, kill } = await launchProcess({
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ export function waitForLine(progress: Progress, process: childProcess.ChildProce
|
|||
helper.addEventListener(process, 'error', reject)
|
||||
];
|
||||
|
||||
progress.cleanupWhenCanceled(cleanup);
|
||||
progress.cleanupWhenAborted(cleanup);
|
||||
|
||||
function onLine(line: string) {
|
||||
const match = line.match(regex);
|
||||
|
|
|
|||
|
|
@ -127,10 +127,15 @@ export class WebSocketTransport implements ConnectionTransport {
|
|||
onmessage?: (message: ProtocolResponse) => void;
|
||||
onclose?: () => void;
|
||||
|
||||
static connect(progress: Progress, url: string): Promise<WebSocketTransport> {
|
||||
static async connect(progress: Progress, url: string): Promise<WebSocketTransport> {
|
||||
progress.log(browserLog, `<ws connecting> ${url}`);
|
||||
const transport = new WebSocketTransport(progress, url);
|
||||
const promise = new Promise<WebSocketTransport>((fulfill, reject) => {
|
||||
let success = false;
|
||||
progress.aborted.then(() => {
|
||||
if (!success)
|
||||
transport.closeAndWait().catch(e => null);
|
||||
});
|
||||
await new Promise<WebSocketTransport>((fulfill, reject) => {
|
||||
transport._ws.addEventListener('open', async () => {
|
||||
progress.log(browserLog, `<ws connected> ${url}`);
|
||||
fulfill(transport);
|
||||
|
|
@ -141,7 +146,8 @@ export class WebSocketTransport implements ConnectionTransport {
|
|||
transport._ws.close();
|
||||
});
|
||||
});
|
||||
return progress.race(promise, () => transport.closeAndWait());
|
||||
success = true;
|
||||
return transport;
|
||||
}
|
||||
|
||||
constructor(progress: Progress, url: string) {
|
||||
|
|
|
|||
|
|
@ -65,11 +65,6 @@ export type WaitForNavigationOptions = TimeoutOptions & {
|
|||
url?: URLMatch
|
||||
};
|
||||
|
||||
export type ExtendedWaitForNavigationOptions = TimeoutOptions & {
|
||||
waitUntil?: LifecycleEvent | 'commit',
|
||||
url?: URLMatch
|
||||
};
|
||||
|
||||
export type ElementScreenshotOptions = {
|
||||
type?: 'png' | 'jpeg',
|
||||
path?: string,
|
||||
|
|
|
|||
1
test/assets/frames/child-redirect.html
Normal file
1
test/assets/frames/child-redirect.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<iframe src='./redirect-my-parent.html'></iframe>
|
||||
3
test/assets/frames/redirect-my-parent.html
Normal file
3
test/assets/frames/redirect-my-parent.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<script>
|
||||
window.parent.location = './one-frame.html';
|
||||
</script>
|
||||
|
|
@ -187,6 +187,14 @@ describe('Auto waiting', () => {
|
|||
await page.click('input[type=submit]');
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
});
|
||||
it('should report navigation in the log when clicking anchor', async({page, server}) => {
|
||||
await page.setContent(`<a href="${server.PREFIX + '/frames/one-frame.html'}">click me</a>`);
|
||||
const __testHookAfterPointerAction = () => new Promise(f => setTimeout(f, 6000));
|
||||
const error = await page.click('a', { timeout: 5000, __testHookAfterPointerAction }).catch(e => e);
|
||||
expect(error.message).toContain('Timeout 5000ms exceeded during page.click.');
|
||||
expect(error.message).toContain('waiting for scheduled navigations to finish...');
|
||||
expect(error.message).toContain(`navigated to "${server.PREFIX + '/frames/one-frame.html'}"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto waiting should not hang when', () => {
|
||||
|
|
|
|||
|
|
@ -198,8 +198,8 @@ describe('Page.goto', function() {
|
|||
server.setRoute('/empty.html', (req, res) => { });
|
||||
let error = null;
|
||||
await page.goto(server.PREFIX + '/empty.html', {timeout: 1}).catch(e => error = e);
|
||||
const message = 'Navigation timeout exceeded';
|
||||
expect(error.message).toContain(message);
|
||||
expect(error.message).toContain('Timeout 1ms exceeded during page.goto.');
|
||||
expect(error.message).toContain(server.PREFIX + '/empty.html');
|
||||
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
|
||||
});
|
||||
it('should fail when exceeding default maximum navigation timeout', async({page, server}) => {
|
||||
|
|
@ -209,8 +209,8 @@ describe('Page.goto', function() {
|
|||
page.context().setDefaultNavigationTimeout(2);
|
||||
page.setDefaultNavigationTimeout(1);
|
||||
await page.goto(server.PREFIX + '/empty.html').catch(e => error = e);
|
||||
const message = 'Navigation timeout exceeded';
|
||||
expect(error.message).toContain(message);
|
||||
expect(error.message).toContain('Timeout 1ms exceeded during page.goto.');
|
||||
expect(error.message).toContain(server.PREFIX + '/empty.html');
|
||||
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
|
||||
});
|
||||
it('should fail when exceeding browser context navigation timeout', async({page, server}) => {
|
||||
|
|
@ -219,8 +219,8 @@ describe('Page.goto', function() {
|
|||
let error = null;
|
||||
page.context().setDefaultNavigationTimeout(2);
|
||||
await page.goto(server.PREFIX + '/empty.html').catch(e => error = e);
|
||||
const message = 'Navigation timeout exceeded';
|
||||
expect(error.message).toContain(message);
|
||||
expect(error.message).toContain('Timeout 2ms exceeded during page.goto.');
|
||||
expect(error.message).toContain(server.PREFIX + '/empty.html');
|
||||
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
|
||||
});
|
||||
it('should fail when exceeding default maximum timeout', async({page, server}) => {
|
||||
|
|
@ -230,8 +230,8 @@ describe('Page.goto', function() {
|
|||
page.context().setDefaultTimeout(2);
|
||||
page.setDefaultTimeout(1);
|
||||
await page.goto(server.PREFIX + '/empty.html').catch(e => error = e);
|
||||
const message = 'Navigation timeout exceeded';
|
||||
expect(error.message).toContain(message);
|
||||
expect(error.message).toContain('Timeout 1ms exceeded during page.goto.');
|
||||
expect(error.message).toContain(server.PREFIX + '/empty.html');
|
||||
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
|
||||
});
|
||||
it('should fail when exceeding browser context timeout', async({page, server}) => {
|
||||
|
|
@ -240,8 +240,8 @@ describe('Page.goto', function() {
|
|||
let error = null;
|
||||
page.context().setDefaultTimeout(2);
|
||||
await page.goto(server.PREFIX + '/empty.html').catch(e => error = e);
|
||||
const message = 'Navigation timeout exceeded';
|
||||
expect(error.message).toContain(message);
|
||||
expect(error.message).toContain('Timeout 2ms exceeded during page.goto.');
|
||||
expect(error.message).toContain(server.PREFIX + '/empty.html');
|
||||
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
|
||||
});
|
||||
it('should prioritize default navigation timeout over default timeout', async({page, server}) => {
|
||||
|
|
@ -251,8 +251,8 @@ describe('Page.goto', function() {
|
|||
page.setDefaultTimeout(0);
|
||||
page.setDefaultNavigationTimeout(1);
|
||||
await page.goto(server.PREFIX + '/empty.html').catch(e => error = e);
|
||||
const message = 'Navigation timeout exceeded';
|
||||
expect(error.message).toContain(message);
|
||||
expect(error.message).toContain('Timeout 1ms exceeded during page.goto.');
|
||||
expect(error.message).toContain(server.PREFIX + '/empty.html');
|
||||
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
|
||||
});
|
||||
it('should disable timeout when its set to 0', async({page, server}) => {
|
||||
|
|
@ -365,7 +365,8 @@ describe('Page.goto', function() {
|
|||
await page.goto(server.PREFIX + '/grid.html', {
|
||||
referer: 'http://google.com/',
|
||||
}).catch(e => error = e);
|
||||
expect(error.message).toBe('"referer" is already specified as extra HTTP header');
|
||||
expect(error.message).toContain('"referer" is already specified as extra HTTP header');
|
||||
expect(error.message).toContain(server.PREFIX + '/grid.html');
|
||||
});
|
||||
it('should override referrer-policy', async({page, server}) => {
|
||||
server.setRoute('/grid.html', (req, res) => {
|
||||
|
|
@ -545,6 +546,15 @@ describe('Page.waitForNavigation', function() {
|
|||
expect(response.ok()).toBe(true);
|
||||
expect(response.url()).toContain('grid.html');
|
||||
});
|
||||
it('should respect timeout', async({page, server}) => {
|
||||
const promise = page.waitForNavigation({ url: '**/frame.html', timeout: 5000 });
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
const error = await promise.catch(e => e);
|
||||
expect(error.message).toContain('Timeout 5000ms exceeded during page.waitForNavigation.');
|
||||
expect(error.message).toContain('waiting for navigation to "**/frame.html" until "load"');
|
||||
expect(error.message).toContain(`navigated to "${server.EMPTY_PAGE}"`);
|
||||
expect(error.message).toContain(`"load" event fired`);
|
||||
});
|
||||
it('should work with both domcontentloaded and load', async({page, server}) => {
|
||||
let response = null;
|
||||
server.setRoute('/one-style.css', (req, res) => response = res);
|
||||
|
|
@ -737,7 +747,7 @@ describe('Page.waitForLoadState', () => {
|
|||
server.setRoute('/one-style.css', (req, res) => response = res);
|
||||
await page.goto(server.PREFIX + '/one-style.html', {waitUntil: 'domcontentloaded'});
|
||||
const error = await page.waitForLoadState('load', { timeout: 1 }).catch(e => e);
|
||||
expect(error.message).toBe('Navigation timeout exceeded');
|
||||
expect(error.message).toContain('Timeout 1ms exceeded during page.waitForLoadState.');
|
||||
});
|
||||
it('should resolve immediately if loaded', async({page, server}) => {
|
||||
await page.goto(server.PREFIX + '/one-style.html');
|
||||
|
|
@ -904,6 +914,16 @@ describe('Frame.goto', function() {
|
|||
const error = await navigationPromise;
|
||||
expect(error.message).toContain('frame was detached');
|
||||
});
|
||||
it('should continue after client redirect', async({page, server}) => {
|
||||
server.setRoute('/frames/script.js', () => {});
|
||||
const url = server.PREFIX + '/frames/child-redirect.html';
|
||||
const error = await page.goto(url, { timeout: 5000, waitUntil: 'networkidle' }).catch(e => e);
|
||||
expect(error.message).toContain('Timeout 5000ms exceeded during page.goto.');
|
||||
expect(error.message).toContain(`page.goto("${url}"), waiting until "networkidle"`);
|
||||
expect(error.message).toContain(`navigated to "${url}"`);
|
||||
expect(error.message).toContain(`navigated to "${server.PREFIX + '/frames/one-frame.html'}"`);
|
||||
expect(error.message).toContain(`"domcontentloaded" event fired`);
|
||||
});
|
||||
it('should return matching responses', async({page, server}) => {
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
// Attach three frames.
|
||||
|
|
@ -955,11 +975,12 @@ describe('Frame.waitForNavigation', function() {
|
|||
frame.evaluate('window.location = "/empty.html"'),
|
||||
page.evaluate('setTimeout(() => document.querySelector("iframe").remove())'),
|
||||
]).catch(e => error = e);
|
||||
expect(error.message).toContain('waiting for navigation until "load"');
|
||||
expect(error.message).toContain('frame was detached');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Frame._waitForLodState', function() {
|
||||
describe('Frame.waitForLoadState', function() {
|
||||
it('should work', async({page, server}) => {
|
||||
await page.goto(server.PREFIX + '/frames/one-frame.html');
|
||||
const frame = page.frames()[1];
|
||||
|
|
|
|||
|
|
@ -631,8 +631,8 @@ describe('Page.setContent', function() {
|
|||
const imgPath = '/img.png';
|
||||
// stall for image
|
||||
server.setRoute(imgPath, (req, res) => {});
|
||||
let error = null;
|
||||
await page.setContent(`<img src="${server.PREFIX + imgPath}"></img>`).catch(e => error = e);
|
||||
const error = await page.setContent(`<img src="${server.PREFIX + imgPath}"></img>`).catch(e => e);
|
||||
expect(error.message).toContain('Timeout 1ms exceeded during page.setContent.');
|
||||
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
|
||||
});
|
||||
it('should await resources to load', async({page, server}) => {
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ class Reporter {
|
|||
const lineNumber = location.lineNumber();
|
||||
if (lineNumber < lines.length) {
|
||||
const lineNumberLength = (lineNumber + 1 + '').length;
|
||||
const FROM = Math.max(test.location().lineNumber() - 1, lineNumber - 5);
|
||||
const FROM = Math.max(0, lineNumber - 5);
|
||||
const snippet = lines.slice(FROM, lineNumber).map((line, index) => ` ${(FROM + index + 1 + '').padStart(lineNumberLength, ' ')} | ${line}`).join('\n');
|
||||
const pointer = ` ` + ' '.repeat(lineNumberLength) + ' ' + '~'.repeat(location.columnNumber() - 1) + '^';
|
||||
console.log('\n' + snippet + '\n' + colors.grey(pointer) + '\n');
|
||||
|
|
|
|||
Loading…
Reference in a new issue