diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index c6f47914108a5..9431188c5494d 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -27,7 +27,7 @@ import * as network from './network'; import { Page } from './page'; import { ProgressController } from './progress'; import * as types from './types'; -import { LongStandingScope, asLocator, assert, constructURLBasedOnBaseURL, makeWaitForNextTask, monotonicTime, renderTitleForCall } from '../utils'; +import { LongStandingScope, asLocator, assert, constructURLBasedOnBaseURL, makeWaitForNextTask, renderTitleForCall } from '../utils'; import { isSessionClosedError } from './protocolError'; import { debugLogger } from './utils/debugLogger'; import { eventsHelper } from './utils/eventsHelper'; @@ -1383,33 +1383,36 @@ export class Frame extends SdkObject { } async expect(metadata: CallMetadata, selector: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> { - const result = await this._expectImpl(metadata, selector, options); - // Library mode special case for the expect errors which are return values, not exceptions. - if (result.matches === options.isNot) - metadata.error = { error: { name: 'Expect', message: 'Expect failed' } }; - return result; - } + const controller = new ProgressController(metadata, this); + return await controller.run(async progress => { + const lastIntermediateResult: { received?: any, isSet: boolean } = { isSet: false }; - private async _expectImpl(metadata: CallMetadata, selector: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> { - const lastIntermediateResult: { received?: any, isSet: boolean } = { isSet: false }; - try { - let timeout = options.timeout; - const start = timeout > 0 ? monotonicTime() : 0; + progress.setCustomErrorHandler((e: any) => { + // Q: Why not throw upon isSessionClosedError(e) as in other places? + // A: We want user to receive a friendly message containing the last intermediate result. + if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e)) + throw e; + const result: { matches: boolean, received?: any, log?: string[], timedOut?: boolean } = { matches: options.isNot, log: compressCallLog(metadata.log) }; + if (lastIntermediateResult.isSet) + result.received = lastIntermediateResult.received; + if (e instanceof TimeoutError) + result.timedOut = true; + // Library mode special case for the expect errors which are return values, not exceptions. + metadata.error = { error: { name: 'Expect', message: 'Expect failed' } }; + return result; + }); // Step 1: perform locator handlers checkpoint with a specified timeout. - await (new ProgressController(metadata, this)).run(async progress => { - progress.log(`${renderTitleForCall(metadata)}${timeout ? ` with timeout ${timeout}ms` : ''}`); - progress.log(`waiting for ${this._asLocator(selector)}`); - await this._page.performActionPreChecks(progress); - }, timeout); + progress.log(`${renderTitleForCall(metadata)}${options.timeout ? ` with timeout ${options.timeout}ms` : ''}`); + progress.log(`waiting for ${this._asLocator(selector)}`); + await this._page.performActionPreChecks(progress); // Step 2: perform one-shot expect check without a timeout. // Supports the case of `expect(locator).toBeVisible({ timeout: 1 })` // that should succeed when the locator is already visible. + progress.pause(); try { - const resultOneShot = await (new ProgressController(metadata, this)).run(async progress => { - return await this._expectInternal(progress, selector, options, lastIntermediateResult); - }); + const resultOneShot = await this._expectInternal(progress, selector, options, lastIntermediateResult); if (resultOneShot.matches !== options.isNot) return resultOneShot; } catch (e) { @@ -1417,39 +1420,21 @@ export class Frame extends SdkObject { throw e; // Ignore any other errors from one-shot, we'll handle them during retries. } - if (timeout > 0) { - const elapsed = monotonicTime() - start; - timeout -= elapsed; - } - if (timeout < 0) - return { matches: options.isNot, log: compressCallLog(metadata.log), timedOut: true, received: lastIntermediateResult.received }; + progress.resume(); // Step 3: auto-retry expect with increasing timeouts. Bounded by the total remaining time. - return await (new ProgressController(metadata, this)).run(async progress => { - return await this.retryWithProgressAndTimeouts(progress, [100, 250, 500, 1000], async continuePolling => { - await this._page.performActionPreChecks(progress); - const { matches, received } = await this._expectInternal(progress, selector, options, lastIntermediateResult); - if (matches === options.isNot) { - // Keep waiting in these cases: - // expect(locator).conditionThatDoesNotMatch - // expect(locator).not.conditionThatDoesMatch - return continuePolling; - } - return { matches, received }; - }); - }, timeout); - } catch (e) { - // Q: Why not throw upon isSessionClosedError(e) as in other places? - // A: We want user to receive a friendly message containing the last intermediate result. - if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e)) - throw e; - const result: { matches: boolean, received?: any, log?: string[], timedOut?: boolean } = { matches: options.isNot, log: compressCallLog(metadata.log) }; - if (lastIntermediateResult.isSet) - result.received = lastIntermediateResult.received; - if (e instanceof TimeoutError) - result.timedOut = true; - return result; - } + return await this.retryWithProgressAndTimeouts(progress, [100, 250, 500, 1000], async continuePolling => { + await this._page.performActionPreChecks(progress); + const { matches, received } = await this._expectInternal(progress, selector, options, lastIntermediateResult); + if (matches === options.isNot) { + // Keep waiting in these cases: + // expect(locator).conditionThatDoesNotMatch + // expect(locator).not.conditionThatDoesMatch + return continuePolling; + } + return { matches, received }; + }); + }, options.timeout); } private async _expectInternal(progress: Progress, selector: string, options: FrameExpectParams, lastIntermediateResult: { received?: any, isSet: boolean }) { diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 96273086330bb..208dc539e3779 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -623,8 +623,24 @@ export class Page extends SdkObject { intermediateResult = { errorMessage: comparatorResult.errorMessage, diff: comparatorResult.diff, actual, previous }; return false; }; + const errorHandler = (e: any) => { + // Q: Why not throw upon isSessionClosedError(e) as in other places? + // A: We want user to receive a friendly diff between actual and expected/previous. + if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e)) + throw e; + let errorMessage = e.message; + if (e instanceof TimeoutError && intermediateResult?.previous) + errorMessage = `Failed to take two consecutive stable screenshots.`; + return { + log: compressCallLog(e.message ? [...metadata.log, e.message] : metadata.log), + ...intermediateResult, + errorMessage, + timedOut: (e instanceof TimeoutError), + }; + }; const callTimeout = options.timeout; return controller.run(async progress => { + progress.setCustomErrorHandler(errorHandler); let actual: Buffer | undefined; let previous: Buffer | undefined; const pollIntervals = [0, 100, 250, 500]; @@ -673,21 +689,7 @@ export class Page extends SdkObject { return {}; } throw new Error(intermediateResult!.errorMessage); - }, callTimeout).catch(e => { - // Q: Why not throw upon isSessionClosedError(e) as in other places? - // A: We want user to receive a friendly diff between actual and expected/previous. - if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e)) - throw e; - let errorMessage = e.message; - if (e instanceof TimeoutError && intermediateResult?.previous) - errorMessage = `Failed to take two consecutive stable screenshots.`; - return { - log: compressCallLog(e.message ? [...metadata.log, e.message] : metadata.log), - ...intermediateResult, - errorMessage, - timedOut: (e instanceof TimeoutError), - }; - }); + }, callTimeout); } async screenshot(metadata: CallMetadata, options: ScreenshotOptions & types.TimeoutOptions): Promise { diff --git a/packages/playwright-core/src/server/progress.ts b/packages/playwright-core/src/server/progress.ts index 16dcc6fb1f501..80b2d9f59b95a 100644 --- a/packages/playwright-core/src/server/progress.ts +++ b/packages/playwright-core/src/server/progress.ts @@ -28,6 +28,9 @@ export interface Progress { cleanupWhenAborted(cleanup: () => any): void; throwIfAborted(): void; metadata: CallMetadata; + setCustomErrorHandler(handler: (error: Error) => any): void; + pause(): void; + resume(): void; } export class ProgressController { @@ -40,6 +43,7 @@ export class ProgressController { private _state: 'before' | 'running' | 'aborted' | 'finished' = 'before'; private _deadline: number = 0; private _timeout: number = 0; + private _errorHandler: (error: Error) => any; readonly metadata: CallMetadata; readonly instrumentation: Instrumentation; readonly sdkObject: SdkObject; @@ -49,6 +53,7 @@ export class ProgressController { this.sdkObject = sdkObject; this.instrumentation = sdkObject.instrumentation; this._forceAbortPromise.catch(e => null); // Prevent unhandled promise rejection. + this._errorHandler = error => { throw error; }; // Default error handler does not handle the error. } setLogName(logName: LogName) { @@ -68,6 +73,8 @@ export class ProgressController { assert(this._state === 'before'); this._state = 'running'; this.sdkObject.attribution.context?._activeProgressControllers.add(this); + let timer: NodeJS.Timeout | undefined; + const timeoutError = new TimeoutError(`Timeout ${this._timeout}ms exceeded.`); const progress: Progress = { log: message => { @@ -88,12 +95,25 @@ export class ProgressController { if (this._state === 'aborted') throw new AbortedError(); }, - metadata: this.metadata + metadata: this.metadata, + setCustomErrorHandler: handler => this._errorHandler = handler, + pause: () => { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + }, + resume: () => { + const remaining = progress.timeUntilDeadline(); + if (remaining <= 0) + this._forceAbortPromise.reject(timeoutError); + else + timer = setTimeout(() => this._forceAbortPromise.reject(timeoutError), remaining); + }, }; - const timeoutError = new TimeoutError(`Timeout ${this._timeout}ms exceeded.`); - const timer = setTimeout(() => this._forceAbortPromise.reject(timeoutError), progress.timeUntilDeadline()); try { + progress.resume(); const promise = task(progress); const result = await Promise.race([promise, this._forceAbortPromise]); this._state = 'finished'; @@ -101,10 +121,10 @@ export class ProgressController { } catch (e) { this._state = 'aborted'; await Promise.all(this._cleanups.splice(0).map(runCleanup)); - throw e; + return this._errorHandler(e); } finally { this.sdkObject.attribution.context?._activeProgressControllers.delete(this); - clearTimeout(timer); + progress.pause(); } } }