diff --git a/packages/playwright-core/src/client/android.ts b/packages/playwright-core/src/client/android.ts index 45403acaa2..8a9f1549d8 100644 --- a/packages/playwright-core/src/client/android.ts +++ b/packages/playwright-core/src/client/android.ts @@ -281,7 +281,7 @@ export class AndroidDevice extends ChannelOwner i const waiter = Waiter.createForEvent(this, event); waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); if (event !== Events.AndroidDevice.Close) - waiter.rejectOnEvent(this, Events.AndroidDevice.Close, new TargetClosedError()); + waiter.rejectOnEvent(this, Events.AndroidDevice.Close, () => new TargetClosedError()); const result = await waiter.waitForEvent(this, event, predicate as any); waiter.dispose(); return result; diff --git a/packages/playwright-core/src/client/browser.ts b/packages/playwright-core/src/client/browser.ts index 650e980952..f06e739799 100644 --- a/packages/playwright-core/src/client/browser.ts +++ b/packages/playwright-core/src/client/browser.ts @@ -40,6 +40,7 @@ export class Browser extends ChannelOwner implements ap // Used from @playwright/test fixtures. _connectHeaders?: HeadersArray; + _closeReason: string | undefined; static from(browser: channels.BrowserChannel): Browser { return (browser as any)._object; @@ -131,6 +132,7 @@ export class Browser extends ChannelOwner implements ap } async close(options: { reason?: string } = {}): Promise { + this._closeReason = options.reason; try { if (this._shouldCloseConnectionOnClose) this._connection.close(); diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index c4664b1a36..b347dff782 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -64,6 +64,7 @@ export class BrowserContext extends ChannelOwner readonly _isChromium: boolean; private _harRecorders = new Map(); private _closeWasCalled = false; + private _closeReason: string | undefined; static from(context: channels.BrowserContextChannel): BrowserContext { return (context as any)._object; @@ -337,6 +338,10 @@ export class BrowserContext extends ChannelOwner await this._channel.setNetworkInterceptionPatterns({ patterns }); } + _effectiveCloseReason(): string | undefined { + return this._closeReason || this._browser?._closeReason; + } + async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise { return this._wrapApiCall(async () => { const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); @@ -344,7 +349,7 @@ export class BrowserContext extends ChannelOwner const waiter = Waiter.createForEvent(this, event); waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); if (event !== Events.BrowserContext.Close) - waiter.rejectOnEvent(this, Events.BrowserContext.Close, new TargetClosedError()); + waiter.rejectOnEvent(this, Events.BrowserContext.Close, () => new TargetClosedError(this._effectiveCloseReason())); const result = await waiter.waitForEvent(this, event, predicate as any); waiter.dispose(); return result; @@ -386,6 +391,7 @@ export class BrowserContext extends ChannelOwner async close(options: { reason?: string } = {}): Promise { if (this._closeWasCalled) return; + this._closeReason = options.reason; this._closeWasCalled = true; await this._wrapApiCall(async () => { await this._browserType?._willCloseContext(this); diff --git a/packages/playwright-core/src/client/connection.ts b/packages/playwright-core/src/client/connection.ts index eef42b1843..380ce04b9b 100644 --- a/packages/playwright-core/src/client/connection.ts +++ b/packages/playwright-core/src/client/connection.ts @@ -184,9 +184,7 @@ export class Connection extends EventEmitter { } close(cause?: Error) { - this._closedError = new TargetClosedError(); - if (cause) - rewriteErrorMessage(this._closedError, this._closedError.message + '\nCaused by: ' + cause.toString()); + this._closedError = new TargetClosedError(cause?.toString()); for (const callback of this._callbacks.values()) callback.reject(this._closedError); this._callbacks.clear(); diff --git a/packages/playwright-core/src/client/electron.ts b/packages/playwright-core/src/client/electron.ts index 0d2c980df5..103ef5c193 100644 --- a/packages/playwright-core/src/client/electron.ts +++ b/packages/playwright-core/src/client/electron.ts @@ -121,7 +121,7 @@ export class ElectronApplication extends ChannelOwner new TargetClosedError()); const result = await waiter.waitForEvent(this, event, predicate as any); waiter.dispose(); return result; diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index 8c94d1eb33..db0b23530d 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -36,7 +36,6 @@ import { urlMatches } from '../utils/network'; import type * as api from '../../types/types'; import type * as structs from '../../types/structs'; import { debugLogger } from '../common/debugLogger'; -import { TargetClosedError } from '../common/errors'; export type WaitForNavigationOptions = { timeout?: number, @@ -105,8 +104,8 @@ export class Frame extends ChannelOwner implements api.Fr private _setupNavigationWaiter(options: { timeout?: number }): Waiter { const waiter = new Waiter(this._page!, ''); if (this._page!.isClosed()) - waiter.rejectImmediately(new TargetClosedError()); - waiter.rejectOnEvent(this._page!, Events.Page.Close, new TargetClosedError()); + waiter.rejectImmediately(this._page!._closeErrorWithReason()); + waiter.rejectOnEvent(this._page!, Events.Page.Close, () => this._page!._closeErrorWithReason()); waiter.rejectOnEvent(this._page!, Events.Page.Crash, new Error('Navigation failed because page crashed!')); waiter.rejectOnEvent(this._page!, Events.Page.FrameDetached, new Error('Navigating frame was detached!'), frame => frame === this); const timeout = this._page!._timeoutSettings.navigationTimeout(options); diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index dd201e9a80..d0c00b51d6 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -34,7 +34,6 @@ import { MultiMap } from '../utils/multimap'; import { APIResponse } from './fetch'; import type { Serializable } from '../../types/structs'; import type { BrowserContext } from './browserContext'; -import { TargetClosedError } from '../common/errors'; export type NetworkCookie = { name: string, @@ -611,7 +610,7 @@ export class WebSocket extends ChannelOwner implement waiter.rejectOnEvent(this, Events.WebSocket.Error, new Error('Socket error')); if (event !== Events.WebSocket.Close) waiter.rejectOnEvent(this, Events.WebSocket.Close, new Error('Socket closed')); - waiter.rejectOnEvent(this._page, Events.Page.Close, new TargetClosedError()); + waiter.rejectOnEvent(this._page, Events.Page.Close, () => this._page._closeErrorWithReason()); const result = await waiter.waitForEvent(this, event, predicate as any); waiter.dispose(); return result; diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index f1c817b090..afa70e1806 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -19,7 +19,7 @@ import fs from 'fs'; import path from 'path'; import type * as structs from '../../types/structs'; import type * as api from '../../types/types'; -import { isTargetClosedError, TargetClosedError, kTargetClosedErrorMessage } from '../common/errors'; +import { isTargetClosedError, TargetClosedError } from '../common/errors'; import { urlMatches } from '../utils/network'; import { TimeoutSettings } from '../common/timeoutSettings'; import type * as channels from '@protocol/channels'; @@ -93,6 +93,7 @@ export class Page extends ChannelOwner implements api.Page readonly _timeoutSettings: TimeoutSettings; private _video: Video | null = null; readonly _opener: Page | null; + private _closeReason: string | undefined; static from(page: channels.PageChannel): Page { return (page as any)._object; @@ -140,8 +141,8 @@ export class Page extends ChannelOwner implements api.Page this.coverage = new Coverage(this._channel); - this.once(Events.Page.Close, () => this._closedOrCrashedScope.close(kTargetClosedErrorMessage)); - this.once(Events.Page.Crash, () => this._closedOrCrashedScope.close(kTargetClosedErrorMessage)); + this.once(Events.Page.Close, () => this._closedOrCrashedScope.close(this._closeErrorWithReason())); + this.once(Events.Page.Crash, () => this._closedOrCrashedScope.close(new TargetClosedError())); this._setEventToSubscriptionMapping(new Map([ [Events.Page.Console, 'console'], @@ -387,6 +388,10 @@ export class Page extends ChannelOwner implements api.Page return this._waitForEvent(event, optionsOrPredicate, `waiting for event "${event}"`); } + _closeErrorWithReason(): TargetClosedError { + return new TargetClosedError(this._closeReason || this._browserContext._effectiveCloseReason()); + } + private async _waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions, logLine?: string): Promise { return this._wrapApiCall(async () => { const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); @@ -398,7 +403,7 @@ export class Page extends ChannelOwner implements api.Page if (event !== Events.Page.Crash) waiter.rejectOnEvent(this, Events.Page.Crash, new Error('Page crashed')); if (event !== Events.Page.Close) - waiter.rejectOnEvent(this, Events.Page.Close, new TargetClosedError()); + waiter.rejectOnEvent(this, Events.Page.Close, () => this._closeErrorWithReason()); const result = await waiter.waitForEvent(this, event, predicate as any); waiter.dispose(); return result; @@ -513,7 +518,8 @@ export class Page extends ChannelOwner implements api.Page await this._channel.bringToFront(); } - async close(options: { runBeforeUnload?: boolean } = { runBeforeUnload: undefined }) { + async close(options: { runBeforeUnload?: boolean, reason?: string } = {}) { + this._closeReason = options.reason; try { if (this._ownedContext) await this._ownedContext.close(); diff --git a/packages/playwright-core/src/client/waiter.ts b/packages/playwright-core/src/client/waiter.ts index 10f3e6d4b8..c88192868c 100644 --- a/packages/playwright-core/src/client/waiter.ts +++ b/packages/playwright-core/src/client/waiter.ts @@ -50,9 +50,9 @@ export class Waiter { return this.waitForPromise(promise, dispose); } - rejectOnEvent(emitter: EventEmitter, event: string, error: Error, predicate?: (arg: T) => boolean | Promise) { + rejectOnEvent(emitter: EventEmitter, event: string, error: Error | (() => Error), predicate?: (arg: T) => boolean | Promise) { const { promise, dispose } = waitForEvent(emitter, event, predicate); - this._rejectOn(promise.then(() => { throw error; }), dispose); + this._rejectOn(promise.then(() => { throw (typeof error === 'function' ? error() : error); }), dispose); } rejectOnTimeout(timeout: number, message: string) { diff --git a/packages/playwright-core/src/client/worker.ts b/packages/playwright-core/src/client/worker.ts index bc3a2b3acf..1429fcdcc1 100644 --- a/packages/playwright-core/src/client/worker.ts +++ b/packages/playwright-core/src/client/worker.ts @@ -23,7 +23,7 @@ import type { BrowserContext } from './browserContext'; import type * as api from '../../types/types'; import type * as structs from '../../types/structs'; import { LongStandingScope } from '../utils'; -import { kTargetClosedErrorMessage } from '../common/errors'; +import { TargetClosedError } from '../common/errors'; export class Worker extends ChannelOwner implements api.Worker { _page: Page | undefined; // Set for web workers. @@ -43,7 +43,7 @@ export class Worker extends ChannelOwner implements api. this._context._serviceWorkers.delete(this); this.emit(Events.Worker.Close, this); }); - this.once(Events.Worker.Close, () => this._closedScope.close(kTargetClosedErrorMessage)); + this.once(Events.Worker.Close, () => this._closedScope.close(this._page?._closeErrorWithReason() || new TargetClosedError())); } url(): string { diff --git a/packages/playwright-core/src/common/errors.ts b/packages/playwright-core/src/common/errors.ts index 12ea1cf6ee..b8e6c9b11f 100644 --- a/packages/playwright-core/src/common/errors.ts +++ b/packages/playwright-core/src/common/errors.ts @@ -25,16 +25,13 @@ class CustomError extends Error { export class TimeoutError extends CustomError {} -export const kTargetClosedErrorMessage = 'Target page, context or browser has been closed'; -export const kTargetCrashedErrorMessage = 'Target crashed'; - export class TargetClosedError extends Error { - constructor() { - super(kTargetClosedErrorMessage); + constructor(cause?: string, logs?: string) { + super((cause || 'Target page, context or browser has been closed') + (logs || '')); this.name = this.constructor.name; } } export function isTargetClosedError(error: Error) { - return error instanceof TargetClosedError || error.message.includes(kTargetClosedErrorMessage); + return error instanceof TargetClosedError || error.name === 'TargetClosedError'; } diff --git a/packages/playwright-core/src/protocol/serializers.ts b/packages/playwright-core/src/protocol/serializers.ts index d43747237f..92d584da38 100644 --- a/packages/playwright-core/src/protocol/serializers.ts +++ b/packages/playwright-core/src/protocol/serializers.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { TimeoutError } from '../common/errors'; +import { TargetClosedError, TimeoutError } from '../common/errors'; import type { SerializedError, SerializedValue } from '@protocol/channels'; export function serializeError(e: any): SerializedError { @@ -34,6 +34,11 @@ export function parseError(error: SerializedError): Error { e.stack = error.error.stack || ''; return e; } + if (error.error.name === 'TargetClosedError') { + const e = new TargetClosedError(error.error.message); + e.stack = error.error.stack || ''; + return e; + } const e = new Error(error.error.message); e.stack = error.error.stack || ''; e.name = error.error.name; diff --git a/packages/playwright-core/src/server/dispatchers/dispatcher.ts b/packages/playwright-core/src/server/dispatchers/dispatcher.ts index 6cbb4294ac..36f39704a7 100644 --- a/packages/playwright-core/src/server/dispatchers/dispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/dispatcher.ts @@ -19,7 +19,7 @@ import type * as channels from '@protocol/channels'; import { serializeError } from '../../protocol/serializers'; import { findValidator, ValidationError, createMetadataValidator, type ValidatorContext } from '../../protocol/validator'; import { assert, isUnderTest, monotonicTime, rewriteErrorMessage } from '../../utils'; -import { TargetClosedError, isTargetClosedError, kTargetClosedErrorMessage, kTargetCrashedErrorMessage } from '../../common/errors'; +import { TargetClosedError, isTargetClosedError } from '../../common/errors'; import type { CallMetadata } from '../instrumentation'; import { SdkObject } from '../instrumentation'; import type { PlaywrightDispatcher } from './playwrightDispatcher'; @@ -330,15 +330,17 @@ export class DispatcherConnection { const validator = findValidator(dispatcher._type, method, 'Result'); callMetadata.result = validator(result, '', { tChannelImpl: this._tChannelImplToWire.bind(this), binary: this._isLocal ? 'buffer' : 'toBase64' }); } catch (e) { - if (isTargetClosedError(e) && sdkObject) - rewriteErrorMessage(e, closeReason(sdkObject)); - if (isProtocolError(e)) { + if (isTargetClosedError(e) && sdkObject) { + const reason = closeReason(sdkObject); + if (reason) + rewriteErrorMessage(e, reason); + } else if (isProtocolError(e)) { if (e.type === 'closed') { - const closedReason = sdkObject ? closeReason(sdkObject) : kTargetClosedErrorMessage; - rewriteErrorMessage(e, closedReason + e.browserLogMessage()); + const reason = sdkObject ? closeReason(sdkObject) : undefined; + e = new TargetClosedError(reason, e.browserLogMessage()); + } else if (e.type === 'crashed') { + rewriteErrorMessage(e, 'Target crashed ' + e.browserLogMessage()); } - if (e.type === 'crashed') - rewriteErrorMessage(e, kTargetCrashedErrorMessage + e.browserLogMessage()); } callMetadata.error = serializeError(e); } finally { @@ -357,8 +359,8 @@ export class DispatcherConnection { } } -function closeReason(sdkObject: SdkObject) { +function closeReason(sdkObject: SdkObject): string | undefined { return sdkObject.attribution.page?._closeReason || sdkObject.attribution.context?._closeReason || - sdkObject.attribution.browser?._closeReason || kTargetClosedErrorMessage; + sdkObject.attribution.browser?._closeReason; } diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 8e65cc9067..f367957502 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1532,7 +1532,7 @@ export class Frame extends SdkObject { _onDetached() { this._stopNetworkIdleTimer(); - this._detachedScope.close('Frame was detached'); + this._detachedScope.close(new Error('Frame was detached')); for (const data of this._contextData.values()) { if (data.context) data.context.contextDestroyed('Frame was detached'); diff --git a/packages/playwright-core/src/server/javascript.ts b/packages/playwright-core/src/server/javascript.ts index cdb2872688..bfc77ea98b 100644 --- a/packages/playwright-core/src/server/javascript.ts +++ b/packages/playwright-core/src/server/javascript.ts @@ -73,7 +73,7 @@ export class ExecutionContext extends SdkObject { } contextDestroyed(reason: string) { - this._contextDestroyedScope.close(reason); + this._contextDestroyedScope.close(new Error(reason)); } async _raceAgainstContextDestroyed(promise: Promise): Promise { diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 221449e3dd..9f29478318 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -43,7 +43,7 @@ import type { TimeoutOptions } from '../common/types'; import { isInvalidSelectorError } from '../utils/isomorphic/selectorParser'; import { parseEvaluationResultValue, source } from './isomorphic/utilityScriptSerializers'; import type { SerializedValue } from './isomorphic/utilityScriptSerializers'; -import { kTargetClosedErrorMessage } from '../common/errors'; +import { TargetClosedError } from '../common/errors'; export interface PageDelegate { readonly rawMouse: input.RawMouse; @@ -277,7 +277,7 @@ export class Page extends SdkObject { this.emit(Page.Events.Close); this._closedPromise.resolve(); this.instrumentation.onPageClose(this); - this.openScope.close(kTargetClosedErrorMessage); + this.openScope.close(new TargetClosedError()); } _didCrash() { @@ -286,7 +286,7 @@ export class Page extends SdkObject { this.emit(Page.Events.Crash); this._crashed = true; this.instrumentation.onPageClose(this); - this.openScope.close('Page crashed'); + this.openScope.close(new Error('Page crashed')); } async _onFileChooserOpened(handle: dom.ElementHandle) { @@ -733,7 +733,7 @@ export class Worker extends SdkObject { if (this._existingExecutionContext) this._existingExecutionContext.contextDestroyed('Worker was closed'); this.emit(Worker.Events.Close, this); - this.openScope.close('Worker closed'); + this.openScope.close(new Error('Worker closed')); } async evaluateExpression(expression: string, isFunction: boolean | undefined, arg: any): Promise { diff --git a/packages/playwright-core/src/utils/manualPromise.ts b/packages/playwright-core/src/utils/manualPromise.ts index 25d47c8f4b..467b8de0db 100644 --- a/packages/playwright-core/src/utils/manualPromise.ts +++ b/packages/playwright-core/src/utils/manualPromise.ts @@ -58,7 +58,7 @@ export class ManualPromise extends Promise { export class LongStandingScope { private _terminateError: Error | undefined; - private _terminateErrorMessage: string | undefined; + private _closeError: Error | undefined; private _terminatePromises = new Map, string[]>(); private _isClosed = false; @@ -69,14 +69,11 @@ export class LongStandingScope { p.resolve(error); } - close(errorMessage: string) { + close(error: Error) { this._isClosed = true; - this._terminateErrorMessage = errorMessage; - for (const [p, frames] of this._terminatePromises) { - const error = new Error(errorMessage); - error.stack = [error.name + ':' + errorMessage, ...frames].join('\n'); - p.resolve(error); - } + this._closeError = error; + for (const [p, frames] of this._terminatePromises) + p.resolve(cloneError(error, frames)); } isClosed() { @@ -97,11 +94,12 @@ export class LongStandingScope { private async _race(promises: Promise[], safe: boolean, defaultValue?: any): Promise { const terminatePromise = new ManualPromise(); + const frames = captureRawStack(); if (this._terminateError) terminatePromise.resolve(this._terminateError); - if (this._terminateErrorMessage) - terminatePromise.resolve(new Error(this._terminateErrorMessage)); - this._terminatePromises.set(terminatePromise, captureRawStack()); + if (this._closeError) + terminatePromise.resolve(cloneError(this._closeError, frames)); + this._terminatePromises.set(terminatePromise, frames); try { return await Promise.race([ terminatePromise.then(e => safe ? defaultValue : Promise.reject(e)), @@ -112,3 +110,11 @@ export class LongStandingScope { } } } + +function cloneError(error: Error, frames: string[]) { + const clone = new Error(); + clone.name = error.name; + clone.message = error.message; + clone.stack = [error.name + ':' + error.message, ...frames].join('\n'); + return clone; +}