diff --git a/docs/src/test-reporter-api/class-teststep.md b/docs/src/test-reporter-api/class-teststep.md index 504ee03d8f..43970bac41 100644 --- a/docs/src/test-reporter-api/class-teststep.md +++ b/docs/src/test-reporter-api/class-teststep.md @@ -17,6 +17,11 @@ Step category to differentiate steps with different origin and verbosity. Built- Running time in milliseconds. +## property: TestStep.location +- type: <[void]|[Location]> + +Location in the source where the step is defined. + ## property: TestStep.error - type: <[void]|[TestError]> diff --git a/packages/playwright-core/src/client/channelOwner.ts b/packages/playwright-core/src/client/channelOwner.ts index b13f63f78f..84e65031b6 100644 --- a/packages/playwright-core/src/client/channelOwner.ts +++ b/packages/playwright-core/src/client/channelOwner.ts @@ -82,7 +82,7 @@ export abstract class ChannelOwner { if (callCookie && csi) { - callCookie.userObject = csi.onApiCallBegin(renderCallWithParams(stackTrace!.apiName!, params)).userObject; + callCookie.userObject = csi.onApiCallBegin(renderCallWithParams(stackTrace!.apiName!, params), stackTrace).userObject; csi = undefined; } return this._connection.sendMessageToServer(this, prop, validator(params, ''), stackTrace); diff --git a/packages/playwright-core/src/client/types.ts b/packages/playwright-core/src/client/types.ts index 73c47fd7b9..506d874a94 100644 --- a/packages/playwright-core/src/client/types.ts +++ b/packages/playwright-core/src/client/types.ts @@ -17,6 +17,7 @@ import * as channels from '../protocol/channels'; import type { Size } from '../common/types'; +import { ParsedStackTrace } from '../utils/stackTrace'; export { Size, Point, Rect, Quad, URLMatch, TimeoutOptions, HeadersArray } from '../common/types'; type LoggerSeverity = 'verbose' | 'info' | 'warning' | 'error'; @@ -26,7 +27,7 @@ export interface Logger { } export interface ClientSideInstrumentation { - onApiCallBegin(apiCall: string): { userObject: any }; + onApiCallBegin(apiCall: string, stackTrace: ParsedStackTrace | null): { userObject: any }; onApiCallEnd(userData: { userObject: any }, error?: Error): any; } diff --git a/packages/playwright-test/package.json b/packages/playwright-test/package.json index 982f9adffb..a713c79878 100644 --- a/packages/playwright-test/package.json +++ b/packages/playwright-test/package.json @@ -48,6 +48,7 @@ "pirates": "^4.0.1", "pixelmatch": "^5.2.1", "playwright-core": "=1.16.0-next", - "source-map-support": "^0.4.18" + "source-map-support": "^0.4.18", + "stack-utils": "^2.0.3" } } diff --git a/packages/playwright-test/src/dispatcher.ts b/packages/playwright-test/src/dispatcher.ts index c8f379dc5d..2ff2b065bb 100644 --- a/packages/playwright-test/src/dispatcher.ts +++ b/packages/playwright-test/src/dispatcher.ts @@ -150,6 +150,7 @@ export class Dispatcher { startTime: new Date(params.wallTime), duration: 0, steps: [], + location: params.location, data: {}, }; steps.set(params.stepId, step); diff --git a/packages/playwright-test/src/expect.ts b/packages/playwright-test/src/expect.ts index b6bc304035..69ac339225 100644 --- a/packages/playwright-test/src/expect.ts +++ b/packages/playwright-test/src/expect.ts @@ -15,6 +15,7 @@ */ import expectLibrary from 'expect'; +import path from 'path'; import { INVERTED_COLOR, RECEIVED_COLOR, @@ -46,6 +47,9 @@ 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(); // #region // Mirrored from https://github.com/facebook/jest/blob/f13abff8df9a0e1148baf3584bcde6d1b479edc7/packages/expect/src/print.ts @@ -124,7 +128,9 @@ function wrap(matcherName: string, matcher: any) { // at Object.throwingMatcher [as toHaveText] (...) // at (...) const stackLines = new Error().stack!.split('\n').slice(INTERNAL_STACK_LENGTH + 1); + const frame = stackLines[0] ? stackUtils.parseLine(stackLines[0]) : undefined; const step = testInfo._addStep({ + location: frame && frame.file ? { file: path.resolve(process.cwd(), frame.file), line: frame.line || 0, column: frame.column || 0 } : undefined, category: 'expect', title: `expect${this.isNot ? '.not' : ''}.${matcherName}`, canHaveChildren: true, diff --git a/packages/playwright-test/src/index.ts b/packages/playwright-test/src/index.ts index 5347964708..a052116104 100644 --- a/packages/playwright-test/src/index.ts +++ b/packages/playwright-test/src/index.ts @@ -298,14 +298,16 @@ export const test = _baseTest.extend({ await context.tracing.stop(); } (context as any)._csi = { - onApiCallBegin: (apiCall: string) => { + onApiCallBegin: (apiCall: string, stackTrace: ParsedStackTrace | null) => { if (apiCall.startsWith('expect.')) return { userObject: null }; - const step = (testInfo as any)._addStep({ + const testInfoImpl = testInfo as any; + const step = testInfoImpl._addStep({ + location: stackTrace?.frames[0], category: 'pw:api', title: apiCall, canHaveChildren: false, - forceNoParent: false, + forceNoParent: false }); return { userObject: step }; }, diff --git a/packages/playwright-test/src/ipc.ts b/packages/playwright-test/src/ipc.ts index e0af4ec9c0..41d9d65aef 100644 --- a/packages/playwright-test/src/ipc.ts +++ b/packages/playwright-test/src/ipc.ts @@ -54,6 +54,7 @@ export type StepBeginPayload = { canHaveChildren: boolean; forceNoParent: boolean; wallTime: number; // milliseconds since unix epoch + location?: { file: string, line: number, column: number }; }; export type StepEndPayload = { diff --git a/packages/playwright-test/src/testType.ts b/packages/playwright-test/src/testType.ts index 5e05d1a38f..75fb3c851f 100644 --- a/packages/playwright-test/src/testType.ts +++ b/packages/playwright-test/src/testType.ts @@ -189,6 +189,7 @@ export class TestTypeImpl { const step = testInfo._addStep({ category: 'test.step', title, + location, canHaveChildren: true, forceNoParent: false }); diff --git a/packages/playwright-test/src/types.ts b/packages/playwright-test/src/types.ts index df533e85a0..e8c959e9a4 100644 --- a/packages/playwright-test/src/types.ts +++ b/packages/playwright-test/src/types.ts @@ -31,6 +31,7 @@ export interface TestStepInternal { category: string; canHaveChildren: boolean; forceNoParent: boolean; + location?: Location; } export interface TestInfoImpl extends TestInfo { diff --git a/packages/playwright-test/src/workerRunner.ts b/packages/playwright-test/src/workerRunner.ts index 1fadc58af3..b63aa87575 100644 --- a/packages/playwright-test/src/workerRunner.ts +++ b/packages/playwright-test/src/workerRunner.ts @@ -306,10 +306,13 @@ export class WorkerRunner extends EventEmitter { this.emit('stepEnd', payload); } }; + // Sanitize location that comes from userland. + const location = data.location ? { file: data.location.file, line: data.location.line, column: data.location.column } : undefined; const payload: StepBeginPayload = { testId, stepId, ...data, + location, wallTime: Date.now(), }; if (reportEvents) diff --git a/packages/playwright-test/types/testReporter.d.ts b/packages/playwright-test/types/testReporter.d.ts index 4503bf6eb8..4dd0a91936 100644 --- a/packages/playwright-test/types/testReporter.d.ts +++ b/packages/playwright-test/types/testReporter.d.ts @@ -230,6 +230,10 @@ export interface TestStep { * Returns a list of step titles from the root step down to this step. */ titlePath(): string[]; + /** + * Location in the source where the step is defined. + */ + location?: Location; /** * Parent step, if any. */ diff --git a/tests/config/browserTest.ts b/tests/config/browserTest.ts index e2aec8dcbc..d5dd5b56f8 100644 --- a/tests/config/browserTest.ts +++ b/tests/config/browserTest.ts @@ -24,6 +24,7 @@ import * as os from 'os'; import { RemoteServer, RemoteServerOptions } from './remoteServer'; import { baseTest, CommonWorkerFixtures } from './baseTest'; import { CommonFixtures } from './commonFixtures'; +import { ParsedStackTrace } from 'playwright-core/src/utils/stackTrace'; type PlaywrightWorkerOptions = { executablePath: LaunchOptions['executablePath']; @@ -147,11 +148,12 @@ export const playwrightFixtures: Fixtures { + onApiCallBegin: (apiCall: string, stackTrace: ParsedStackTrace | null) => { if (apiCall.startsWith('expect.')) return { userObject: null }; const testInfoImpl = testInfo as any; const step = testInfoImpl._addStep({ + location: stackTrace?.frames[0], category: 'pw:api', title: apiCall, canHaveChildren: false, diff --git a/tests/playwright-test/reporter.spec.ts b/tests/playwright-test/reporter.spec.ts index 507dad9a15..9bec348d48 100644 --- a/tests/playwright-test/reporter.spec.ts +++ b/tests/playwright-test/reporter.spec.ts @@ -46,6 +46,7 @@ class Reporter { duration: undefined, parent: undefined, data: undefined, + location: undefined, steps: step.steps.length ? step.steps.map(s => this.distillStep(s)) : undefined, }; } diff --git a/tests/playwright-test/test-step.spec.ts b/tests/playwright-test/test-step.spec.ts index eab8e1a0d5..6635aca4b8 100644 --- a/tests/playwright-test/test-step.spec.ts +++ b/tests/playwright-test/test-step.spec.ts @@ -29,6 +29,11 @@ class Reporter { duration: undefined, parent: undefined, data: undefined, + location: step.location ? { + file: step.location.file.substring(step.location.file.lastIndexOf(require('path').sep) + 1).replace('.js', '.ts'), + line: step.location.line ? typeof step.location.line : 0, + column: step.location.column ? typeof step.location.column : 0 + } : undefined, steps: step.steps.length ? step.steps.map(s => this.distillStep(s)) : undefined, }; } @@ -83,6 +88,11 @@ test('should report api step hierarchy', async ({ runInlineTest }) => { steps: [ { category: 'pw:api', + location: { + column: 'number', + file: 'index.ts', + line: 'number', + }, title: 'browserContext.newPage', }, ], @@ -90,13 +100,28 @@ test('should report api step hierarchy', async ({ runInlineTest }) => { { category: 'test.step', title: 'outer step 1', + location: { + column: 'number', + file: 'a.test.ts', + line: 'number', + }, steps: [ { category: 'test.step', + location: { + column: 'number', + file: 'a.test.ts', + line: 'number', + }, title: 'inner step 1.1', }, { category: 'test.step', + location: { + column: 'number', + file: 'a.test.ts', + line: 'number', + }, title: 'inner step 1.2', }, ], @@ -104,13 +129,28 @@ test('should report api step hierarchy', async ({ runInlineTest }) => { { category: 'test.step', title: 'outer step 2', + location: { + column: 'number', + file: 'a.test.ts', + line: 'number', + }, steps: [ { category: 'test.step', + location: { + column: 'number', + file: 'a.test.ts', + line: 'number', + }, title: 'inner step 2.1', }, { category: 'test.step', + location: { + column: 'number', + file: 'a.test.ts', + line: 'number', + }, title: 'inner step 2.2', }, ], @@ -121,6 +161,11 @@ test('should report api step hierarchy', async ({ runInlineTest }) => { steps: [ { category: 'pw:api', + location: { + column: 'number', + file: 'index.ts', + line: 'number', + }, title: 'browserContext.close', }, ], @@ -156,12 +201,22 @@ test('should not report nested after hooks', async ({ runInlineTest }) => { { category: 'pw:api', title: 'browserContext.newPage', + location: { + column: 'number', + file: 'index.ts', + line: 'number', + }, }, ], }, { category: 'test.step', title: 'my step', + location: { + column: 'number', + file: 'a.test.ts', + line: 'number', + }, }, { category: 'hook', @@ -170,6 +225,11 @@ test('should not report nested after hooks', async ({ runInlineTest }) => { { category: 'pw:api', title: 'browserContext.close', + location: { + column: 'number', + file: 'index.ts', + line: 'number', + }, }, ], }, @@ -230,3 +290,64 @@ test('should report test.step from fixtures', async ({ runInlineTest }) => { `%% end After Hooks`, ]); }); + +test('should report expect step locations', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': stepHierarchyReporter, + 'playwright.config.ts': ` + module.exports = { + reporter: './reporter', + }; + `, + 'a.test.ts': ` + const { test } = pwt; + test('pass', async ({ page }) => { + expect(true).toBeTruthy(); + }); + ` + }, { reporter: '', workers: 1 }); + + expect(result.exitCode).toBe(0); + const objects = result.output.split('\n').filter(line => line.startsWith('%% ')).map(line => line.substring(3).trim()).filter(Boolean).map(line => JSON.parse(line)); + expect(objects).toEqual([ + { + category: 'hook', + title: 'Before Hooks', + steps: [ + { + category: 'pw:api', + location: { + column: 'number', + file: 'index.ts', + line: 'number', + }, + title: 'browserContext.newPage', + }, + ], + }, + { + category: 'expect', + title: 'expect.toBeTruthy', + location: { + column: 'number', + file: 'a.test.ts', + line: 'number', + }, + }, + { + category: 'hook', + title: 'After Hooks', + steps: [ + { + category: 'pw:api', + location: { + column: 'number', + file: 'index.ts', + line: 'number', + }, + title: 'browserContext.close', + }, + ], + }, + ]); +}); diff --git a/utils/generate_types/overrides-testReporter.d.ts b/utils/generate_types/overrides-testReporter.d.ts index 94bc2824d1..5fa2d75df8 100644 --- a/utils/generate_types/overrides-testReporter.d.ts +++ b/utils/generate_types/overrides-testReporter.d.ts @@ -61,6 +61,7 @@ export interface TestResult { export interface TestStep { title: string; titlePath(): string[]; + location?: Location; parent?: TestStep; category: string, startTime: Date;