diff --git a/packages/playwright-core/src/client/channelOwner.ts b/packages/playwright-core/src/client/channelOwner.ts index 0fe9ffd0ce..aa3dac12c3 100644 --- a/packages/playwright-core/src/client/channelOwner.ts +++ b/packages/playwright-core/src/client/channelOwner.ts @@ -154,14 +154,14 @@ export abstract class ChannelOwner(func: (apiZone: ApiZone) => Promise, isInternal = false, customStackTrace?: ParsedStackTrace): Promise { + async _wrapApiCall(func: (apiZone: ApiZone) => Promise, isInternal = false): Promise { const logger = this._logger; const stack = captureRawStack(); const apiZone = zones.zoneData('apiZone', stack); if (apiZone) return func(apiZone); - const stackTrace = customStackTrace || captureStackTrace(stack); + const stackTrace = 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 1e31261981..295fed4ed0 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -17,7 +17,6 @@ import type * as structs from '../../types/structs'; import type * as api from '../../types/types'; import type * as channels from '@protocol/channels'; -import type { ParsedStackTrace } from '../utils/stackTrace'; import * as util from 'util'; import { monotonicTime } from '../utils'; import { ElementHandle } from './elementHandle'; @@ -310,15 +309,13 @@ export class Locator implements api.Locator { await this._frame._channel.waitForSelector({ selector: this._selector, strict: true, omitReturnValue: true, ...options }); } - async _expect(customStackTrace: ParsedStackTrace, expression: string, options: Omit & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> { - return this._frame._wrapApiCall(async () => { - const params: channels.FrameExpectParams = { selector: this._selector, expression, ...options, isNot: !!options.isNot }; - 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); + async _expect(expression: string, options: Omit & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> { + const params: channels.FrameExpectParams = { selector: this._selector, expression, ...options, isNot: !!options.isNot }; + params.expectedValue = serializeArgument(options.expectedValue); + const result = (await this._frame._channel.expect(params)); + if (result.received !== undefined) + result.received = parseResult(result.received); + return result; } [util.inspect.custom]() { diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 733c5a6b70..8001bf059c 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -26,7 +26,6 @@ import type * as channels from '@protocol/channels'; import { parseError, serializeError } from '../protocol/serializers'; import { assert, headersObjectToArray, isObject, isRegExp, isString } from '../utils'; import { mkdirIfNeeded } from '../utils/fileUtils'; -import type { ParsedStackTrace } from '../utils/stackTrace'; import { Accessibility } from './accessibility'; import { Artifact } from './artifact'; import type { BrowserContext } from './browserContext'; @@ -498,26 +497,24 @@ export class Page extends ChannelOwner implements api.Page return result.binary; } - 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; - return await this._channel.expectScreenshot({ - ...options, - isNot: !!options.isNot, - locator, - screenshotOptions: { - ...options.screenshotOptions, - mask, - } - }); - }, false /* isInternal */, customStackTrace); + 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; + return await this._channel.expectScreenshot({ + ...options, + isNot: !!options.isNot, + locator, + screenshotOptions: { + ...options.screenshotOptions, + mask, + } + }); } async title(): Promise { diff --git a/packages/playwright-core/src/utils/stackTrace.ts b/packages/playwright-core/src/utils/stackTrace.ts index d0a82c61eb..8aa6c69ba0 100644 --- a/packages/playwright-core/src/utils/stackTrace.ts +++ b/packages/playwright-core/src/utils/stackTrace.ts @@ -28,14 +28,12 @@ export function rewriteErrorMessage(e: E, newMessage: string): } const CORE_DIR = path.resolve(__dirname, '..', '..'); -const CORE_LIB = path.join(CORE_DIR, 'lib'); -const CORE_SRC = path.join(CORE_DIR, 'src'); const COVERAGE_PATH = path.join(CORE_DIR, '..', '..', 'tests', 'config', 'coverage.js'); -const stackIgnoreFilters = [ - (frame: StackFrame) => frame.file.startsWith(CORE_DIR), +const internalStackPrefixes = [ + CORE_DIR, ]; -export const addStackIgnoreFilter = (filter: (frame: StackFrame) => boolean) => stackIgnoreFilters.push(filter); +export const addInternalStackPrefix = (prefix: string) => internalStackPrefixes.push(prefix); export type StackFrame = { file: string, @@ -60,7 +58,7 @@ export function captureRawStack(): string { return stack; } -export function isInternalFileName(file: string, functionName?: string): boolean { +function isInternalFileName(file: string, functionName?: string): boolean { // Node 16+ has node:internal. if (file.startsWith('internal') || file.startsWith('node:')) return true; @@ -77,7 +75,7 @@ export function captureStackTrace(rawStack?: string): ParsedStackTrace { type ParsedFrame = { frame: StackFrame; frameText: string; - inCore: boolean; + isPlaywrightLibrary: boolean; }; let parsedFrames = stack.split('\n').map(line => { const { frame, fileName } = parseStackTraceLine(line); @@ -87,7 +85,7 @@ export function captureStackTrace(rawStack?: string): ParsedStackTrace { return null; if (!process.env.PWDEBUGIMPL && isTesting && fileName.includes(COVERAGE_PATH)) return null; - const inCore = fileName.startsWith(CORE_LIB) || fileName.startsWith(CORE_SRC); + const isPlaywrightLibrary = fileName.startsWith(CORE_DIR); const parsed: ParsedFrame = { frame: { file: fileName, @@ -96,21 +94,29 @@ export function captureStackTrace(rawStack?: string): ParsedStackTrace { function: frame.function, }, frameText: line, - inCore + isPlaywrightLibrary }; return parsed; }).filter(Boolean) as ParsedFrame[]; let apiName = ''; const allFrames = parsedFrames; - // Deepest transition between non-client code calling into client code - // is the api entry. + + // Use stack trap for the API annotation, if available. + for (let i = parsedFrames.length - 1; i >= 0; i--) { + const parsedFrame = parsedFrames[i]; + if (parsedFrame.frame.function?.startsWith('__PWTRAP__[')) { + apiName = parsedFrame.frame.function!.substring('__PWTRAP__['.length, parsedFrame.frame.function!.length - 1); + break; + } + } + + // Otherwise, 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); - if (!process.env.PWDEBUGIMPL) - parsedFrames = parsedFrames.slice(i + 1); + const parsedFrame = parsedFrames[i]; + if (parsedFrame.isPlaywrightLibrary && !parsedFrames[i + 1].isPlaywrightLibrary) { + apiName = apiName || normalizeAPIName(parsedFrame.frame.function); break; } } @@ -124,11 +130,11 @@ export function captureStackTrace(rawStack?: string): ParsedStackTrace { return match[1].toLowerCase() + match[2]; } - // Hide all test runner and library frames in the user stack (event handlers produce them). - parsedFrames = parsedFrames.filter((f, i) => { + // This is for the inspector so that it did not include the test runner stack frames. + parsedFrames = parsedFrames.filter(f => { if (process.env.PWDEBUGIMPL) return true; - if (stackIgnoreFilters.some(filter => filter(f.frame))) + if (internalStackPrefixes.some(prefix => f.frame.file.startsWith(prefix))) return false; return true; }); diff --git a/packages/playwright-test/src/index.ts b/packages/playwright-test/src/index.ts index c7c47e5d12..a925723017 100644 --- a/packages/playwright-test/src/index.ts +++ b/packages/playwright-test/src/index.ts @@ -18,7 +18,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { APIRequestContext, BrowserContext, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core'; import * as playwrightLibrary from 'playwright-core'; -import { createGuid, debugMode, removeFolders, addStackIgnoreFilter } from 'playwright-core/lib/utils'; +import { createGuid, debugMode, removeFolders, addInternalStackPrefix } from 'playwright-core/lib/utils'; import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, TraceMode, VideoMode } from '../types/test'; import type { TestInfoImpl } from './worker/testInfo'; import { rootTestType } from './common/testType'; @@ -27,7 +27,7 @@ export { expect } from './matchers/expect'; export { store } from './store'; export const _baseTest: TestType<{}, {}> = rootTestType.test; -addStackIgnoreFilter((frame: StackFrame) => frame.file.startsWith(path.dirname(require.resolve('../package.json')))); +addInternalStackPrefix(path.dirname(require.resolve('../package.json'))); if ((process as any)['__pw_initiator__']) { const originalStackTraceLimit = Error.stackTraceLimit; diff --git a/packages/playwright-test/src/matchers/expect.ts b/packages/playwright-test/src/matchers/expect.ts index 12e29b2c1a..a1175a20e3 100644 --- a/packages/playwright-test/src/matchers/expect.ts +++ b/packages/playwright-test/src/matchers/expect.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { pollAgainstTimeout } from 'playwright-core/lib/utils'; +import { captureStackTrace, pollAgainstTimeout } from 'playwright-core/lib/utils'; import path from 'path'; import { toBeChecked, @@ -44,7 +44,7 @@ import { import { toMatchSnapshot, toHaveScreenshot } from './toMatchSnapshot'; import type { Expect } from '../common/types'; import { currentTestInfo, currentExpectTimeout } from '../common/globals'; -import { serializeError, captureStackTrace, trimLongString } from '../util'; +import { serializeError, trimLongString } from '../util'; import { expect as expectLibrary, INVERTED_COLOR, @@ -157,6 +157,7 @@ type ExpectMetaInfo = { isNot: boolean; isSoft: boolean; isPoll: boolean; + nameTokens: string[]; pollTimeout?: number; pollIntervals?: number[]; generator?: Generator; @@ -166,7 +167,7 @@ class ExpectMetaInfoProxyHandler { private _info: ExpectMetaInfo; constructor(messageOrOptions: ExpectMessageOrOptions, isSoft: boolean, isPoll: boolean, generator?: Generator) { - this._info = { isSoft, isPoll, generator, isNot: false }; + this._info = { isSoft, isPoll, generator, isNot: false, nameTokens: [] }; if (typeof messageOrOptions === 'string') { this._info.message = messageOrOptions; } else { @@ -224,12 +225,12 @@ class ExpectMetaInfoProxyHandler { messageLines.splice(uselessMatcherLineIndex, 1); } const newMessage = [ - 'Error: ' + customMessage, + customMessage, '', ...messageLines, ].join('\n'); jestError.message = newMessage; - jestError.stack = newMessage + '\n' + stackLines.join('\n'); + jestError.stack = jestError.name + ': ' + newMessage + '\n' + stackLines.join('\n'); } const serializerError = serializeError(jestError); @@ -241,8 +242,10 @@ class ExpectMetaInfoProxyHandler { }; try { - const result = matcher.call(target, ...args); - if ((result instanceof Promise)) + const result = namedFunction(defaultTitle)(() => { + return matcher.call(target, ...args); + }); + if (result instanceof Promise) return result.then(() => step.complete({})).catch(reportStepError); else step.complete({}); @@ -253,6 +256,14 @@ class ExpectMetaInfoProxyHandler { } } +function namedFunction(name: string) { + const result = function(callback: any) { + return callback(); + }; + Object.defineProperty(result, 'name', { value: '__PWTRAP__[' + name + ']' }); + return result; +} + async function pollMatcher(matcherName: any, isNot: boolean, pollIntervals: number[] | undefined, timeout: number, generator: () => any, ...args: any[]) { const result = await pollAgainstTimeout(async () => { const value = await generator(); diff --git a/packages/playwright-test/src/matchers/matchers.ts b/packages/playwright-test/src/matchers/matchers.ts index 9d80a038b7..6228fd332e 100644 --- a/packages/playwright-test/src/matchers/matchers.ts +++ b/packages/playwright-test/src/matchers/matchers.ts @@ -24,11 +24,10 @@ import type { TestInfoErrorState } from '../worker/testInfo'; import { toBeTruthy } from './toBeTruthy'; import { toEqual } from './toEqual'; import { toExpectedTextValues, toMatchText } from './toMatchText'; -import type { ParsedStackTrace } from 'playwright-core/lib/utils'; import { constructURLBasedOnBaseURL, isTextualMimeType, pollAgainstTimeout } from 'playwright-core/lib/utils'; interface LocatorEx extends Locator { - _expect(customStackTrace: ParsedStackTrace, expression: string, options: Omit & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>; + _expect(expression: string, options: Omit & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>; } interface APIResponseEx extends APIResponse { @@ -40,9 +39,9 @@ export function toBeChecked( locator: LocatorEx, options?: { checked?: boolean, timeout?: number }, ) { - return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', async (isNot, timeout) => { const checked = !options || options.checked === undefined || options.checked === true; - return await locator._expect(customStackTrace, checked ? 'to.be.checked' : 'to.be.unchecked', { isNot, timeout }); + return await locator._expect(checked ? 'to.be.checked' : 'to.be.unchecked', { isNot, timeout }); }, options); } @@ -51,8 +50,8 @@ export function toBeDisabled( locator: LocatorEx, options?: { timeout?: number }, ) { - return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', async (isNot, timeout, customStackTrace) => { - return await locator._expect(customStackTrace, 'to.be.disabled', { isNot, timeout }); + return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', async (isNot, timeout) => { + return await locator._expect('to.be.disabled', { isNot, timeout }); }, options); } @@ -61,9 +60,9 @@ export function toBeEditable( locator: LocatorEx, options?: { editable?: boolean, timeout?: number }, ) { - return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', async (isNot, timeout) => { const editable = !options || options.editable === undefined || options.editable === true; - return await locator._expect(customStackTrace, editable ? 'to.be.editable' : 'to.be.readonly', { isNot, timeout }); + return await locator._expect(editable ? 'to.be.editable' : 'to.be.readonly', { isNot, timeout }); }, options); } @@ -72,8 +71,8 @@ export function toBeEmpty( locator: LocatorEx, options?: { timeout?: number }, ) { - return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', async (isNot, timeout, customStackTrace) => { - return await locator._expect(customStackTrace, 'to.be.empty', { isNot, timeout }); + return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', async (isNot, timeout) => { + return await locator._expect('to.be.empty', { isNot, timeout }); }, options); } @@ -82,9 +81,9 @@ export function toBeEnabled( locator: LocatorEx, options?: { enabled?: boolean, timeout?: number }, ) { - return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', async (isNot, timeout) => { const enabled = !options || options.enabled === undefined || options.enabled === true; - return await locator._expect(customStackTrace, enabled ? 'to.be.enabled' : 'to.be.disabled', { isNot, timeout }); + return await locator._expect(enabled ? 'to.be.enabled' : 'to.be.disabled', { isNot, timeout }); }, options); } @@ -93,8 +92,8 @@ export function toBeFocused( locator: LocatorEx, options?: { timeout?: number }, ) { - return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', async (isNot, timeout, customStackTrace) => { - return await locator._expect(customStackTrace, 'to.be.focused', { isNot, timeout }); + return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', async (isNot, timeout) => { + return await locator._expect('to.be.focused', { isNot, timeout }); }, options); } @@ -103,8 +102,8 @@ export function toBeHidden( locator: LocatorEx, options?: { timeout?: number }, ) { - return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', async (isNot, timeout, customStackTrace) => { - return await locator._expect(customStackTrace, 'to.be.hidden', { isNot, timeout }); + return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', async (isNot, timeout) => { + return await locator._expect('to.be.hidden', { isNot, timeout }); }, options); } @@ -113,9 +112,9 @@ export function toBeVisible( locator: LocatorEx, options?: { visible?: boolean, timeout?: number }, ) { - return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', async (isNot, timeout) => { const visible = !options || options.visible === undefined || options.visible === true; - return await locator._expect(customStackTrace, visible ? 'to.be.visible' : 'to.be.hidden', { isNot, timeout }); + return await locator._expect(visible ? 'to.be.visible' : 'to.be.hidden', { isNot, timeout }); }, options); } @@ -124,8 +123,8 @@ export function toBeInViewport( locator: LocatorEx, options?: { timeout?: number, ratio?: number }, ) { - return toBeTruthy.call(this, 'toBeInViewport', locator, 'Locator', async (isNot, timeout, customStackTrace) => { - return await locator._expect(customStackTrace, 'to.be.in.viewport', { isNot, expectedNumber: options?.ratio, timeout }); + return toBeTruthy.call(this, 'toBeInViewport', locator, 'Locator', async (isNot, timeout) => { + return await locator._expect('to.be.in.viewport', { isNot, expectedNumber: options?.ratio, timeout }); }, options); } @@ -138,12 +137,12 @@ export function toContainText( if (Array.isArray(expected)) { return toEqual.call(this, 'toContainText', locator, 'Locator', async (isNot, timeout, customStackTrace) => { const expectedText = toExpectedTextValues(expected, { matchSubstring: true, normalizeWhiteSpace: true, ignoreCase: options.ignoreCase }); - return await locator._expect(customStackTrace, 'to.contain.text.array', { expectedText, isNot, useInnerText: options.useInnerText, timeout }); + return await locator._expect('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, customStackTrace) => { + return toMatchText.call(this, 'toContainText', locator, 'Locator', async (isNot, timeout) => { const expectedText = toExpectedTextValues([expected], { matchSubstring: true, normalizeWhiteSpace: true, ignoreCase: options.ignoreCase }); - return await locator._expect(customStackTrace, 'to.have.text', { expectedText, isNot, useInnerText: options.useInnerText, timeout }); + return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options.useInnerText, timeout }); }, expected, options); } } @@ -155,9 +154,9 @@ export function toHaveAttribute( expected: string | RegExp, options?: { timeout?: number }, ) { - return toMatchText.call(this, 'toHaveAttribute', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return toMatchText.call(this, 'toHaveAttribute', locator, 'Locator', async (isNot, timeout) => { const expectedText = toExpectedTextValues([expected]); - return await locator._expect(customStackTrace, 'to.have.attribute', { expressionArg: name, expectedText, isNot, timeout }); + return await locator._expect('to.have.attribute', { expressionArg: name, expectedText, isNot, timeout }); }, expected, options); } @@ -168,14 +167,14 @@ export function toHaveClass( options?: { timeout?: number }, ) { if (Array.isArray(expected)) { - return toEqual.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return toEqual.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout) => { const expectedText = toExpectedTextValues(expected); - return await locator._expect(customStackTrace, 'to.have.class.array', { expectedText, isNot, timeout }); + return await locator._expect('to.have.class.array', { expectedText, isNot, timeout }); }, expected, options); } else { - return toMatchText.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return toMatchText.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout) => { const expectedText = toExpectedTextValues([expected]); - return await locator._expect(customStackTrace, 'to.have.class', { expectedText, isNot, timeout }); + return await locator._expect('to.have.class', { expectedText, isNot, timeout }); }, expected, options); } } @@ -186,8 +185,8 @@ export function toHaveCount( expected: number, options?: { timeout?: number }, ) { - return toEqual.call(this, 'toHaveCount', locator, 'Locator', async (isNot, timeout, customStackTrace) => { - return await locator._expect(customStackTrace, 'to.have.count', { expectedNumber: expected, isNot, timeout }); + return toEqual.call(this, 'toHaveCount', locator, 'Locator', async (isNot, timeout) => { + return await locator._expect('to.have.count', { expectedNumber: expected, isNot, timeout }); }, expected, options); } @@ -198,9 +197,9 @@ export function toHaveCSS( expected: string | RegExp, options?: { timeout?: number }, ) { - return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async (isNot, timeout) => { const expectedText = toExpectedTextValues([expected]); - return await locator._expect(customStackTrace, 'to.have.css', { expressionArg: name, expectedText, isNot, timeout }); + return await locator._expect('to.have.css', { expressionArg: name, expectedText, isNot, timeout }); }, expected, options); } @@ -210,9 +209,9 @@ export function toHaveId( expected: string | RegExp, options?: { timeout?: number }, ) { - return toMatchText.call(this, 'toHaveId', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return toMatchText.call(this, 'toHaveId', locator, 'Locator', async (isNot, timeout) => { const expectedText = toExpectedTextValues([expected]); - return await locator._expect(customStackTrace, 'to.have.id', { expectedText, isNot, timeout }); + return await locator._expect('to.have.id', { expectedText, isNot, timeout }); }, expected, options); } @@ -223,8 +222,8 @@ export function toHaveJSProperty( expected: any, options?: { timeout?: number }, ) { - 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 }); + return toEqual.call(this, 'toHaveJSProperty', locator, 'Locator', async (isNot, timeout) => { + return await locator._expect('to.have.property', { expressionArg: name, expectedValue: expected, isNot, timeout }); }, expected, options); } @@ -235,14 +234,14 @@ export function toHaveText( options: { timeout?: number, useInnerText?: boolean, ignoreCase?: boolean } = {}, ) { if (Array.isArray(expected)) { - return toEqual.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return toEqual.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout) => { const expectedText = toExpectedTextValues(expected, { normalizeWhiteSpace: true, ignoreCase: options.ignoreCase }); - return await locator._expect(customStackTrace, 'to.have.text.array', { expectedText, isNot, useInnerText: options?.useInnerText, timeout }); + return await locator._expect('to.have.text.array', { expectedText, isNot, useInnerText: options?.useInnerText, timeout }); }, expected, options); } else { - return toMatchText.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return toMatchText.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout) => { const expectedText = toExpectedTextValues([expected], { normalizeWhiteSpace: true, ignoreCase: options.ignoreCase }); - return await locator._expect(customStackTrace, 'to.have.text', { expectedText, isNot, useInnerText: options?.useInnerText, timeout }); + return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options?.useInnerText, timeout }); }, expected, options); } } @@ -253,9 +252,9 @@ export function toHaveValue( expected: string | RegExp, options?: { timeout?: number }, ) { - return toMatchText.call(this, 'toHaveValue', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return toMatchText.call(this, 'toHaveValue', locator, 'Locator', async (isNot, timeout) => { const expectedText = toExpectedTextValues([expected]); - return await locator._expect(customStackTrace, 'to.have.value', { expectedText, isNot, timeout }); + return await locator._expect('to.have.value', { expectedText, isNot, timeout }); }, expected, options); } @@ -267,7 +266,7 @@ export function toHaveValues( ) { return toEqual.call(this, 'toHaveValues', locator, 'Locator', async (isNot, timeout, customStackTrace) => { const expectedText = toExpectedTextValues(expected); - return await locator._expect(customStackTrace, 'to.have.values', { expectedText, isNot, timeout }); + return await locator._expect('to.have.values', { expectedText, isNot, timeout }); }, expected, options); } @@ -278,9 +277,9 @@ export function toHaveTitle( options: { timeout?: number } = {}, ) { const locator = page.locator(':root') as LocatorEx; - return toMatchText.call(this, 'toHaveTitle', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return toMatchText.call(this, 'toHaveTitle', locator, 'Locator', async (isNot, timeout) => { const expectedText = toExpectedTextValues([expected], { normalizeWhiteSpace: true }); - return await locator._expect(customStackTrace, 'to.have.title', { expectedText, isNot, timeout }); + return await locator._expect('to.have.title', { expectedText, isNot, timeout }); }, expected, options); } @@ -293,9 +292,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, customStackTrace) => { + return toMatchText.call(this, 'toHaveURL', locator, 'Locator', async (isNot, timeout) => { const expectedText = toExpectedTextValues([expected]); - return await locator._expect(customStackTrace, 'to.have.url', { expectedText, isNot, timeout }); + return await locator._expect('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 dfc1b617b8..da89f93a0e 100644 --- a/packages/playwright-test/src/matchers/toBeTruthy.ts +++ b/packages/playwright-test/src/matchers/toBeTruthy.ts @@ -15,8 +15,7 @@ */ import type { Expect } from '../common/types'; -import type { ParsedStackTrace } from '../util'; -import { expectTypes, callLogText, captureStackTrace } from '../util'; +import { expectTypes, callLogText } from '../util'; import { matcherHint } from './matcherHint'; import { currentExpectTimeout } from '../common/globals'; @@ -25,7 +24,7 @@ export async function toBeTruthy( matcherName: string, receiver: any, receiverType: string, - query: (isNot: boolean, timeout: number, customStackTrace: ParsedStackTrace) => Promise<{ matches: boolean, log?: string[], received?: any, timedOut?: boolean }>, + query: (isNot: boolean, timeout: number) => Promise<{ matches: boolean, log?: string[], received?: any, timedOut?: boolean }>, options: { timeout?: number } = {}, ) { expectTypes(receiver, [receiverType], matcherName); @@ -37,7 +36,7 @@ export async function toBeTruthy( const timeout = currentExpectTimeout(options); - const { matches, log, timedOut } = await query(this.isNot, timeout, captureStackTrace(`expect.${this.isNot ? 'not.' : ''}${matcherName}`)); + const { matches, log, timedOut } = await query(this.isNot, timeout); const message = () => { return matcherHint(this, matcherName, undefined, '', matcherOptions, timedOut ? timeout : undefined) + callLogText(log); diff --git a/packages/playwright-test/src/matchers/toMatchSnapshot.ts b/packages/playwright-test/src/matchers/toMatchSnapshot.ts index f0c74c4abb..00696d628b 100644 --- a/packages/playwright-test/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright-test/src/matchers/toMatchSnapshot.ts @@ -25,7 +25,7 @@ import type { PageScreenshotOptions } from 'playwright-core/types/types'; import { addSuffixToFilePath, serializeError, sanitizeForFilePath, trimLongString, callLogText, - expectTypes, captureStackTrace } from '../util'; + expectTypes } from '../util'; import { colors } from 'playwright-core/lib/utilsBundle'; import fs from 'fs'; import path from 'path'; @@ -329,7 +329,6 @@ export async function toHaveScreenshot( maxDiffPixelRatio: undefined, }; - const customStackTrace = captureStackTrace(`expect.${this.isNot ? 'not.' : ''}toHaveScreenshot`); const hasSnapshot = fs.existsSync(helper.snapshotPath); if (this.isNot) { if (!hasSnapshot) @@ -338,7 +337,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(customStackTrace, { + const isDifferent = !(await page._expectScreenshot({ expected: await fs.promises.readFile(helper.snapshotPath), isNot: true, locator, @@ -359,7 +358,7 @@ export async function toHaveScreenshot( if (!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(customStackTrace, { + const { actual, previous, diff, errorMessage, log } = await page._expectScreenshot({ expected: undefined, isNot: false, locator, @@ -381,7 +380,7 @@ export async function toHaveScreenshot( // - regular matcher (i.e. not a `.not`) // - perhaps an 'all' flag to update non-matching screenshots const expected = await fs.promises.readFile(helper.snapshotPath); - const { actual, diff, errorMessage, log } = await page._expectScreenshot(customStackTrace, { + const { actual, diff, errorMessage, log } = await page._expectScreenshot({ expected, isNot: false, locator, diff --git a/packages/playwright-test/src/matchers/toMatchText.ts b/packages/playwright-test/src/matchers/toMatchText.ts index fad6f41da1..c051dbd0b1 100644 --- a/packages/playwright-test/src/matchers/toMatchText.ts +++ b/packages/playwright-test/src/matchers/toMatchText.ts @@ -18,8 +18,7 @@ import type { ExpectedTextValue } from '@protocol/channels'; import { isRegExp, isString } from 'playwright-core/lib/utils'; import type { Expect } from '../common/types'; -import type { ParsedStackTrace } from '../util'; -import { expectTypes, callLogText, captureStackTrace } from '../util'; +import { expectTypes, callLogText } from '../util'; import { printReceivedStringContainExpectedResult, printReceivedStringContainExpectedSubstring @@ -32,7 +31,7 @@ export async function toMatchText( matcherName: string, receiver: any, receiverType: string, - query: (isNot: boolean, timeout: number, customStackTrace: ParsedStackTrace) => Promise<{ matches: boolean, received?: string, log?: string[], timedOut?: boolean }>, + query: (isNot: boolean, timeout: number) => Promise<{ matches: boolean, received?: string, log?: string[], timedOut?: boolean }>, expected: string | RegExp, options: { timeout?: number, matchSubstring?: boolean } = {}, ) { @@ -60,7 +59,7 @@ export async function toMatchText( const timeout = currentExpectTimeout(options); - const { matches: pass, received, log, timedOut } = await query(this.isNot, timeout, captureStackTrace(`expect.${this.isNot ? 'not.' : ''}${matcherName}`)); + const { matches: pass, received, log, timedOut } = await query(this.isNot, timeout); 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 6ec4d14c74..8294375567 100644 --- a/packages/playwright-test/src/util.ts +++ b/packages/playwright-test/src/util.ts @@ -21,67 +21,26 @@ import path from 'path'; import url from 'url'; import { colors, debug, minimatch } from 'playwright-core/lib/utilsBundle'; import type { TestInfoError, Location } from './common/types'; -import { calculateSha1, isRegExp, isString, captureStackTrace as coreCaptureStackTrace } from 'playwright-core/lib/utils'; -import { isInternalFileName } from 'playwright-core/lib/utils'; +import { calculateSha1, captureStackTrace, isRegExp, isString } from 'playwright-core/lib/utils'; import type { ParsedStackTrace } from 'playwright-core/lib/utils'; export type { ParsedStackTrace }; -const PLAYWRIGHT_CORE_PATH = path.dirname(require.resolve('playwright-core')); -const EXPECT_PATH = require.resolve('./common/expectBundle'); -const EXPECT_PATH_IMPL = require.resolve('./common/expectBundleImpl'); const PLAYWRIGHT_TEST_PATH = path.join(__dirname, '..'); -function filterStackTrace(e: Error) { +export function filterStackTrace(e: Error) { if (process.env.PWDEBUGIMPL) return; - // This method filters internal stack frames using Error.prepareStackTrace - // hook. Read more about the hook: https://v8.dev/docs/stack-trace-api - // - // NOTE: Error.prepareStackTrace will only be called if `e.stack` has not - // been accessed before. This is the case for Jest Expect and simple throw - // statements. - // - // If `e.stack` has been accessed, this method will be NOOP. - const oldPrepare = Error.prepareStackTrace; - const stackFormatter = oldPrepare || ((error, structuredStackTrace) => [ - `${error.name}: ${error.message}`, - ...structuredStackTrace.map(callSite => ' at ' + callSite.toString()), - ].join('\n')); - Error.prepareStackTrace = (error, structuredStackTrace) => { - return stackFormatter(error, structuredStackTrace.filter(callSite => { - const fileName = callSite.getFileName(); - const functionName = callSite.getFunctionName() || undefined; - if (!fileName) - return true; - return !fileName.startsWith(PLAYWRIGHT_TEST_PATH) && - !fileName.startsWith(PLAYWRIGHT_CORE_PATH) && - !isInternalFileName(fileName, functionName); - })); - }; - // eslint-disable-next-line - e.stack; // trigger Error.prepareStackTrace - 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 === EXPECT_PATH || frame.file === EXPECT_PATH_IMPL) - continue; - frames.push(frame); - frameTexts.push(stackTrace.frameTexts[i]); - } - return { - allFrames: stackTrace.allFrames, - frames, - frameTexts, - apiName: customApiName ?? stackTrace.apiName, - }; + const stack = captureStackTrace(e.stack); + const stackLines = stack.frames.filter(f => !f.file.startsWith(PLAYWRIGHT_TEST_PATH)).map(f => { + if (f.function) + return ` at ${f.function} (${f.file}:${f.line}:${f.column})`; + return ` at ${f.file}:${f.line}:${f.column}`; + }); + const message = e.message; + e.stack = `${e.name}: ${e.message}\n${stackLines.join('\n')}`; + e.message = message; } export function serializeError(error: Error | any): TestInfoError { diff --git a/tests/page/expect-misc.spec.ts b/tests/page/expect-misc.spec.ts index 6e2a4ee174..e4341c488a 100644 --- a/tests/page/expect-misc.spec.ts +++ b/tests/page/expect-misc.spec.ts @@ -73,7 +73,7 @@ test.describe('toHaveCount', () => { await page.setContent('
'); const locator = page.locator('span'); const error = await expect(locator).not.toHaveCount(1, { timeout: 1000 }).catch(e => e); - expect(error.message).toContain('expect.toHaveCount with timeout 1000ms'); + expect(error.message).toContain('expect.not.toHaveCount with timeout 1000ms'); }); }); diff --git a/tests/page/expect-to-have-text.spec.ts b/tests/page/expect-to-have-text.spec.ts index d76051feb4..12a11d1541 100644 --- a/tests/page/expect-to-have-text.spec.ts +++ b/tests/page/expect-to-have-text.spec.ts @@ -206,7 +206,7 @@ test.describe('toHaveText with array', () => { await page.setContent('
'); const locator = page.locator('p'); const error = await expect(locator).not.toHaveText([], { timeout: 1000 }).catch(e => e); - expect(error.message).toContain('expect.toHaveText with timeout 1000ms'); + expect(error.message).toContain('expect.not.toHaveText with timeout 1000ms'); }); test('pass eventually empty', async ({ page }) => { diff --git a/tests/playwright-test/loader.spec.ts b/tests/playwright-test/loader.spec.ts index 08d359e4ad..3ced335b8d 100644 --- a/tests/playwright-test/loader.spec.ts +++ b/tests/playwright-test/loader.spec.ts @@ -333,7 +333,7 @@ test('should filter out syntax error stack traces', async ({ runInlineTest }, te import { test, expect } from '@playwright/test'; test('should work', ({}) => { // syntax error: cannot have await in non-async function - await Proimse.resolve(); + await Promise.resolve(); }); ` }); @@ -382,26 +382,6 @@ test('should not filter out POM', async ({ runInlineTest }) => { expect(result.output).not.toContain('internal'); }); -test('should filter stack even without default Error.prepareStackTrace', async ({ runInlineTest }) => { - const result = await runInlineTest({ - 'expect-test.spec.ts': ` - import { test, expect } from '@playwright/test'; - test('should work', ({}) => { - Error.prepareStackTrace = undefined; - throw new Error('foobar'); - }); - ` - }); - expect(result.exitCode).toBe(1); - expect(result.output).toContain('foobar'); - expect(result.output).toContain('expect-test.spec.ts'); - expect(result.output).not.toContain(path.sep + `playwright-test`); - expect(result.output).not.toContain(path.sep + `playwright-core`); - expect(result.output).not.toContain('internal'); - const stackLines = result.output.split('\n').filter(line => line.includes(' at ')); - expect(stackLines.length).toBe(1); -}); - test('should work with cross-imports - 1', async ({ runInlineTest }) => { const result = await runInlineTest({ 'test1.spec.ts': ` diff --git a/tests/playwright-test/reporter-base.spec.ts b/tests/playwright-test/reporter-base.spec.ts index 99b78918cf..ffca617b39 100644 --- a/tests/playwright-test/reporter-base.spec.ts +++ b/tests/playwright-test/reporter-base.spec.ts @@ -283,7 +283,6 @@ test('should print errors with inconsistent message/stack', async ({ runInlineTe // Otherwise it is computed lazy and will get 'foo bar' instead. e.stack; e.message = 'foo bar'; - e.stack = 'hi!' + e.stack; throw e; }); ` @@ -291,7 +290,7 @@ test('should print errors with inconsistent message/stack', async ({ runInlineTe expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); const output = result.output; - expect(output).toContain('hi!Error: Hello'); + expect(output).toContain('foo bar'); expect(output).toContain('function myTest'); });