diff --git a/src/dom.ts b/src/dom.ts index 528837873a..4c65758b0a 100644 --- a/src/dom.ts +++ b/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; @@ -251,7 +246,7 @@ export class ElementHandle extends js.JSHandle { } async _retryPointerAction(progress: Progress, action: (point: types.Point) => Promise, options: PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise { - 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 extends js.JSHandle { 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 extends js.JSHandle { 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 { - 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 { @@ -325,7 +322,7 @@ export class ElementHandle extends js.JSHandle { } click(options: ClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise { - 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 { @@ -333,7 +330,7 @@ export class ElementHandle extends js.JSHandle { } dblclick(options: MultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise { - 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 { @@ -341,11 +338,11 @@ export class ElementHandle extends js.JSHandle { } async selectOption(values: string | ElementHandle | types.SelectOption | string[] | ElementHandle[] | types.SelectOption[], options: types.NavigatingActionWaitOptions = {}): Promise { - 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 { - 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 extends js.JSHandle { } async fill(value: string, options: types.NavigatingActionWaitOptions = {}): Promise { - 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 { - 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 extends js.JSHandle { } async selectText(): Promise { - 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 => { 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 extends js.JSHandle { } 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 extends js.JSHandle { } 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 extends js.JSHandle { } 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 extends js.JSHandle { } async _waitForDisplayedAtStablePositionAndEnabled(progress: Progress): Promise { - 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 extends js.JSHandle { 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 { @@ -563,18 +560,18 @@ export class InjectedScriptPollHandler { constructor(progress: Progress, poll: js.JSHandle>) { 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>) { // 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)); }); diff --git a/src/frames.ts b/src/frames.ts index c4ff98f2fe..d60dec115c 100644 --- a/src/frames.ts +++ b/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 { - 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 { - return this._waitForNavigation(options); - } - - async _waitForNavigation(options: types.ExtendedWaitForNavigationOptions = {}): Promise { - 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 { - 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 { + 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 { 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 { - 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 { + 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( selector: string, options: types.TimeoutOptions, action: (progress: Progress, handle: dom.ElementHandle) => Promise): Promise { - 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; - 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 { this._task = task; this._progress = progress; data.rerunnableTasks.add(this); - this.promise = progress.race(new Promise>((resolve, reject) => { + this.promise = new Promise>((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; private _requestMap = new Map(); - 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(() => {}); - 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(promise: Promise): Promise { - 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 { - 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 { - 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 { - 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 { @@ -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!'))); +} diff --git a/src/logger.ts b/src/logger.ts index 6e3b33ffd2..f959b70321 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -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, []); diff --git a/src/progress.ts b/src/progress.ts index 2a07eaa482..c2b9ce0446 100644 --- a/src/progress.ts +++ b/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; + isRunning(): boolean; + cleanupWhenAborted(cleanup: () => any): void; + log(log: Log, message: string | Error): void; +} -export class Progress { - static async runCancelableTask(task: (progress: Progress) => Promise, timeoutOptions: types.TimeoutOptions, logger: InnerLogger, timeoutSettings?: TimeoutSettings, apiName?: string): Promise { - apiName = apiName || getCurrentApiCall(); +export async function runAbortableTask(task: (progress: Progress) => Promise, timeoutOptions: types.TimeoutOptions, logger: InnerLogger, timeoutSettingsOrDefaultTimeout?: TimeoutSettings | number, apiName?: string): Promise { + 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; + + // 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; + + // 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((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(task: (progress: Progress) => Promise): Promise { + 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; - - private _logger: InnerLogger; - private _logRecording: string[] = []; - private _cleanups: (() => any)[] = []; - private _running = true; - - constructor(deadline: number, logger: InnerLogger, canceled: Promise, 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(promise: Promise, cleanup?: () => any): Promise { - 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([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); } } diff --git a/src/server/browserType.ts b/src/server/browserType.ts index 1191239c92..1bd1dba157 100644 --- a/src/server/browserType.ts +++ b/src/server/browserType.ts @@ -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 { 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); diff --git a/src/server/electron.ts b/src/server/electron.ts index b1a68647bf..a185c61247 100644 --- a/src/server/electron.ts +++ b/src/server/electron.ts @@ -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({ diff --git a/src/server/processLauncher.ts b/src/server/processLauncher.ts index f8553dcc2c..2e4ec4611b 100644 --- a/src/server/processLauncher.ts +++ b/src/server/processLauncher.ts @@ -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); diff --git a/src/transport.ts b/src/transport.ts index c663d0bd5d..6ba10b53df 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -127,10 +127,15 @@ export class WebSocketTransport implements ConnectionTransport { onmessage?: (message: ProtocolResponse) => void; onclose?: () => void; - static connect(progress: Progress, url: string): Promise { + static async connect(progress: Progress, url: string): Promise { progress.log(browserLog, ` ${url}`); const transport = new WebSocketTransport(progress, url); - const promise = new Promise((fulfill, reject) => { + let success = false; + progress.aborted.then(() => { + if (!success) + transport.closeAndWait().catch(e => null); + }); + await new Promise((fulfill, reject) => { transport._ws.addEventListener('open', async () => { progress.log(browserLog, ` ${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) { diff --git a/src/types.ts b/src/types.ts index 820ce4c66d..df69579197 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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, diff --git a/test/assets/frames/child-redirect.html b/test/assets/frames/child-redirect.html new file mode 100644 index 0000000000..d4806e7f6b --- /dev/null +++ b/test/assets/frames/child-redirect.html @@ -0,0 +1 @@ + diff --git a/test/assets/frames/redirect-my-parent.html b/test/assets/frames/redirect-my-parent.html new file mode 100644 index 0000000000..3beb0aac1a --- /dev/null +++ b/test/assets/frames/redirect-my-parent.html @@ -0,0 +1,3 @@ + diff --git a/test/autowaiting.spec.js b/test/autowaiting.spec.js index 6f0e36bde8..025b0621aa 100644 --- a/test/autowaiting.spec.js +++ b/test/autowaiting.spec.js @@ -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(`click me`); + 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', () => { diff --git a/test/navigation.spec.js b/test/navigation.spec.js index d69a94c1ca..14c31f96c9 100644 --- a/test/navigation.spec.js +++ b/test/navigation.spec.js @@ -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]; diff --git a/test/page.spec.js b/test/page.spec.js index ddff9e9016..663cf937da 100644 --- a/test/page.spec.js +++ b/test/page.spec.js @@ -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(``).catch(e => error = e); + const error = await page.setContent(``).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}) => { diff --git a/utils/testrunner/Reporter.js b/utils/testrunner/Reporter.js index ff88b4ca56..803cc820de 100644 --- a/utils/testrunner/Reporter.js +++ b/utils/testrunner/Reporter.js @@ -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');