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;
}
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 stack = captureRawStack();
const apiZone = zones.zoneData<ApiZone>('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;

View File

@ -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<FrameExpectOptions, 'expectedValue'> & { 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<FrameExpectOptions, 'expectedValue'> & { 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]() {

View File

@ -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<channels.PageChannel> 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<string> {

View File

@ -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;
}
}

View File

@ -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.<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 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 = {};

View File

@ -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<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 {
@ -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);
}

View File

@ -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<Expect['getState']>,
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);

View File

@ -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<T>(
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<T>(
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
? () =>

View File

@ -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,

View File

@ -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

View File

@ -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);

View File

@ -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);
});