diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 20c42b7f68..e13a3f7d71 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -306,7 +306,7 @@ export class Locator implements api.Locator { await this._frame._channel.waitForSelector({ selector: this._selector, strict: true, omitReturnValue: true, ...options }); } - async _expect(customStackTrace: ParsedStackTrace, expression: string, options: Omit & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[] }> { + async _expect(customStackTrace: ParsedStackTrace, expression: string, options: Omit & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> { return this._frame._wrapApiCall(async () => { const params: channels.FrameExpectParams = { selector: this._selector, expression, ...options, isNot: !!options.isNot }; params.expectedValue = serializeArgument(options.expectedValue); diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index fc69fe086b..8fc6afbd6d 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1581,6 +1581,7 @@ scheme.FrameExpectParams = tObject({ scheme.FrameExpectResult = tObject({ matches: tBoolean, received: tOptional(tType('SerializedValue')), + timedOut: tOptional(tBoolean), log: tOptional(tArray(tString)), }); scheme.WorkerInitializer = tObject({ diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 553ae51e5f..bd288ed04d 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1358,7 +1358,7 @@ export class Frame extends SdkObject { }); } - async expect(metadata: CallMetadata, selector: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[] }> { + async expect(metadata: CallMetadata, selector: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> { const controller = new ProgressController(metadata, this); const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array'); const mainWorld = options.expression === 'to.have.property'; @@ -1418,7 +1418,13 @@ export class Frame extends SdkObject { // A: We want user to receive a friendly message containing the last intermediate result. if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e)) throw e; - return { received: controller.lastIntermediateResult(), matches: options.isNot, log: metadata.log }; + const result: { matches: boolean, received?: any, log?: string[], timedOut?: boolean } = { matches: options.isNot, log: metadata.log }; + const intermediateResult = controller.lastIntermediateResult(); + if (intermediateResult) + result.received = intermediateResult.value; + else + result.timedOut = true; + return result; }); } diff --git a/packages/playwright-core/src/server/progress.ts b/packages/playwright-core/src/server/progress.ts index 61caf732f0..6ae335f079 100644 --- a/packages/playwright-core/src/server/progress.ts +++ b/packages/playwright-core/src/server/progress.ts @@ -43,7 +43,7 @@ export class ProgressController { private _state: 'before' | 'running' | 'aborted' | 'finished' = 'before'; private _deadline: number = 0; private _timeout: number = 0; - private _lastIntermediateResult: any; + private _lastIntermediateResult: { value: any } | undefined; readonly metadata: CallMetadata; readonly instrumentation: Instrumentation; readonly sdkObject: SdkObject; @@ -59,7 +59,7 @@ export class ProgressController { this._logName = logName; } - lastIntermediateResult() { + lastIntermediateResult(): { value: any } | undefined { return this._lastIntermediateResult; } @@ -85,7 +85,7 @@ export class ProgressController { this.instrumentation.onCallLog(this.sdkObject, this.metadata, this._logName, message); } if ('intermediateResult' in entry) - this._lastIntermediateResult = entry.intermediateResult; + this._lastIntermediateResult = { value: entry.intermediateResult }; }, timeUntilDeadline: () => this._deadline ? this._deadline - monotonicTime() : 2147483647, // 2^31-1 safe setTimeout in Node. isRunning: () => this._state === 'running', diff --git a/packages/playwright-test/src/DEPS.list b/packages/playwright-test/src/DEPS.list index c6fa7ed79c..4967152e53 100644 --- a/packages/playwright-test/src/DEPS.list +++ b/packages/playwright-test/src/DEPS.list @@ -1,4 +1,5 @@ [*] +../types.ts ./utilsBundle.ts matchers/ reporters/ diff --git a/packages/playwright-test/src/matchers/matcherHint.ts b/packages/playwright-test/src/matchers/matcherHint.ts new file mode 100644 index 0000000000..0da2be0f0d --- /dev/null +++ b/packages/playwright-test/src/matchers/matcherHint.ts @@ -0,0 +1,25 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { colors } from 'playwright-core/lib/utilsBundle'; +import type { Expect } from '../types'; + +export function matcherHint(state: ReturnType, matcherName: string, a: any, b: any, matcherOptions: any, timeout?: number) { + const message = state.utils.matcherHint(matcherName, a, b, matcherOptions); + if (timeout) + return colors.red(`Timed out ${timeout}ms waiting for `) + message; + return message; +} diff --git a/packages/playwright-test/src/matchers/matchers.ts b/packages/playwright-test/src/matchers/matchers.ts index bccb016aff..2a463be613 100644 --- a/packages/playwright-test/src/matchers/matchers.ts +++ b/packages/playwright-test/src/matchers/matchers.ts @@ -27,7 +27,7 @@ import type { ParsedStackTrace } from 'playwright-core/lib/utils/stackTrace'; import { isTextualMimeType } from 'playwright-core/lib/utils/mimeType'; interface LocatorEx extends Locator { - _expect(customStackTrace: ParsedStackTrace, expression: string, options: Omit & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[] }>; + _expect(customStackTrace: ParsedStackTrace, expression: string, options: Omit & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>; } interface APIResponseEx extends APIResponse { diff --git a/packages/playwright-test/src/matchers/toBeTruthy.ts b/packages/playwright-test/src/matchers/toBeTruthy.ts index 53e18f4500..58644408dc 100644 --- a/packages/playwright-test/src/matchers/toBeTruthy.ts +++ b/packages/playwright-test/src/matchers/toBeTruthy.ts @@ -17,13 +17,14 @@ import type { Expect } from '../types'; import type { ParsedStackTrace } from '../util'; import { expectTypes, callLogText, currentExpectTimeout, captureStackTrace } from '../util'; +import { matcherHint } from './matcherHint'; export async function toBeTruthy( this: ReturnType, matcherName: string, receiver: any, receiverType: string, - query: (isNot: boolean, timeout: number, customStackTrace: ParsedStackTrace) => Promise<{ matches: boolean, log?: string[] }>, + query: (isNot: boolean, timeout: number, customStackTrace: ParsedStackTrace) => Promise<{ matches: boolean, log?: string[], received?: any, timedOut?: boolean }>, options: { timeout?: number } = {}, ) { expectTypes(receiver, [receiverType], matcherName); @@ -35,10 +36,10 @@ export async function toBeTruthy( const timeout = currentExpectTimeout(options); - const { matches, log } = await query(this.isNot, timeout, captureStackTrace('expect.' + matcherName)); + const { matches, log, timedOut } = await query(this.isNot, timeout, captureStackTrace('expect.' + matcherName)); const message = () => { - return this.utils.matcherHint(matcherName, undefined, '', matcherOptions) + callLogText(log); + return matcherHint(this, matcherName, undefined, '', matcherOptions, timedOut ? timeout : undefined) + callLogText(log); }; return { message, pass: matches }; diff --git a/packages/playwright-test/src/matchers/toEqual.ts b/packages/playwright-test/src/matchers/toEqual.ts index 39f516c394..c0b1c50630 100644 --- a/packages/playwright-test/src/matchers/toEqual.ts +++ b/packages/playwright-test/src/matchers/toEqual.ts @@ -19,6 +19,7 @@ import { expectTypes } from '../util'; import { callLogText, currentExpectTimeout } from '../util'; import type { ParsedStackTrace } from 'playwright-core/lib/utils/stackTrace'; import { captureStackTrace } from 'playwright-core/lib/utils/stackTrace'; +import { matcherHint } from './matcherHint'; // Omit colon and one or more spaces, so can call getLabelPrinter. const EXPECTED_LABEL = 'Expected'; @@ -32,7 +33,7 @@ export async function toEqual( matcherName: string, receiver: any, receiverType: string, - query: (isNot: boolean, timeout: number, customStackTrace: ParsedStackTrace) => Promise<{ matches: boolean, received?: any, log?: string[] }>, + query: (isNot: boolean, timeout: number, customStackTrace: ParsedStackTrace) => Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>, expected: T, options: { timeout?: number, contains?: boolean } = {}, ) { @@ -48,18 +49,18 @@ export async function toEqual( const customStackTrace = captureStackTrace(); customStackTrace.apiName = 'expect.' + matcherName; - const { matches: pass, received, log } = await query(this.isNot, timeout, customStackTrace); + const { matches: pass, received, log, timedOut } = await query(this.isNot, timeout, customStackTrace); const message = pass ? () => - this.utils.matcherHint(matcherName, undefined, undefined, matcherOptions) + + matcherHint(this, matcherName, undefined, undefined, matcherOptions, timedOut ? timeout : undefined) + '\n\n' + `Expected: not ${this.utils.printExpected(expected)}\n` + (this.utils.stringify(expected) !== this.utils.stringify(received) ? `Received: ${this.utils.printReceived(received)}` : '') + callLogText(log) : () => - this.utils.matcherHint(matcherName, undefined, undefined, matcherOptions) + + matcherHint(this, matcherName, undefined, undefined, matcherOptions, timedOut ? timeout : undefined) + '\n\n' + this.utils.printDiffOrStringify( expected, diff --git a/packages/playwright-test/src/matchers/toMatchText.ts b/packages/playwright-test/src/matchers/toMatchText.ts index 985407d996..47ceca878a 100644 --- a/packages/playwright-test/src/matchers/toMatchText.ts +++ b/packages/playwright-test/src/matchers/toMatchText.ts @@ -24,13 +24,14 @@ import { printReceivedStringContainExpectedResult, printReceivedStringContainExpectedSubstring } from '../expect'; +import { matcherHint } from './matcherHint'; export async function toMatchText( this: ReturnType, matcherName: string, receiver: any, receiverType: string, - query: (isNot: boolean, timeout: number, customStackTrace: ParsedStackTrace) => Promise<{ matches: boolean, received?: string, log?: string[] }>, + query: (isNot: boolean, timeout: number, customStackTrace: ParsedStackTrace) => Promise<{ matches: boolean, received?: string, log?: string[], timedOut?: boolean }>, expected: string | RegExp, options: { timeout?: number, matchSubstring?: boolean } = {}, ) { @@ -47,7 +48,7 @@ export async function toMatchText( ) { throw new Error( this.utils.matcherErrorMessage( - this.utils.matcherHint(matcherName, undefined, undefined, matcherOptions), + matcherHint(this, matcherName, undefined, undefined, matcherOptions), `${this.utils.EXPECTED_COLOR( 'expected', )} value must be a string or regular expression`, @@ -58,13 +59,13 @@ export async function toMatchText( const timeout = currentExpectTimeout(options); - const { matches: pass, received, log } = await query(this.isNot, timeout, captureStackTrace('expect.' + matcherName)); + const { matches: pass, received, log, timedOut } = await query(this.isNot, timeout, captureStackTrace('expect.' + matcherName)); const stringSubstring = options.matchSubstring ? 'substring' : 'string'; const receivedString = received || ''; const message = pass ? () => typeof expected === 'string' - ? this.utils.matcherHint(matcherName, undefined, undefined, matcherOptions) + + ? matcherHint(this, matcherName, undefined, undefined, matcherOptions, timedOut ? timeout : undefined) + '\n\n' + `Expected ${stringSubstring}: not ${this.utils.printExpected(expected)}\n` + `Received string: ${printReceivedStringContainExpectedSubstring( @@ -72,7 +73,7 @@ export async function toMatchText( receivedString.indexOf(expected), expected.length, )}` + callLogText(log) - : this.utils.matcherHint(matcherName, undefined, undefined, matcherOptions) + + : matcherHint(this, matcherName, undefined, undefined, matcherOptions, timedOut ? timeout : undefined) + '\n\n' + `Expected pattern: not ${this.utils.printExpected(expected)}\n` + `Received string: ${printReceivedStringContainExpectedResult( @@ -87,7 +88,7 @@ export async function toMatchText( const labelReceived = 'Received string'; return ( - this.utils.matcherHint(matcherName, undefined, undefined, matcherOptions) + + matcherHint(this, matcherName, undefined, undefined, matcherOptions, timedOut ? timeout : undefined) + '\n\n' + this.utils.printDiffOrStringify( expected, diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index f53f58399f..db08b9e779 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -2843,6 +2843,7 @@ export type FrameExpectOptions = { export type FrameExpectResult = { matches: boolean, received?: SerializedValue, + timedOut?: boolean, log?: string[], }; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 3fee377243..d9962f39f3 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -2130,6 +2130,7 @@ Frame: returns: matches: boolean received: SerializedValue? + timedOut: boolean? log: type: array? items: string diff --git a/tests/playwright-test/expect.spec.ts b/tests/playwright-test/expect.spec.ts index db87fae5bf..3112513d86 100644 --- a/tests/playwright-test/expect.spec.ts +++ b/tests/playwright-test/expect.spec.ts @@ -504,7 +504,7 @@ test('should print pending operations for toHaveText', async ({ runInlineTest }) expect(result.exitCode).toBe(1); const output = stripAnsi(result.output); expect(output).toContain('Pending operations:'); - expect(output).toContain('Error: expect(received).toHaveText(expected)'); + expect(output).toContain('expect(received).toHaveText(expected)'); expect(output).toContain('Expected string: "Text"'); expect(output).toContain('Received string: ""'); expect(output).toContain('waiting for locator(\'no-such-thing\')'); @@ -532,3 +532,20 @@ test('should print expected/received on Ctrl+C', async ({ runInlineTest }) => { expect(stripAnsi(result.output)).toContain('Expected string: "Text 2"'); expect(stripAnsi(result.output)).toContain('Received string: "Text content"'); }); + +test('should print timed out error message', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + const { test } = pwt; + + test('fail', async ({ page }) => { + await page.setContent('
Text content
'); + await expect(page.locator('no-such-thing')).not.toBeVisible({ timeout: 1 }); + }); + `, + }, { workers: 1 }); + expect(result.failed).toBe(1); + expect(result.exitCode).toBe(1); + const output = stripAnsi(result.output); + expect(output).toContain('Timed out 1ms waiting for expect(received).not.toBeVisible()'); +});