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`.
This commit is contained in:
Andrey Lushnikov 2022-03-14 19:01:13 -06:00 committed by GitHub
parent e3bd7ce119
commit 88610c8b4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 145 additions and 133 deletions

View file

@ -101,14 +101,14 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
return channel; return channel;
} }
async _wrapApiCall<R>(func: (apiZone: ApiZone) => Promise<R>, isInternal = false): Promise<R> { async _wrapApiCall<R>(func: (apiZone: ApiZone) => Promise<R>, isInternal = false, customStackTrace?: ParsedStackTrace): Promise<R> {
const logger = this._logger; const logger = this._logger;
const stack = captureRawStack(); const stack = captureRawStack();
const apiZone = zones.zoneData<ApiZone>('apiZone', stack); const apiZone = zones.zoneData<ApiZone>('apiZone', stack);
if (apiZone) if (apiZone)
return func(apiZone); return func(apiZone);
const stackTrace = captureStackTrace(stack); const stackTrace = customStackTrace || captureStackTrace(stack);
if (isInternal) if (isInternal)
delete stackTrace.apiName; delete stackTrace.apiName;
const csi = isInternal ? undefined : this._instrumentation; const csi = isInternal ? undefined : this._instrumentation;

View file

@ -17,6 +17,7 @@
import * as structs from '../../types/structs'; import * as structs from '../../types/structs';
import * as api from '../../types/types'; import * as api from '../../types/types';
import * as channels from '../protocol/channels'; import * as channels from '../protocol/channels';
import type { ParsedStackTrace } from '../utils/stackTrace';
import * as util from 'util'; import * as util from 'util';
import { isRegExp, monotonicTime } from '../utils/utils'; import { isRegExp, monotonicTime } from '../utils/utils';
import { ElementHandle } from './elementHandle'; 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 }); await this._frame._channel.waitForSelector({ selector: this._selector, strict: true, omitReturnValue: true, ...options });
} }
async _expect(expression: string, options: Omit<FrameExpectOptions, 'expectedValue'> & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[] }> { async _expect(customStackTrace: ParsedStackTrace, expression: string, options: Omit<FrameExpectOptions, 'expectedValue'> & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[] }> {
const params: channels.FrameExpectParams = { selector: this._selector, expression, ...options, isNot: !!options.isNot }; return this._frame._wrapApiCall(async () => {
if (options.expectedValue) const params: channels.FrameExpectParams = { selector: this._selector, expression, ...options, isNot: !!options.isNot };
params.expectedValue = serializeArgument(options.expectedValue); if (options.expectedValue)
const result = (await this._frame._channel.expect(params)); params.expectedValue = serializeArgument(options.expectedValue);
if (result.received !== undefined) const result = (await this._frame._channel.expect(params));
result.received = parseResult(result.received); if (result.received !== undefined)
return result; result.received = parseResult(result.received);
return result;
}, false /* isInternal */, customStackTrace);
} }
[util.inspect.custom]() { [util.inspect.custom]() {

View file

@ -18,6 +18,7 @@
import { Events } from './events'; import { Events } from './events';
import { assert } from '../utils/utils'; import { assert } from '../utils/utils';
import { TimeoutSettings } from '../utils/timeoutSettings'; import { TimeoutSettings } from '../utils/timeoutSettings';
import type { ParsedStackTrace } from '../utils/stackTrace';
import * as channels from '../protocol/channels'; import * as channels from '../protocol/channels';
import { parseError, serializeError } from '../protocol/serializers'; import { parseError, serializeError } from '../protocol/serializers';
import { Accessibility } from './accessibility'; import { Accessibility } from './accessibility';
@ -483,34 +484,36 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
return buffer; return buffer;
} }
async _expectScreenshot(options: ExpectScreenshotOptions): Promise<{ actual?: Buffer, previous?: Buffer, diff?: Buffer, errorMessage?: string, log?: string[]}> { async _expectScreenshot(customStackTrace: ParsedStackTrace, options: ExpectScreenshotOptions): Promise<{ actual?: Buffer, previous?: Buffer, diff?: Buffer, errorMessage?: string, log?: string[]}> {
const mask = options.screenshotOptions?.mask ? options.screenshotOptions?.mask.map(locator => ({ return this._wrapApiCall(async () => {
frame: locator._frame._channel, const mask = options.screenshotOptions?.mask ? options.screenshotOptions?.mask.map(locator => ({
selector: locator._selector, frame: locator._frame._channel,
})) : undefined; selector: locator._selector,
const locator = options.locator ? { })) : undefined;
frame: options.locator._frame._channel, const locator = options.locator ? {
selector: options.locator._selector, frame: options.locator._frame._channel,
} : undefined; selector: options.locator._selector,
const expected = options.expected ? options.expected.toString('base64') : undefined; } : undefined;
const expected = options.expected ? options.expected.toString('base64') : undefined;
const result = await this._channel.expectScreenshot({ const result = await this._channel.expectScreenshot({
...options, ...options,
isNot: !!options.isNot, isNot: !!options.isNot,
expected, expected,
locator, locator,
screenshotOptions: { screenshotOptions: {
...options.screenshotOptions, ...options.screenshotOptions,
mask, mask,
} }
}); });
return { return {
log: result.log, log: result.log,
actual: result.actual ? Buffer.from(result.actual, 'base64') : undefined, actual: result.actual ? Buffer.from(result.actual, 'base64') : undefined,
previous: result.previous ? Buffer.from(result.previous, 'base64') : undefined, previous: result.previous ? Buffer.from(result.previous, 'base64') : undefined,
diff: result.diff ? Buffer.from(result.diff, 'base64') : undefined, diff: result.diff ? Buffer.from(result.diff, 'base64') : undefined,
errorMessage: result.errorMessage, errorMessage: result.errorMessage,
}; };
}, false /* isInternal */, customStackTrace);
} }
async title(): Promise<string> { async title(): Promise<string> {

View file

@ -108,28 +108,14 @@ export function captureStackTrace(rawStack?: string): ParsedStackTrace {
let apiName = ''; let apiName = '';
const allFrames = parsedFrames; const allFrames = parsedFrames;
// Deepest transition between non-client code calling into client code
// expect matchers have the following stack structure: // is the api entry.
// at Object.__PWTRAP__[expect.toHaveText] (...) for (let i = 0; i < parsedFrames.length - 1; i++) {
// at __EXTERNAL_MATCHER_TRAP__ (...) if (parsedFrames[i].inCore && !parsedFrames[i + 1].inCore) {
// at Object.throwingMatcher [as toHaveText] (...) const frame = parsedFrames[i].frame;
const TRAP = '__PWTRAP__['; apiName = normalizeAPIName(frame.function);
const expectIndex = parsedFrames.findIndex(f => f.frameText.includes(TRAP)); parsedFrames = parsedFrames.slice(i + 1);
if (expectIndex !== -1) { break;
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;
}
} }
} }

View file

@ -47,10 +47,7 @@ import { toMatchSnapshot, toHaveScreenshot, getSnapshotName } from './matchers/t
import type { Expect, TestError } from './types'; import type { Expect, TestError } from './types';
import matchers from 'expect/build/matchers'; import matchers from 'expect/build/matchers';
import { currentTestInfo } from './globals'; import { currentTestInfo } from './globals';
import { serializeError } from './util'; import { serializeError, captureStackTrace } from './util';
import StackUtils from 'stack-utils';
const stackUtils = new StackUtils();
// #region // #region
// Mirrored from https://github.com/facebook/jest/blob/f13abff8df9a0e1148baf3584bcde6d1b479edc7/packages/expect/src/print.ts // 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) { function wrap(matcherName: string, matcher: any) {
const result = function(this: any, ...args: any[]) { return function(this: any, ...args: any[]) {
const testInfo = currentTestInfo(); const testInfo = currentTestInfo();
if (!testInfo) if (!testInfo)
return matcher.call(this, ...args); return matcher.call(this, ...args);
@ -194,15 +191,9 @@ function wrap(matcherName: string, matcher: any) {
const [received, nameOrOptions, optOptions] = args; const [received, nameOrOptions, optOptions] = args;
titleSuffix = `(${getSnapshotName(testInfo, received, nameOrOptions, optOptions)})`; titleSuffix = `(${getSnapshotName(testInfo, received, nameOrOptions, optOptions)})`;
} }
const stackTrace = captureStackTrace();
const INTERNAL_STACK_LENGTH = 4; const stackLines = stackTrace.frameTexts;
// at Object.__PWTRAP__[expect.toHaveText] (...) const frame = stackTrace.frames[0];
// at __EXTERNAL_MATCHER_TRAP__ (...)
// at Object.throwingMatcher [as toHaveText] (...)
// at Proxy.<anonymous>
// at <test function> (...)
const stackLines = new Error().stack!.split('\n').slice(INTERNAL_STACK_LENGTH + 1);
const frame = stackLines[0] ? stackUtils.parseLine(stackLines[0]) : undefined;
const customMessage = expectCallMetaInfo?.message ?? ''; const customMessage = expectCallMetaInfo?.message ?? '';
const isSoft = expectCallMetaInfo?.isSoft ?? false; const isSoft = expectCallMetaInfo?.isSoft ?? false;
const step = testInfo._addStep({ const step = testInfo._addStep({
@ -257,8 +248,6 @@ function wrap(matcherName: string, matcher: any) {
reportStepError(e); reportStepError(e);
} }
}; };
Object.defineProperty(result, 'name', { value: '__PWTRAP__[expect.' + matcherName + ']' });
return result;
} }
const wrappedMatchers: any = {}; const wrappedMatchers: any = {};

View file

@ -22,9 +22,10 @@ import { expectTypes, callLogText } from '../util';
import { toBeTruthy } from './toBeTruthy'; import { toBeTruthy } from './toBeTruthy';
import { toEqual } from './toEqual'; import { toEqual } from './toEqual';
import { toExpectedTextValues, toMatchText } from './toMatchText'; import { toExpectedTextValues, toMatchText } from './toMatchText';
import { ParsedStackTrace } from 'playwright-core/lib/utils/stackTrace';
interface LocatorEx extends Locator { interface LocatorEx extends Locator {
_expect(expression: string, options: Omit<FrameExpectOptions, 'expectedValue'> & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[] }>; _expect(customStackTrace: ParsedStackTrace, expression: string, options: Omit<FrameExpectOptions, 'expectedValue'> & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[] }>;
} }
interface APIResponseEx extends APIResponse { interface APIResponseEx extends APIResponse {
@ -36,9 +37,9 @@ export function toBeChecked(
locator: LocatorEx, locator: LocatorEx,
options?: { checked?: boolean, timeout?: number }, 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; 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); }, options);
} }
@ -47,8 +48,8 @@ export function toBeDisabled(
locator: LocatorEx, locator: LocatorEx,
options?: { timeout?: number }, options?: { timeout?: number },
) { ) {
return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', async (isNot, timeout) => { return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', async (isNot, timeout, customStackTrace) => {
return await locator._expect('to.be.disabled', { isNot, timeout }); return await locator._expect(customStackTrace, 'to.be.disabled', { isNot, timeout });
}, options); }, options);
} }
@ -57,8 +58,8 @@ export function toBeEditable(
locator: LocatorEx, locator: LocatorEx,
options?: { timeout?: number }, options?: { timeout?: number },
) { ) {
return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', async (isNot, timeout) => { return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', async (isNot, timeout, customStackTrace) => {
return await locator._expect('to.be.editable', { isNot, timeout }); return await locator._expect(customStackTrace, 'to.be.editable', { isNot, timeout });
}, options); }, options);
} }
@ -67,8 +68,8 @@ export function toBeEmpty(
locator: LocatorEx, locator: LocatorEx,
options?: { timeout?: number }, options?: { timeout?: number },
) { ) {
return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', async (isNot, timeout) => { return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', async (isNot, timeout, customStackTrace) => {
return await locator._expect('to.be.empty', { isNot, timeout }); return await locator._expect(customStackTrace, 'to.be.empty', { isNot, timeout });
}, options); }, options);
} }
@ -77,8 +78,8 @@ export function toBeEnabled(
locator: LocatorEx, locator: LocatorEx,
options?: { timeout?: number }, options?: { timeout?: number },
) { ) {
return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', async (isNot, timeout) => { return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', async (isNot, timeout, customStackTrace) => {
return await locator._expect('to.be.enabled', { isNot, timeout }); return await locator._expect(customStackTrace, 'to.be.enabled', { isNot, timeout });
}, options); }, options);
} }
@ -87,8 +88,8 @@ export function toBeFocused(
locator: LocatorEx, locator: LocatorEx,
options?: { timeout?: number }, options?: { timeout?: number },
) { ) {
return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', async (isNot, timeout) => { return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', async (isNot, timeout, customStackTrace) => {
return await locator._expect('to.be.focused', { isNot, timeout }); return await locator._expect(customStackTrace, 'to.be.focused', { isNot, timeout });
}, options); }, options);
} }
@ -97,8 +98,8 @@ export function toBeHidden(
locator: LocatorEx, locator: LocatorEx,
options?: { timeout?: number }, options?: { timeout?: number },
) { ) {
return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', async (isNot, timeout) => { return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', async (isNot, timeout, customStackTrace) => {
return await locator._expect('to.be.hidden', { isNot, timeout }); return await locator._expect(customStackTrace, 'to.be.hidden', { isNot, timeout });
}, options); }, options);
} }
@ -107,8 +108,8 @@ export function toBeVisible(
locator: LocatorEx, locator: LocatorEx,
options?: { timeout?: number }, options?: { timeout?: number },
) { ) {
return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', async (isNot, timeout) => { return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', async (isNot, timeout, customStackTrace) => {
return await locator._expect('to.be.visible', { isNot, timeout }); return await locator._expect(customStackTrace, 'to.be.visible', { isNot, timeout });
}, options); }, options);
} }
@ -119,14 +120,14 @@ export function toContainText(
options?: { timeout?: number, useInnerText?: boolean }, options?: { timeout?: number, useInnerText?: boolean },
) { ) {
if (Array.isArray(expected)) { 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 }); 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 }); }, expected, { ...options, contains: true });
} else { } 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 }); 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); }, expected, options);
} }
} }
@ -138,9 +139,9 @@ export function toHaveAttribute(
expected: string | RegExp, expected: string | RegExp,
options?: { timeout?: number }, 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]); 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); }, expected, options);
} }
@ -151,14 +152,14 @@ export function toHaveClass(
options?: { timeout?: number }, options?: { timeout?: number },
) { ) {
if (Array.isArray(expected)) { 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); 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); }, expected, options);
} else { } 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]); 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); }, expected, options);
} }
} }
@ -169,8 +170,8 @@ export function toHaveCount(
expected: number, expected: number,
options?: { timeout?: number }, options?: { timeout?: number },
) { ) {
return toEqual.call(this, 'toHaveCount', locator, 'Locator', async (isNot, timeout) => { return toEqual.call(this, 'toHaveCount', locator, 'Locator', async (isNot, timeout, customStackTrace) => {
return await locator._expect('to.have.count', { expectedNumber: expected, isNot, timeout }); return await locator._expect(customStackTrace, 'to.have.count', { expectedNumber: expected, isNot, timeout });
}, expected, options); }, expected, options);
} }
@ -181,9 +182,9 @@ export function toHaveCSS(
expected: string | RegExp, expected: string | RegExp,
options?: { timeout?: number }, 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]); 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); }, expected, options);
} }
@ -193,9 +194,9 @@ export function toHaveId(
expected: string | RegExp, expected: string | RegExp,
options?: { timeout?: number }, 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]); 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); }, expected, options);
} }
@ -206,8 +207,8 @@ export function toHaveJSProperty(
expected: any, expected: any,
options?: { timeout?: number }, options?: { timeout?: number },
) { ) {
return toEqual.call(this, 'toHaveJSProperty', locator, 'Locator', async (isNot, timeout) => { return toEqual.call(this, 'toHaveJSProperty', locator, 'Locator', async (isNot, timeout, customStackTrace) => {
return await locator._expect('to.have.property', { expressionArg: name, expectedValue: expected, isNot, timeout }); return await locator._expect(customStackTrace, 'to.have.property', { expressionArg: name, expectedValue: expected, isNot, timeout });
}, expected, options); }, expected, options);
} }
@ -218,14 +219,14 @@ export function toHaveText(
options: { timeout?: number, useInnerText?: boolean } = {}, options: { timeout?: number, useInnerText?: boolean } = {},
) { ) {
if (Array.isArray(expected)) { 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 }); 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); }, expected, options);
} else { } 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 }); 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); }, expected, options);
} }
} }
@ -236,9 +237,9 @@ export function toHaveValue(
expected: string | RegExp, expected: string | RegExp,
options?: { timeout?: number }, 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]); 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); }, expected, options);
} }
@ -249,9 +250,9 @@ export function toHaveTitle(
options: { timeout?: number } = {}, options: { timeout?: number } = {},
) { ) {
const locator = page.locator(':root') as LocatorEx; 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 }); 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); }, expected, options);
} }
@ -264,9 +265,9 @@ export function toHaveURL(
const baseURL = (page.context() as any)._options.baseURL; const baseURL = (page.context() as any)._options.baseURL;
expected = typeof expected === 'string' ? constructURLBasedOnBaseURL(baseURL, expected) : expected; expected = typeof expected === 'string' ? constructURLBasedOnBaseURL(baseURL, expected) : expected;
const locator = page.locator(':root') as LocatorEx; 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]); 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); }, expected, options);
} }

View file

@ -15,14 +15,14 @@
*/ */
import type { Expect } from '../types'; import type { Expect } from '../types';
import { expectTypes, callLogText, currentExpectTimeout } from '../util'; import { expectTypes, callLogText, currentExpectTimeout, ParsedStackTrace, captureStackTrace } from '../util';
export async function toBeTruthy( export async function toBeTruthy(
this: ReturnType<Expect['getState']>, this: ReturnType<Expect['getState']>,
matcherName: string, matcherName: string,
receiver: any, receiver: any,
receiverType: string, 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 } = {}, options: { timeout?: number } = {},
) { ) {
expectTypes(receiver, [receiverType], matcherName); expectTypes(receiver, [receiverType], matcherName);
@ -34,7 +34,7 @@ export async function toBeTruthy(
const timeout = currentExpectTimeout(options); 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 = () => { const message = () => {
return this.utils.matcherHint(matcherName, undefined, '', matcherOptions) + callLogText(log); return this.utils.matcherHint(matcherName, undefined, '', matcherOptions) + callLogText(log);

View file

@ -17,6 +17,7 @@
import type { Expect } from '../types'; import type { Expect } from '../types';
import { expectTypes } from '../util'; import { expectTypes } from '../util';
import { callLogText, currentExpectTimeout } 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. // Omit colon and one or more spaces, so can call getLabelPrinter.
const EXPECTED_LABEL = 'Expected'; const EXPECTED_LABEL = 'Expected';
@ -30,7 +31,7 @@ export async function toEqual<T>(
matcherName: string, matcherName: string,
receiver: any, receiver: any,
receiverType: string, 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, expected: T,
options: { timeout?: number, contains?: boolean } = {}, options: { timeout?: number, contains?: boolean } = {},
) { ) {
@ -44,7 +45,9 @@ export async function toEqual<T>(
const timeout = currentExpectTimeout(options); 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 const message = pass
? () => ? () =>

View file

@ -21,7 +21,10 @@ import type { Expect } from '../types';
import { currentTestInfo } from '../globals'; import { currentTestInfo } from '../globals';
import { mimeTypeToComparator, ImageComparatorOptions, Comparator } from 'playwright-core/lib/utils/comparators'; import { mimeTypeToComparator, ImageComparatorOptions, Comparator } from 'playwright-core/lib/utils/comparators';
import type { PageScreenshotOptions } from 'playwright-core/types/types'; 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 { UpdateSnapshots } from '../types';
import colors from 'colors/safe'; import colors from 'colors/safe';
import fs from 'fs'; import fs from 'fs';
@ -310,6 +313,7 @@ export async function toHaveScreenshot(
maxDiffPixelRatio: undefined, maxDiffPixelRatio: undefined,
}; };
const customStackTrace = captureStackTrace(`expect.toHaveScreenshot`);
const hasSnapshot = fs.existsSync(helper.snapshotPath); const hasSnapshot = fs.existsSync(helper.snapshotPath);
if (this.isNot) { if (this.isNot) {
if (!hasSnapshot) if (!hasSnapshot)
@ -318,7 +322,7 @@ export async function toHaveScreenshot(
// Having `errorMessage` means we timed out while waiting // Having `errorMessage` means we timed out while waiting
// for screenshots not to match, so screenshots // for screenshots not to match, so screenshots
// are actually the same in the end. // 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), expected: await fs.promises.readFile(helper.snapshotPath),
isNot: true, isNot: true,
locator, locator,
@ -336,7 +340,7 @@ export async function toHaveScreenshot(
if (helper.updateSnapshots === 'all' || !hasSnapshot) { if (helper.updateSnapshots === 'all' || !hasSnapshot) {
// Regenerate a new screenshot by waiting until two screenshots are the same. // Regenerate a new screenshot by waiting until two screenshots are the same.
const timeout = currentExpectTimeout(helper.allOptions); 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, expected: undefined,
isNot: false, isNot: false,
locator, locator,
@ -371,7 +375,7 @@ export async function toHaveScreenshot(
// - regular matcher (i.e. not a `.not`) // - regular matcher (i.e. not a `.not`)
// - no flags to update screenshots // - no flags to update screenshots
const expected = await fs.promises.readFile(helper.snapshotPath); 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, expected,
isNot: false, isNot: false,
locator, locator,

View file

@ -18,7 +18,7 @@
import type { ExpectedTextValue } from 'playwright-core/lib/protocol/channels'; import type { ExpectedTextValue } from 'playwright-core/lib/protocol/channels';
import { isRegExp, isString } from 'playwright-core/lib/utils/utils'; import { isRegExp, isString } from 'playwright-core/lib/utils/utils';
import type { Expect } from '../types'; import type { Expect } from '../types';
import { expectTypes, callLogText, currentExpectTimeout } from '../util'; import { expectTypes, callLogText, currentExpectTimeout, captureStackTrace, ParsedStackTrace } from '../util';
import { import {
printReceivedStringContainExpectedResult, printReceivedStringContainExpectedResult,
printReceivedStringContainExpectedSubstring printReceivedStringContainExpectedSubstring
@ -29,7 +29,7 @@ export async function toMatchText(
matcherName: string, matcherName: string,
receiver: any, receiver: any,
receiverType: string, 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, expected: string | RegExp,
options: { timeout?: number, matchSubstring?: boolean } = {}, options: { timeout?: number, matchSubstring?: boolean } = {},
) { ) {
@ -57,7 +57,7 @@ export async function toMatchText(
const timeout = currentExpectTimeout(options); 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 stringSubstring = options.matchSubstring ? 'substring' : 'string';
const receivedString = received || ''; const receivedString = received || '';
const message = pass const message = pass

View file

@ -24,6 +24,9 @@ import debug from 'debug';
import { calculateSha1, isRegExp } from 'playwright-core/lib/utils/utils'; import { calculateSha1, isRegExp } from 'playwright-core/lib/utils/utils';
import { isInternalFileName } from 'playwright-core/lib/utils/stackTrace'; import { isInternalFileName } from 'playwright-core/lib/utils/stackTrace';
import { currentTestInfo } from './globals'; 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 PLAYWRIGHT_CORE_PATH = path.dirname(require.resolve('playwright-core'));
const EXPECT_PATH = path.dirname(require.resolve('expect')); const EXPECT_PATH = path.dirname(require.resolve('expect'));
@ -60,6 +63,25 @@ function filterStackTrace(e: Error) {
Error.prepareStackTrace = oldPrepare; 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 { export function serializeError(error: Error | any): TestError {
if (error instanceof Error) { if (error instanceof Error) {
filterStackTrace(error); filterStackTrace(error);

View file

@ -288,9 +288,10 @@ test('should fail to screenshot an element with infinite animation', async ({ ru
}); });
expect(result.exitCode).toBe(1); 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(`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-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-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('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); expect(fs.existsSync(testInfo.outputPath('__screenshots__', 'a.spec.js', 'is-a-test-1.png'))).toBe(false);
}); });