From 88610c8b4c327e9ed11c5319e20cbc7c8930db9d Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Mon, 14 Mar 2022 19:01:13 -0600 Subject: [PATCH] fix: properly define apiName for web-first assertions (#12706) Turns out relying on PWTRAP in stack is not reliable: depending on the call structure, the stack might be cut unpredictably by Node.js. This patch removes PWTRAP and instead plumbs explicit stack and pre-set `apiName` all the way down to `wrapApiCall`. --- .../src/client/channelOwner.ts | 4 +- .../playwright-core/src/client/locator.ts | 19 ++-- packages/playwright-core/src/client/page.ts | 57 ++++++------ .../playwright-core/src/utils/stackTrace.ts | 30 ++---- packages/playwright-test/src/expect.ts | 21 +---- .../playwright-test/src/matchers/matchers.ts | 91 ++++++++++--------- .../src/matchers/toBeTruthy.ts | 6 +- .../playwright-test/src/matchers/toEqual.ts | 7 +- .../src/matchers/toMatchSnapshot.ts | 12 ++- .../src/matchers/toMatchText.ts | 6 +- packages/playwright-test/src/util.ts | 22 +++++ .../to-have-screenshot.spec.ts | 3 +- 12 files changed, 145 insertions(+), 133 deletions(-) diff --git a/packages/playwright-core/src/client/channelOwner.ts b/packages/playwright-core/src/client/channelOwner.ts index 3a927afe85..7aebb8e032 100644 --- a/packages/playwright-core/src/client/channelOwner.ts +++ b/packages/playwright-core/src/client/channelOwner.ts @@ -101,14 +101,14 @@ export abstract class ChannelOwner(func: (apiZone: ApiZone) => Promise, isInternal = false): Promise { + async _wrapApiCall(func: (apiZone: ApiZone) => Promise, isInternal = false, customStackTrace?: ParsedStackTrace): Promise { const logger = this._logger; const stack = captureRawStack(); const apiZone = zones.zoneData('apiZone', stack); if (apiZone) return func(apiZone); - const stackTrace = captureStackTrace(stack); + const stackTrace = customStackTrace || captureStackTrace(stack); if (isInternal) delete stackTrace.apiName; const csi = isInternal ? undefined : this._instrumentation; diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index d0b5d2a56f..bdcfe0f06f 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -17,6 +17,7 @@ import * as structs from '../../types/structs'; import * as api from '../../types/types'; import * as channels from '../protocol/channels'; +import type { ParsedStackTrace } from '../utils/stackTrace'; import * as util from 'util'; import { isRegExp, monotonicTime } from '../utils/utils'; import { ElementHandle } from './elementHandle'; @@ -262,14 +263,16 @@ export class Locator implements api.Locator { await this._frame._channel.waitForSelector({ selector: this._selector, strict: true, omitReturnValue: true, ...options }); } - async _expect(expression: string, options: Omit & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[] }> { - const params: channels.FrameExpectParams = { selector: this._selector, expression, ...options, isNot: !!options.isNot }; - if (options.expectedValue) - params.expectedValue = serializeArgument(options.expectedValue); - const result = (await this._frame._channel.expect(params)); - if (result.received !== undefined) - result.received = parseResult(result.received); - return result; + async _expect(customStackTrace: ParsedStackTrace, expression: string, options: Omit & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[] }> { + return this._frame._wrapApiCall(async () => { + const params: channels.FrameExpectParams = { selector: this._selector, expression, ...options, isNot: !!options.isNot }; + if (options.expectedValue) + params.expectedValue = serializeArgument(options.expectedValue); + const result = (await this._frame._channel.expect(params)); + if (result.received !== undefined) + result.received = parseResult(result.received); + return result; + }, false /* isInternal */, customStackTrace); } [util.inspect.custom]() { diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 22ad916239..84ebf8b6af 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -18,6 +18,7 @@ import { Events } from './events'; import { assert } from '../utils/utils'; import { TimeoutSettings } from '../utils/timeoutSettings'; +import type { ParsedStackTrace } from '../utils/stackTrace'; import * as channels from '../protocol/channels'; import { parseError, serializeError } from '../protocol/serializers'; import { Accessibility } from './accessibility'; @@ -483,34 +484,36 @@ export class Page extends ChannelOwner implements api.Page return buffer; } - async _expectScreenshot(options: ExpectScreenshotOptions): Promise<{ actual?: Buffer, previous?: Buffer, diff?: Buffer, errorMessage?: string, log?: string[]}> { - const mask = options.screenshotOptions?.mask ? options.screenshotOptions?.mask.map(locator => ({ - frame: locator._frame._channel, - selector: locator._selector, - })) : undefined; - const locator = options.locator ? { - frame: options.locator._frame._channel, - selector: options.locator._selector, - } : undefined; - const expected = options.expected ? options.expected.toString('base64') : undefined; + async _expectScreenshot(customStackTrace: ParsedStackTrace, options: ExpectScreenshotOptions): Promise<{ actual?: Buffer, previous?: Buffer, diff?: Buffer, errorMessage?: string, log?: string[]}> { + return this._wrapApiCall(async () => { + const mask = options.screenshotOptions?.mask ? options.screenshotOptions?.mask.map(locator => ({ + frame: locator._frame._channel, + selector: locator._selector, + })) : undefined; + const locator = options.locator ? { + frame: options.locator._frame._channel, + selector: options.locator._selector, + } : undefined; + const expected = options.expected ? options.expected.toString('base64') : undefined; - const result = await this._channel.expectScreenshot({ - ...options, - isNot: !!options.isNot, - expected, - locator, - screenshotOptions: { - ...options.screenshotOptions, - mask, - } - }); - return { - log: result.log, - actual: result.actual ? Buffer.from(result.actual, 'base64') : undefined, - previous: result.previous ? Buffer.from(result.previous, 'base64') : undefined, - diff: result.diff ? Buffer.from(result.diff, 'base64') : undefined, - errorMessage: result.errorMessage, - }; + const result = await this._channel.expectScreenshot({ + ...options, + isNot: !!options.isNot, + expected, + locator, + screenshotOptions: { + ...options.screenshotOptions, + mask, + } + }); + return { + log: result.log, + actual: result.actual ? Buffer.from(result.actual, 'base64') : undefined, + previous: result.previous ? Buffer.from(result.previous, 'base64') : undefined, + diff: result.diff ? Buffer.from(result.diff, 'base64') : undefined, + errorMessage: result.errorMessage, + }; + }, false /* isInternal */, customStackTrace); } async title(): Promise { diff --git a/packages/playwright-core/src/utils/stackTrace.ts b/packages/playwright-core/src/utils/stackTrace.ts index b954e15fcd..727fb6ea9a 100644 --- a/packages/playwright-core/src/utils/stackTrace.ts +++ b/packages/playwright-core/src/utils/stackTrace.ts @@ -108,28 +108,14 @@ export function captureStackTrace(rawStack?: string): ParsedStackTrace { let apiName = ''; const allFrames = parsedFrames; - - // expect matchers have the following stack structure: - // at Object.__PWTRAP__[expect.toHaveText] (...) - // at __EXTERNAL_MATCHER_TRAP__ (...) - // at Object.throwingMatcher [as toHaveText] (...) - const TRAP = '__PWTRAP__['; - const expectIndex = parsedFrames.findIndex(f => f.frameText.includes(TRAP)); - if (expectIndex !== -1) { - const text = parsedFrames[expectIndex].frameText; - const aliasIndex = text.indexOf(TRAP); - apiName = text.substring(aliasIndex + TRAP.length, text.indexOf(']')); - parsedFrames = parsedFrames.slice(expectIndex + 3); - } else { - // Deepest transition between non-client code calling into client code - // is the api entry. - for (let i = 0; i < parsedFrames.length - 1; i++) { - if (parsedFrames[i].inCore && !parsedFrames[i + 1].inCore) { - const frame = parsedFrames[i].frame; - apiName = normalizeAPIName(frame.function); - parsedFrames = parsedFrames.slice(i + 1); - break; - } + // Deepest transition between non-client code calling into client code + // is the api entry. + for (let i = 0; i < parsedFrames.length - 1; i++) { + if (parsedFrames[i].inCore && !parsedFrames[i + 1].inCore) { + const frame = parsedFrames[i].frame; + apiName = normalizeAPIName(frame.function); + parsedFrames = parsedFrames.slice(i + 1); + break; } } diff --git a/packages/playwright-test/src/expect.ts b/packages/playwright-test/src/expect.ts index 34d615867b..d053c4c01a 100644 --- a/packages/playwright-test/src/expect.ts +++ b/packages/playwright-test/src/expect.ts @@ -47,10 +47,7 @@ import { toMatchSnapshot, toHaveScreenshot, getSnapshotName } from './matchers/t import type { Expect, TestError } from './types'; import matchers from 'expect/build/matchers'; import { currentTestInfo } from './globals'; -import { serializeError } from './util'; -import StackUtils from 'stack-utils'; - -const stackUtils = new StackUtils(); +import { serializeError, captureStackTrace } from './util'; // #region // Mirrored from https://github.com/facebook/jest/blob/f13abff8df9a0e1148baf3584bcde6d1b479edc7/packages/expect/src/print.ts @@ -184,7 +181,7 @@ class ExpectMetaInfoProxyHandler { } function wrap(matcherName: string, matcher: any) { - const result = function(this: any, ...args: any[]) { + return function(this: any, ...args: any[]) { const testInfo = currentTestInfo(); if (!testInfo) return matcher.call(this, ...args); @@ -194,15 +191,9 @@ function wrap(matcherName: string, matcher: any) { const [received, nameOrOptions, optOptions] = args; titleSuffix = `(${getSnapshotName(testInfo, received, nameOrOptions, optOptions)})`; } - - const INTERNAL_STACK_LENGTH = 4; - // at Object.__PWTRAP__[expect.toHaveText] (...) - // at __EXTERNAL_MATCHER_TRAP__ (...) - // at Object.throwingMatcher [as toHaveText] (...) - // at Proxy. - // at (...) - const stackLines = new Error().stack!.split('\n').slice(INTERNAL_STACK_LENGTH + 1); - const frame = stackLines[0] ? stackUtils.parseLine(stackLines[0]) : undefined; + const stackTrace = captureStackTrace(); + const stackLines = stackTrace.frameTexts; + const frame = stackTrace.frames[0]; const customMessage = expectCallMetaInfo?.message ?? ''; const isSoft = expectCallMetaInfo?.isSoft ?? false; const step = testInfo._addStep({ @@ -257,8 +248,6 @@ function wrap(matcherName: string, matcher: any) { reportStepError(e); } }; - Object.defineProperty(result, 'name', { value: '__PWTRAP__[expect.' + matcherName + ']' }); - return result; } const wrappedMatchers: any = {}; diff --git a/packages/playwright-test/src/matchers/matchers.ts b/packages/playwright-test/src/matchers/matchers.ts index 60e48673fc..87446ae239 100644 --- a/packages/playwright-test/src/matchers/matchers.ts +++ b/packages/playwright-test/src/matchers/matchers.ts @@ -22,9 +22,10 @@ import { expectTypes, callLogText } from '../util'; import { toBeTruthy } from './toBeTruthy'; import { toEqual } from './toEqual'; import { toExpectedTextValues, toMatchText } from './toMatchText'; +import { ParsedStackTrace } from 'playwright-core/lib/utils/stackTrace'; interface LocatorEx extends Locator { - _expect(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[] }>; } interface APIResponseEx extends APIResponse { @@ -36,9 +37,9 @@ export function toBeChecked( locator: LocatorEx, options?: { checked?: boolean, timeout?: number }, ) { - return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', async (isNot, timeout) => { + return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', async (isNot, timeout, customStackTrace) => { const checked = !options || options.checked === undefined || options.checked === true; - return await locator._expect(checked ? 'to.be.checked' : 'to.be.unchecked', { isNot, timeout }); + return await locator._expect(customStackTrace, checked ? 'to.be.checked' : 'to.be.unchecked', { isNot, timeout }); }, options); } @@ -47,8 +48,8 @@ export function toBeDisabled( locator: LocatorEx, options?: { timeout?: number }, ) { - return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', async (isNot, timeout) => { - return await locator._expect('to.be.disabled', { isNot, timeout }); + return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return await locator._expect(customStackTrace, 'to.be.disabled', { isNot, timeout }); }, options); } @@ -57,8 +58,8 @@ export function toBeEditable( locator: LocatorEx, options?: { timeout?: number }, ) { - return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', async (isNot, timeout) => { - return await locator._expect('to.be.editable', { isNot, timeout }); + return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return await locator._expect(customStackTrace, 'to.be.editable', { isNot, timeout }); }, options); } @@ -67,8 +68,8 @@ export function toBeEmpty( locator: LocatorEx, options?: { timeout?: number }, ) { - return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', async (isNot, timeout) => { - return await locator._expect('to.be.empty', { isNot, timeout }); + return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return await locator._expect(customStackTrace, 'to.be.empty', { isNot, timeout }); }, options); } @@ -77,8 +78,8 @@ export function toBeEnabled( locator: LocatorEx, options?: { timeout?: number }, ) { - return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', async (isNot, timeout) => { - return await locator._expect('to.be.enabled', { isNot, timeout }); + return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return await locator._expect(customStackTrace, 'to.be.enabled', { isNot, timeout }); }, options); } @@ -87,8 +88,8 @@ export function toBeFocused( locator: LocatorEx, options?: { timeout?: number }, ) { - return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', async (isNot, timeout) => { - return await locator._expect('to.be.focused', { isNot, timeout }); + return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return await locator._expect(customStackTrace, 'to.be.focused', { isNot, timeout }); }, options); } @@ -97,8 +98,8 @@ export function toBeHidden( locator: LocatorEx, options?: { timeout?: number }, ) { - return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', async (isNot, timeout) => { - return await locator._expect('to.be.hidden', { isNot, timeout }); + return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return await locator._expect(customStackTrace, 'to.be.hidden', { isNot, timeout }); }, options); } @@ -107,8 +108,8 @@ export function toBeVisible( locator: LocatorEx, options?: { timeout?: number }, ) { - return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', async (isNot, timeout) => { - return await locator._expect('to.be.visible', { isNot, timeout }); + return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return await locator._expect(customStackTrace, 'to.be.visible', { isNot, timeout }); }, options); } @@ -119,14 +120,14 @@ export function toContainText( options?: { timeout?: number, useInnerText?: boolean }, ) { if (Array.isArray(expected)) { - return toEqual.call(this, 'toContainText', locator, 'Locator', async (isNot, timeout) => { + return toEqual.call(this, 'toContainText', locator, 'Locator', async (isNot, timeout, customStackTrace) => { const expectedText = toExpectedTextValues(expected, { matchSubstring: true, normalizeWhiteSpace: true }); - return await locator._expect('to.contain.text.array', { expectedText, isNot, useInnerText: options?.useInnerText, timeout }); + return await locator._expect(customStackTrace, 'to.contain.text.array', { expectedText, isNot, useInnerText: options?.useInnerText, timeout }); }, expected, { ...options, contains: true }); } else { - return toMatchText.call(this, 'toContainText', locator, 'Locator', async (isNot, timeout) => { + return toMatchText.call(this, 'toContainText', locator, 'Locator', async (isNot, timeout, customStackTrace) => { const expectedText = toExpectedTextValues([expected], { matchSubstring: true, normalizeWhiteSpace: true }); - return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options?.useInnerText, timeout }); + return await locator._expect(customStackTrace, 'to.have.text', { expectedText, isNot, useInnerText: options?.useInnerText, timeout }); }, expected, options); } } @@ -138,9 +139,9 @@ export function toHaveAttribute( expected: string | RegExp, options?: { timeout?: number }, ) { - return toMatchText.call(this, 'toHaveAttribute', locator, 'Locator', async (isNot, timeout) => { + return toMatchText.call(this, 'toHaveAttribute', locator, 'Locator', async (isNot, timeout, customStackTrace) => { const expectedText = toExpectedTextValues([expected]); - return await locator._expect('to.have.attribute', { expressionArg: name, expectedText, isNot, timeout }); + return await locator._expect(customStackTrace, 'to.have.attribute', { expressionArg: name, expectedText, isNot, timeout }); }, expected, options); } @@ -151,14 +152,14 @@ export function toHaveClass( options?: { timeout?: number }, ) { if (Array.isArray(expected)) { - return toEqual.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout) => { + return toEqual.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout, customStackTrace) => { const expectedText = toExpectedTextValues(expected); - return await locator._expect('to.have.class.array', { expectedText, isNot, timeout }); + return await locator._expect(customStackTrace, 'to.have.class.array', { expectedText, isNot, timeout }); }, expected, options); } else { - return toMatchText.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout) => { + return toMatchText.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout, customStackTrace) => { const expectedText = toExpectedTextValues([expected]); - return await locator._expect('to.have.class', { expectedText, isNot, timeout }); + return await locator._expect(customStackTrace, 'to.have.class', { expectedText, isNot, timeout }); }, expected, options); } } @@ -169,8 +170,8 @@ export function toHaveCount( expected: number, options?: { timeout?: number }, ) { - return toEqual.call(this, 'toHaveCount', locator, 'Locator', async (isNot, timeout) => { - return await locator._expect('to.have.count', { expectedNumber: expected, isNot, timeout }); + return toEqual.call(this, 'toHaveCount', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return await locator._expect(customStackTrace, 'to.have.count', { expectedNumber: expected, isNot, timeout }); }, expected, options); } @@ -181,9 +182,9 @@ export function toHaveCSS( expected: string | RegExp, options?: { timeout?: number }, ) { - return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async (isNot, timeout) => { + return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async (isNot, timeout, customStackTrace) => { const expectedText = toExpectedTextValues([expected]); - return await locator._expect('to.have.css', { expressionArg: name, expectedText, isNot, timeout }); + return await locator._expect(customStackTrace, 'to.have.css', { expressionArg: name, expectedText, isNot, timeout }); }, expected, options); } @@ -193,9 +194,9 @@ export function toHaveId( expected: string | RegExp, options?: { timeout?: number }, ) { - return toMatchText.call(this, 'toHaveId', locator, 'Locator', async (isNot, timeout) => { + return toMatchText.call(this, 'toHaveId', locator, 'Locator', async (isNot, timeout, customStackTrace) => { const expectedText = toExpectedTextValues([expected]); - return await locator._expect('to.have.id', { expectedText, isNot, timeout }); + return await locator._expect(customStackTrace, 'to.have.id', { expectedText, isNot, timeout }); }, expected, options); } @@ -206,8 +207,8 @@ export function toHaveJSProperty( expected: any, options?: { timeout?: number }, ) { - return toEqual.call(this, 'toHaveJSProperty', locator, 'Locator', async (isNot, timeout) => { - return await locator._expect('to.have.property', { expressionArg: name, expectedValue: expected, isNot, timeout }); + return toEqual.call(this, 'toHaveJSProperty', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return await locator._expect(customStackTrace, 'to.have.property', { expressionArg: name, expectedValue: expected, isNot, timeout }); }, expected, options); } @@ -218,14 +219,14 @@ export function toHaveText( options: { timeout?: number, useInnerText?: boolean } = {}, ) { if (Array.isArray(expected)) { - return toEqual.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout) => { + return toEqual.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout, customStackTrace) => { const expectedText = toExpectedTextValues(expected, { normalizeWhiteSpace: true }); - return await locator._expect('to.have.text.array', { expectedText, isNot, useInnerText: options?.useInnerText, timeout }); + return await locator._expect(customStackTrace, 'to.have.text.array', { expectedText, isNot, useInnerText: options?.useInnerText, timeout }); }, expected, options); } else { - return toMatchText.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout) => { + return toMatchText.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout, customStackTrace) => { const expectedText = toExpectedTextValues([expected], { normalizeWhiteSpace: true }); - return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options?.useInnerText, timeout }); + return await locator._expect(customStackTrace, 'to.have.text', { expectedText, isNot, useInnerText: options?.useInnerText, timeout }); }, expected, options); } } @@ -236,9 +237,9 @@ export function toHaveValue( expected: string | RegExp, options?: { timeout?: number }, ) { - return toMatchText.call(this, 'toHaveValue', locator, 'Locator', async (isNot, timeout) => { + return toMatchText.call(this, 'toHaveValue', locator, 'Locator', async (isNot, timeout, customStackTrace) => { const expectedText = toExpectedTextValues([expected]); - return await locator._expect('to.have.value', { expectedText, isNot, timeout }); + return await locator._expect(customStackTrace, 'to.have.value', { expectedText, isNot, timeout }); }, expected, options); } @@ -249,9 +250,9 @@ export function toHaveTitle( options: { timeout?: number } = {}, ) { const locator = page.locator(':root') as LocatorEx; - return toMatchText.call(this, 'toHaveTitle', locator, 'Locator', async (isNot, timeout) => { + return toMatchText.call(this, 'toHaveTitle', locator, 'Locator', async (isNot, timeout, customStackTrace) => { const expectedText = toExpectedTextValues([expected], { normalizeWhiteSpace: true }); - return await locator._expect('to.have.title', { expectedText, isNot, timeout }); + return await locator._expect(customStackTrace, 'to.have.title', { expectedText, isNot, timeout }); }, expected, options); } @@ -264,9 +265,9 @@ export function toHaveURL( const baseURL = (page.context() as any)._options.baseURL; expected = typeof expected === 'string' ? constructURLBasedOnBaseURL(baseURL, expected) : expected; const locator = page.locator(':root') as LocatorEx; - return toMatchText.call(this, 'toHaveURL', locator, 'Locator', async (isNot, timeout) => { + return toMatchText.call(this, 'toHaveURL', locator, 'Locator', async (isNot, timeout, customStackTrace) => { const expectedText = toExpectedTextValues([expected]); - return await locator._expect('to.have.url', { expectedText, isNot, timeout }); + return await locator._expect(customStackTrace, 'to.have.url', { expectedText, isNot, timeout }); }, expected, options); } diff --git a/packages/playwright-test/src/matchers/toBeTruthy.ts b/packages/playwright-test/src/matchers/toBeTruthy.ts index 7159185d53..c885d1720d 100644 --- a/packages/playwright-test/src/matchers/toBeTruthy.ts +++ b/packages/playwright-test/src/matchers/toBeTruthy.ts @@ -15,14 +15,14 @@ */ import type { Expect } from '../types'; -import { expectTypes, callLogText, currentExpectTimeout } from '../util'; +import { expectTypes, callLogText, currentExpectTimeout, ParsedStackTrace, captureStackTrace } from '../util'; export async function toBeTruthy( this: ReturnType, matcherName: string, receiver: any, receiverType: string, - query: (isNot: boolean, timeout: number) => Promise<{ matches: boolean, log?: string[] }>, + query: (isNot: boolean, timeout: number, customStackTrace: ParsedStackTrace) => Promise<{ matches: boolean, log?: string[] }>, options: { timeout?: number } = {}, ) { expectTypes(receiver, [receiverType], matcherName); @@ -34,7 +34,7 @@ export async function toBeTruthy( const timeout = currentExpectTimeout(options); - const { matches, log } = await query(this.isNot, timeout); + const { matches, log } = await query(this.isNot, timeout, captureStackTrace('expect.' + matcherName)); const message = () => { return this.utils.matcherHint(matcherName, undefined, '', matcherOptions) + callLogText(log); diff --git a/packages/playwright-test/src/matchers/toEqual.ts b/packages/playwright-test/src/matchers/toEqual.ts index cd9b2f44ee..e920b636fe 100644 --- a/packages/playwright-test/src/matchers/toEqual.ts +++ b/packages/playwright-test/src/matchers/toEqual.ts @@ -17,6 +17,7 @@ import type { Expect } from '../types'; import { expectTypes } from '../util'; import { callLogText, currentExpectTimeout } from '../util'; +import { ParsedStackTrace, captureStackTrace } from 'playwright-core/lib/utils/stackTrace'; // Omit colon and one or more spaces, so can call getLabelPrinter. const EXPECTED_LABEL = 'Expected'; @@ -30,7 +31,7 @@ export async function toEqual( matcherName: string, receiver: any, receiverType: string, - query: (isNot: boolean, timeout: number) => Promise<{ matches: boolean, received?: any, log?: string[] }>, + query: (isNot: boolean, timeout: number, customStackTrace: ParsedStackTrace) => Promise<{ matches: boolean, received?: any, log?: string[] }>, expected: T, options: { timeout?: number, contains?: boolean } = {}, ) { @@ -44,7 +45,9 @@ export async function toEqual( const timeout = currentExpectTimeout(options); - const { matches: pass, received, log } = await query(this.isNot, timeout); + const customStackTrace = captureStackTrace(); + customStackTrace.apiName = 'expect.' + matcherName; + const { matches: pass, received, log } = await query(this.isNot, timeout, customStackTrace); const message = pass ? () => diff --git a/packages/playwright-test/src/matchers/toMatchSnapshot.ts b/packages/playwright-test/src/matchers/toMatchSnapshot.ts index c5dff94b9d..b0bf65a547 100644 --- a/packages/playwright-test/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright-test/src/matchers/toMatchSnapshot.ts @@ -21,7 +21,10 @@ import type { Expect } from '../types'; import { currentTestInfo } from '../globals'; import { mimeTypeToComparator, ImageComparatorOptions, Comparator } from 'playwright-core/lib/utils/comparators'; import type { PageScreenshotOptions } from 'playwright-core/types/types'; -import { addSuffixToFilePath, serializeError, sanitizeForFilePath, trimLongString, callLogText, currentExpectTimeout, expectTypes } from '../util'; +import { + addSuffixToFilePath, serializeError, sanitizeForFilePath, + trimLongString, callLogText, currentExpectTimeout, + expectTypes, captureStackTrace } from '../util'; import { UpdateSnapshots } from '../types'; import colors from 'colors/safe'; import fs from 'fs'; @@ -310,6 +313,7 @@ export async function toHaveScreenshot( maxDiffPixelRatio: undefined, }; + const customStackTrace = captureStackTrace(`expect.toHaveScreenshot`); const hasSnapshot = fs.existsSync(helper.snapshotPath); if (this.isNot) { if (!hasSnapshot) @@ -318,7 +322,7 @@ export async function toHaveScreenshot( // Having `errorMessage` means we timed out while waiting // for screenshots not to match, so screenshots // are actually the same in the end. - const isDifferent = !(await page._expectScreenshot({ + const isDifferent = !(await page._expectScreenshot(customStackTrace, { expected: await fs.promises.readFile(helper.snapshotPath), isNot: true, locator, @@ -336,7 +340,7 @@ export async function toHaveScreenshot( if (helper.updateSnapshots === 'all' || !hasSnapshot) { // Regenerate a new screenshot by waiting until two screenshots are the same. const timeout = currentExpectTimeout(helper.allOptions); - const { actual, previous, diff, errorMessage, log } = await page._expectScreenshot({ + const { actual, previous, diff, errorMessage, log } = await page._expectScreenshot(customStackTrace, { expected: undefined, isNot: false, locator, @@ -371,7 +375,7 @@ export async function toHaveScreenshot( // - regular matcher (i.e. not a `.not`) // - no flags to update screenshots const expected = await fs.promises.readFile(helper.snapshotPath); - const { actual, diff, errorMessage, log } = await page._expectScreenshot({ + const { actual, diff, errorMessage, log } = await page._expectScreenshot(customStackTrace, { expected, isNot: false, locator, diff --git a/packages/playwright-test/src/matchers/toMatchText.ts b/packages/playwright-test/src/matchers/toMatchText.ts index ed860b6055..caeceeb44b 100644 --- a/packages/playwright-test/src/matchers/toMatchText.ts +++ b/packages/playwright-test/src/matchers/toMatchText.ts @@ -18,7 +18,7 @@ import type { ExpectedTextValue } from 'playwright-core/lib/protocol/channels'; import { isRegExp, isString } from 'playwright-core/lib/utils/utils'; import type { Expect } from '../types'; -import { expectTypes, callLogText, currentExpectTimeout } from '../util'; +import { expectTypes, callLogText, currentExpectTimeout, captureStackTrace, ParsedStackTrace } from '../util'; import { printReceivedStringContainExpectedResult, printReceivedStringContainExpectedSubstring @@ -29,7 +29,7 @@ export async function toMatchText( matcherName: string, receiver: any, receiverType: string, - query: (isNot: boolean, timeout: number) => Promise<{ matches: boolean, received?: string, log?: string[] }>, + query: (isNot: boolean, timeout: number, customStackTrace: ParsedStackTrace) => Promise<{ matches: boolean, received?: string, log?: string[] }>, expected: string | RegExp, options: { timeout?: number, matchSubstring?: boolean } = {}, ) { @@ -57,7 +57,7 @@ export async function toMatchText( const timeout = currentExpectTimeout(options); - const { matches: pass, received, log } = await query(this.isNot, timeout); + const { matches: pass, received, log } = await query(this.isNot, timeout, captureStackTrace('expect.' + matcherName)); const stringSubstring = options.matchSubstring ? 'substring' : 'string'; const receivedString = received || ''; const message = pass diff --git a/packages/playwright-test/src/util.ts b/packages/playwright-test/src/util.ts index 8244eee7b5..7d249fbaf4 100644 --- a/packages/playwright-test/src/util.ts +++ b/packages/playwright-test/src/util.ts @@ -24,6 +24,9 @@ import debug from 'debug'; import { calculateSha1, isRegExp } from 'playwright-core/lib/utils/utils'; import { isInternalFileName } from 'playwright-core/lib/utils/stackTrace'; import { currentTestInfo } from './globals'; +import { captureStackTrace as coreCaptureStackTrace, ParsedStackTrace } from 'playwright-core/lib/utils/stackTrace'; + +export { ParsedStackTrace }; const PLAYWRIGHT_CORE_PATH = path.dirname(require.resolve('playwright-core')); const EXPECT_PATH = path.dirname(require.resolve('expect')); @@ -60,6 +63,25 @@ function filterStackTrace(e: Error) { Error.prepareStackTrace = oldPrepare; } +export function captureStackTrace(customApiName?: string): ParsedStackTrace { + const stackTrace: ParsedStackTrace = coreCaptureStackTrace(); + const frames = []; + const frameTexts = []; + for (let i = 0; i < stackTrace.frames.length; ++i) { + const frame = stackTrace.frames[i]; + if (frame.file.startsWith(EXPECT_PATH)) + continue; + frames.push(frame); + frameTexts.push(stackTrace.frameTexts[i]); + } + return { + allFrames: stackTrace.allFrames, + frames, + frameTexts, + apiName: customApiName ?? stackTrace.apiName, + }; +} + export function serializeError(error: Error | any): TestError { if (error instanceof Error) { filterStackTrace(error); diff --git a/tests/playwright-test/to-have-screenshot.spec.ts b/tests/playwright-test/to-have-screenshot.spec.ts index 601e3cc18a..3e21226c24 100644 --- a/tests/playwright-test/to-have-screenshot.spec.ts +++ b/tests/playwright-test/to-have-screenshot.spec.ts @@ -288,9 +288,10 @@ test('should fail to screenshot an element with infinite animation', async ({ ru }); expect(result.exitCode).toBe(1); expect(stripAnsi(result.output)).toContain(`Timeout 2000ms exceeded while generating screenshot because element kept changing`); + expect(stripAnsi(result.output)).toContain(`expect.toHaveScreenshot with timeout 2000ms`); + expect(fs.existsSync(testInfo.outputPath('test-results', 'a-is-a-test', 'is-a-test-1-previous.png'))).toBe(true); expect(fs.existsSync(testInfo.outputPath('test-results', 'a-is-a-test', 'is-a-test-1-actual.png'))).toBe(true); expect(fs.existsSync(testInfo.outputPath('test-results', 'a-is-a-test', 'is-a-test-1-expected.png'))).toBe(false); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-is-a-test', 'is-a-test-1-previous.png'))).toBe(true); expect(fs.existsSync(testInfo.outputPath('test-results', 'a-is-a-test', 'is-a-test-1-diff.png'))).toBe(true); expect(fs.existsSync(testInfo.outputPath('__screenshots__', 'a.spec.js', 'is-a-test-1.png'))).toBe(false); });