feat(api): expose step location (#9602)
This commit is contained in:
parent
4977edcaf3
commit
e37660b068
|
|
@ -17,6 +17,11 @@ Step category to differentiate steps with different origin and verbosity. Built-
|
||||||
|
|
||||||
Running time in milliseconds.
|
Running time in milliseconds.
|
||||||
|
|
||||||
|
## property: TestStep.location
|
||||||
|
- type: <[void]|[Location]>
|
||||||
|
|
||||||
|
Location in the source where the step is defined.
|
||||||
|
|
||||||
## property: TestStep.error
|
## property: TestStep.error
|
||||||
- type: <[void]|[TestError]>
|
- type: <[void]|[TestError]>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
|
||||||
if (validator) {
|
if (validator) {
|
||||||
return (params: any) => {
|
return (params: any) => {
|
||||||
if (callCookie && csi) {
|
if (callCookie && csi) {
|
||||||
callCookie.userObject = csi.onApiCallBegin(renderCallWithParams(stackTrace!.apiName!, params)).userObject;
|
callCookie.userObject = csi.onApiCallBegin(renderCallWithParams(stackTrace!.apiName!, params), stackTrace).userObject;
|
||||||
csi = undefined;
|
csi = undefined;
|
||||||
}
|
}
|
||||||
return this._connection.sendMessageToServer(this, prop, validator(params, ''), stackTrace);
|
return this._connection.sendMessageToServer(this, prop, validator(params, ''), stackTrace);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
|
|
||||||
import * as channels from '../protocol/channels';
|
import * as channels from '../protocol/channels';
|
||||||
import type { Size } from '../common/types';
|
import type { Size } from '../common/types';
|
||||||
|
import { ParsedStackTrace } from '../utils/stackTrace';
|
||||||
export { Size, Point, Rect, Quad, URLMatch, TimeoutOptions, HeadersArray } from '../common/types';
|
export { Size, Point, Rect, Quad, URLMatch, TimeoutOptions, HeadersArray } from '../common/types';
|
||||||
|
|
||||||
type LoggerSeverity = 'verbose' | 'info' | 'warning' | 'error';
|
type LoggerSeverity = 'verbose' | 'info' | 'warning' | 'error';
|
||||||
|
|
@ -26,7 +27,7 @@ export interface Logger {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientSideInstrumentation {
|
export interface ClientSideInstrumentation {
|
||||||
onApiCallBegin(apiCall: string): { userObject: any };
|
onApiCallBegin(apiCall: string, stackTrace: ParsedStackTrace | null): { userObject: any };
|
||||||
onApiCallEnd(userData: { userObject: any }, error?: Error): any;
|
onApiCallEnd(userData: { userObject: any }, error?: Error): any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@
|
||||||
"pirates": "^4.0.1",
|
"pirates": "^4.0.1",
|
||||||
"pixelmatch": "^5.2.1",
|
"pixelmatch": "^5.2.1",
|
||||||
"playwright-core": "=1.16.0-next",
|
"playwright-core": "=1.16.0-next",
|
||||||
"source-map-support": "^0.4.18"
|
"source-map-support": "^0.4.18",
|
||||||
|
"stack-utils": "^2.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,7 @@ export class Dispatcher {
|
||||||
startTime: new Date(params.wallTime),
|
startTime: new Date(params.wallTime),
|
||||||
duration: 0,
|
duration: 0,
|
||||||
steps: [],
|
steps: [],
|
||||||
|
location: params.location,
|
||||||
data: {},
|
data: {},
|
||||||
};
|
};
|
||||||
steps.set(params.stepId, step);
|
steps.set(params.stepId, step);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import expectLibrary from 'expect';
|
import expectLibrary from 'expect';
|
||||||
|
import path from 'path';
|
||||||
import {
|
import {
|
||||||
INVERTED_COLOR,
|
INVERTED_COLOR,
|
||||||
RECEIVED_COLOR,
|
RECEIVED_COLOR,
|
||||||
|
|
@ -46,6 +47,9 @@ 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 } 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
|
||||||
|
|
@ -124,7 +128,9 @@ function wrap(matcherName: string, matcher: any) {
|
||||||
// at Object.throwingMatcher [as toHaveText] (...)
|
// at Object.throwingMatcher [as toHaveText] (...)
|
||||||
// at <test function> (...)
|
// at <test function> (...)
|
||||||
const stackLines = new Error().stack!.split('\n').slice(INTERNAL_STACK_LENGTH + 1);
|
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({
|
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',
|
category: 'expect',
|
||||||
title: `expect${this.isNot ? '.not' : ''}.${matcherName}`,
|
title: `expect${this.isNot ? '.not' : ''}.${matcherName}`,
|
||||||
canHaveChildren: true,
|
canHaveChildren: true,
|
||||||
|
|
|
||||||
|
|
@ -298,14 +298,16 @@ export const test = _baseTest.extend<TestFixtures, WorkerAndFileFixtures>({
|
||||||
await context.tracing.stop();
|
await context.tracing.stop();
|
||||||
}
|
}
|
||||||
(context as any)._csi = {
|
(context as any)._csi = {
|
||||||
onApiCallBegin: (apiCall: string) => {
|
onApiCallBegin: (apiCall: string, stackTrace: ParsedStackTrace | null) => {
|
||||||
if (apiCall.startsWith('expect.'))
|
if (apiCall.startsWith('expect.'))
|
||||||
return { userObject: null };
|
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',
|
category: 'pw:api',
|
||||||
title: apiCall,
|
title: apiCall,
|
||||||
canHaveChildren: false,
|
canHaveChildren: false,
|
||||||
forceNoParent: false,
|
forceNoParent: false
|
||||||
});
|
});
|
||||||
return { userObject: step };
|
return { userObject: step };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ export type StepBeginPayload = {
|
||||||
canHaveChildren: boolean;
|
canHaveChildren: boolean;
|
||||||
forceNoParent: boolean;
|
forceNoParent: boolean;
|
||||||
wallTime: number; // milliseconds since unix epoch
|
wallTime: number; // milliseconds since unix epoch
|
||||||
|
location?: { file: string, line: number, column: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StepEndPayload = {
|
export type StepEndPayload = {
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,7 @@ export class TestTypeImpl {
|
||||||
const step = testInfo._addStep({
|
const step = testInfo._addStep({
|
||||||
category: 'test.step',
|
category: 'test.step',
|
||||||
title,
|
title,
|
||||||
|
location,
|
||||||
canHaveChildren: true,
|
canHaveChildren: true,
|
||||||
forceNoParent: false
|
forceNoParent: false
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export interface TestStepInternal {
|
||||||
category: string;
|
category: string;
|
||||||
canHaveChildren: boolean;
|
canHaveChildren: boolean;
|
||||||
forceNoParent: boolean;
|
forceNoParent: boolean;
|
||||||
|
location?: Location;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TestInfoImpl extends TestInfo {
|
export interface TestInfoImpl extends TestInfo {
|
||||||
|
|
|
||||||
|
|
@ -306,10 +306,13 @@ export class WorkerRunner extends EventEmitter {
|
||||||
this.emit('stepEnd', payload);
|
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 = {
|
const payload: StepBeginPayload = {
|
||||||
testId,
|
testId,
|
||||||
stepId,
|
stepId,
|
||||||
...data,
|
...data,
|
||||||
|
location,
|
||||||
wallTime: Date.now(),
|
wallTime: Date.now(),
|
||||||
};
|
};
|
||||||
if (reportEvents)
|
if (reportEvents)
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,10 @@ export interface TestStep {
|
||||||
* Returns a list of step titles from the root step down to this step.
|
* Returns a list of step titles from the root step down to this step.
|
||||||
*/
|
*/
|
||||||
titlePath(): string[];
|
titlePath(): string[];
|
||||||
|
/**
|
||||||
|
* Location in the source where the step is defined.
|
||||||
|
*/
|
||||||
|
location?: Location;
|
||||||
/**
|
/**
|
||||||
* Parent step, if any.
|
* Parent step, if any.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import * as os from 'os';
|
||||||
import { RemoteServer, RemoteServerOptions } from './remoteServer';
|
import { RemoteServer, RemoteServerOptions } from './remoteServer';
|
||||||
import { baseTest, CommonWorkerFixtures } from './baseTest';
|
import { baseTest, CommonWorkerFixtures } from './baseTest';
|
||||||
import { CommonFixtures } from './commonFixtures';
|
import { CommonFixtures } from './commonFixtures';
|
||||||
|
import { ParsedStackTrace } from 'playwright-core/src/utils/stackTrace';
|
||||||
|
|
||||||
type PlaywrightWorkerOptions = {
|
type PlaywrightWorkerOptions = {
|
||||||
executablePath: LaunchOptions['executablePath'];
|
executablePath: LaunchOptions['executablePath'];
|
||||||
|
|
@ -147,11 +148,12 @@ export const playwrightFixtures: Fixtures<PlaywrightTestOptions & PlaywrightTest
|
||||||
if (trace)
|
if (trace)
|
||||||
await context.tracing.start({ screenshots: true, snapshots: true });
|
await context.tracing.start({ screenshots: true, snapshots: true });
|
||||||
(context as any)._csi = {
|
(context as any)._csi = {
|
||||||
onApiCallBegin: (apiCall: string) => {
|
onApiCallBegin: (apiCall: string, stackTrace: ParsedStackTrace | null) => {
|
||||||
if (apiCall.startsWith('expect.'))
|
if (apiCall.startsWith('expect.'))
|
||||||
return { userObject: null };
|
return { userObject: null };
|
||||||
const testInfoImpl = testInfo as any;
|
const testInfoImpl = testInfo as any;
|
||||||
const step = testInfoImpl._addStep({
|
const step = testInfoImpl._addStep({
|
||||||
|
location: stackTrace?.frames[0],
|
||||||
category: 'pw:api',
|
category: 'pw:api',
|
||||||
title: apiCall,
|
title: apiCall,
|
||||||
canHaveChildren: false,
|
canHaveChildren: false,
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ class Reporter {
|
||||||
duration: undefined,
|
duration: undefined,
|
||||||
parent: undefined,
|
parent: undefined,
|
||||||
data: undefined,
|
data: undefined,
|
||||||
|
location: undefined,
|
||||||
steps: step.steps.length ? step.steps.map(s => this.distillStep(s)) : undefined,
|
steps: step.steps.length ? step.steps.map(s => this.distillStep(s)) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,11 @@ class Reporter {
|
||||||
duration: undefined,
|
duration: undefined,
|
||||||
parent: undefined,
|
parent: undefined,
|
||||||
data: 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,
|
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: [
|
steps: [
|
||||||
{
|
{
|
||||||
category: 'pw:api',
|
category: 'pw:api',
|
||||||
|
location: {
|
||||||
|
column: 'number',
|
||||||
|
file: 'index.ts',
|
||||||
|
line: 'number',
|
||||||
|
},
|
||||||
title: 'browserContext.newPage',
|
title: 'browserContext.newPage',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -90,13 +100,28 @@ test('should report api step hierarchy', async ({ runInlineTest }) => {
|
||||||
{
|
{
|
||||||
category: 'test.step',
|
category: 'test.step',
|
||||||
title: 'outer step 1',
|
title: 'outer step 1',
|
||||||
|
location: {
|
||||||
|
column: 'number',
|
||||||
|
file: 'a.test.ts',
|
||||||
|
line: 'number',
|
||||||
|
},
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
category: 'test.step',
|
category: 'test.step',
|
||||||
|
location: {
|
||||||
|
column: 'number',
|
||||||
|
file: 'a.test.ts',
|
||||||
|
line: 'number',
|
||||||
|
},
|
||||||
title: 'inner step 1.1',
|
title: 'inner step 1.1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: 'test.step',
|
category: 'test.step',
|
||||||
|
location: {
|
||||||
|
column: 'number',
|
||||||
|
file: 'a.test.ts',
|
||||||
|
line: 'number',
|
||||||
|
},
|
||||||
title: 'inner step 1.2',
|
title: 'inner step 1.2',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -104,13 +129,28 @@ test('should report api step hierarchy', async ({ runInlineTest }) => {
|
||||||
{
|
{
|
||||||
category: 'test.step',
|
category: 'test.step',
|
||||||
title: 'outer step 2',
|
title: 'outer step 2',
|
||||||
|
location: {
|
||||||
|
column: 'number',
|
||||||
|
file: 'a.test.ts',
|
||||||
|
line: 'number',
|
||||||
|
},
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
category: 'test.step',
|
category: 'test.step',
|
||||||
|
location: {
|
||||||
|
column: 'number',
|
||||||
|
file: 'a.test.ts',
|
||||||
|
line: 'number',
|
||||||
|
},
|
||||||
title: 'inner step 2.1',
|
title: 'inner step 2.1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: 'test.step',
|
category: 'test.step',
|
||||||
|
location: {
|
||||||
|
column: 'number',
|
||||||
|
file: 'a.test.ts',
|
||||||
|
line: 'number',
|
||||||
|
},
|
||||||
title: 'inner step 2.2',
|
title: 'inner step 2.2',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -121,6 +161,11 @@ test('should report api step hierarchy', async ({ runInlineTest }) => {
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
category: 'pw:api',
|
category: 'pw:api',
|
||||||
|
location: {
|
||||||
|
column: 'number',
|
||||||
|
file: 'index.ts',
|
||||||
|
line: 'number',
|
||||||
|
},
|
||||||
title: 'browserContext.close',
|
title: 'browserContext.close',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -156,12 +201,22 @@ test('should not report nested after hooks', async ({ runInlineTest }) => {
|
||||||
{
|
{
|
||||||
category: 'pw:api',
|
category: 'pw:api',
|
||||||
title: 'browserContext.newPage',
|
title: 'browserContext.newPage',
|
||||||
|
location: {
|
||||||
|
column: 'number',
|
||||||
|
file: 'index.ts',
|
||||||
|
line: 'number',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: 'test.step',
|
category: 'test.step',
|
||||||
title: 'my step',
|
title: 'my step',
|
||||||
|
location: {
|
||||||
|
column: 'number',
|
||||||
|
file: 'a.test.ts',
|
||||||
|
line: 'number',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: 'hook',
|
category: 'hook',
|
||||||
|
|
@ -170,6 +225,11 @@ test('should not report nested after hooks', async ({ runInlineTest }) => {
|
||||||
{
|
{
|
||||||
category: 'pw:api',
|
category: 'pw:api',
|
||||||
title: 'browserContext.close',
|
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`,
|
`%% 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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ export interface TestResult {
|
||||||
export interface TestStep {
|
export interface TestStep {
|
||||||
title: string;
|
title: string;
|
||||||
titlePath(): string[];
|
titlePath(): string[];
|
||||||
|
location?: Location;
|
||||||
parent?: TestStep;
|
parent?: TestStep;
|
||||||
category: string,
|
category: string,
|
||||||
startTime: Date;
|
startTime: Date;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue