feat(tracing) Adding groups to trace via pw-api (#33081)

Signed-off-by: René <snooz@posteo.de>
Signed-off-by: René <41592183+Snooz82@users.noreply.github.com>
Co-authored-by: Dmitry Gozman <dgozman@gmail.com>
This commit is contained in:
René 2024-11-05 12:45:37 +01:00 committed by GitHub
parent da4614ea7c
commit fa10bcd5a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 356 additions and 11 deletions

View File

@ -281,6 +281,56 @@ given name prefix inside the [`option: BrowserType.launch.tracesDir`] directory
To specify the final trace zip file name, you need to pass `path` option to
[`method: Tracing.stopChunk`] instead.
## async method: Tracing.group
* since: v1.49
Creates a new inline group within the trace, assigning any subsequent calls to this group until [method: Tracing.groupEnd] is invoked.
Groups can be nested and are similar to `test.step` in trace.
However, groups are only visualized in the trace viewer and, unlike test.step, have no effect on the test reports.
:::note Groups should not be used with Playwright Test!
This API is intended for Playwright API users that can not use `test.step`.
:::
**Usage**
```js
await context.tracing.start({ screenshots: true, snapshots: true });
await context.tracing.group('Open Playwright.dev');
// All actions between group and groupEnd will be shown in the trace viewer as a group.
const page = await context.newPage();
await page.goto('https://playwright.dev/');
await context.tracing.groupEnd();
await context.tracing.group('Open API Docs of Tracing');
await page.getByRole('link', { name: 'API' }).click();
await page.getByRole('link', { name: 'Tracing' }).click();
await context.tracing.groupEnd();
// This Trace will have two groups: 'Open Playwright.dev' and 'Open API Docs of Tracing'.
```
### param: Tracing.group.name
* since: v1.49
- `name` <[string]>
Group name shown in the actions tree in trace viewer.
### option: Tracing.group.location
* since: v1.49
- `location` ?<[Object]>
- `file` <[string]> Source file path to be shown in the trace viewer source tab.
- `line` ?<[int]> Line number in the source file.
- `column` ?<[int]> Column number in the source file
Specifies a custom location for the group start to be shown in source tab in trace viewer.
By default, location of the tracing.group() call is shown.
## async method: Tracing.groupEnd
* since: v1.49
Closes the currently open inline group in the trace.
## async method: Tracing.stop
* since: v1.12

View File

@ -168,7 +168,7 @@ 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?: boolean): Promise<R> {
const logger = this._logger;
const apiZone = zones.zoneData<ApiZone>('apiZone');
if (apiZone)
@ -178,7 +178,8 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
let apiName: string | undefined = stackTrace.apiName;
const frames: channels.StackFrame[] = stackTrace.frames;
isInternal = isInternal || this._isInternalType;
if (isInternal === undefined)
isInternal = this._isInternalType;
if (isInternal)
apiName = undefined;

View File

@ -51,6 +51,18 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
await this._startCollectingStacks(traceName);
}
async group(name: string, options: { location?: { file: string, line?: number, column?: number } } = {}) {
await this._wrapApiCall(async () => {
await this._channel.tracingGroup({ name, location: options.location });
}, false);
}
async groupEnd() {
await this._wrapApiCall(async () => {
await this._channel.tracingGroupEnd();
}, false);
}
private async _startCollectingStacks(traceName: string) {
if (!this._isTracing) {
this._isTracing = true;

View File

@ -2297,6 +2297,17 @@ scheme.TracingTracingStartChunkParams = tObject({
scheme.TracingTracingStartChunkResult = tObject({
traceName: tString,
});
scheme.TracingTracingGroupParams = tObject({
name: tString,
location: tOptional(tObject({
file: tString,
line: tOptional(tNumber),
column: tOptional(tNumber),
})),
});
scheme.TracingTracingGroupResult = tOptional(tObject({}));
scheme.TracingTracingGroupEndParams = tOptional(tObject({}));
scheme.TracingTracingGroupEndResult = tOptional(tObject({}));
scheme.TracingTracingStopChunkParams = tObject({
mode: tEnum(['archive', 'discard', 'entries']),
});

View File

@ -15,6 +15,7 @@
*/
import type * as channels from '@protocol/channels';
import type { CallMetadata } from '@protocol/callMetadata';
import type { Tracing } from '../trace/recorder/tracing';
import { ArtifactDispatcher } from './artifactDispatcher';
import { Dispatcher, existingDispatcher } from './dispatcher';
@ -41,6 +42,15 @@ export class TracingDispatcher extends Dispatcher<Tracing, channels.TracingChann
return await this._object.startChunk(params);
}
async tracingGroup(params: channels.TracingTracingGroupParams, metadata: CallMetadata): Promise<channels.TracingTracingGroupResult> {
const { name, location } = params;
await this._object.group(name, location, metadata);
}
async tracingGroupEnd(params: channels.TracingTracingGroupEndParams): Promise<channels.TracingTracingGroupEndResult> {
await this._object.groupEnd();
}
async tracingStopChunk(params: channels.TracingTracingStopChunkParams): Promise<channels.TracingTracingStopChunkResult> {
const { artifact, entries } = await this._object.stopChunk(params);
return { artifact: artifact ? ArtifactDispatcher.from(this, artifact) : undefined, entries };

View File

@ -18,7 +18,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import type { NameValue } from '../../../common/types';
import type { TracingTracingStopChunkParams } from '@protocol/channels';
import type { TracingTracingStopChunkParams, StackFrame } from '@protocol/channels';
import { commandsWithTracingSnapshots } from '../../../protocol/debug';
import { assert, createGuid, monotonicTime, SerializedFS, removeFolders, eventsHelper, type RegisteredListener } from '../../../utils';
import { Artifact } from '../../artifact';
@ -61,6 +61,7 @@ type RecordingState = {
traceSha1s: Set<string>,
recording: boolean;
callIds: Set<string>;
groupStack: string[];
};
const kScreencastOptions = { width: 800, height: 600, quality: 90 };
@ -148,6 +149,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
networkSha1s: new Set(),
recording: false,
callIds: new Set(),
groupStack: [],
};
this._fs.mkdir(this._state.resourcesDir);
this._fs.writeFile(this._state.networkFile, '');
@ -194,6 +196,53 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
return { traceName: this._state.traceName };
}
private _currentGroupId(): string | undefined {
return this._state?.groupStack.length ? this._state.groupStack[this._state.groupStack.length - 1] : undefined;
}
async group(name: string, location: { file: string, line?: number, column?: number } | undefined, metadata: CallMetadata): Promise<void> {
if (!this._state)
return;
const stackFrames: StackFrame[] = [];
const { file, line, column } = location ?? metadata.location ?? {};
if (file) {
stackFrames.push({
file,
line: line ?? 0,
column: column ?? 0,
});
}
const event: trace.BeforeActionTraceEvent = {
type: 'before',
callId: metadata.id,
startTime: metadata.startTime,
apiName: name,
class: 'Tracing',
method: 'tracingGroup',
params: { },
stepId: metadata.stepId,
stack: stackFrames,
};
if (this._currentGroupId())
event.parentId = this._currentGroupId();
this._state.groupStack.push(event.callId);
this._appendTraceEvent(event);
}
async groupEnd(): Promise<void> {
if (!this._state)
return;
const callId = this._state.groupStack.pop();
if (!callId)
return;
const event: trace.AfterActionTraceEvent = {
type: 'after',
callId,
endTime: monotonicTime(),
};
this._appendTraceEvent(event);
}
private _startScreencast() {
if (!(this._context instanceof BrowserContext))
return;
@ -236,6 +285,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
throw new Error(`Tracing is already stopping`);
if (this._state.recording)
throw new Error(`Must stop trace file before stopping tracing`);
await this._closeAllGroups();
this._harTracer.stop();
this.flushHarEntries();
await this._fs.syncAndGetError();
@ -264,6 +314,11 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
await this._fs.syncAndGetError();
}
async _closeAllGroups() {
while (this._currentGroupId())
await this.groupEnd();
}
async stopChunk(params: TracingTracingStopChunkParams): Promise<{ artifact?: Artifact, entries?: NameValue[] }> {
if (this._isStopping)
throw new Error(`Tracing is already stopping`);
@ -276,6 +331,8 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
return {};
}
await this._closeAllGroups();
this._context.instrumentation.removeListener(this);
eventsHelper.removeEventListeners(this._eventListeners);
if (this._state.options.screenshots)
@ -354,7 +411,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) {
// IMPORTANT: no awaits before this._appendTraceEvent in this method.
const event = createBeforeActionTraceEvent(metadata);
const event = createBeforeActionTraceEvent(metadata, this._currentGroupId());
if (!event)
return Promise.resolve();
sdkObject.attribution.page?.temporarilyDisableTracingScreencastThrottling();
@ -571,10 +628,10 @@ export function shouldCaptureSnapshot(metadata: CallMetadata): boolean {
return commandsWithTracingSnapshots.has(metadata.type + '.' + metadata.method);
}
function createBeforeActionTraceEvent(metadata: CallMetadata): trace.BeforeActionTraceEvent | null {
function createBeforeActionTraceEvent(metadata: CallMetadata, parentId?: string): trace.BeforeActionTraceEvent | null {
if (metadata.internal || metadata.method.startsWith('tracing'))
return null;
return {
const event: trace.BeforeActionTraceEvent = {
type: 'before',
callId: metadata.id,
startTime: metadata.startTime,
@ -585,6 +642,9 @@ function createBeforeActionTraceEvent(metadata: CallMetadata): trace.BeforeActio
stepId: metadata.stepId,
pageId: metadata.pageId,
};
if (parentId)
event.parentId = parentId;
return event;
}
function createInputActionTraceEvent(metadata: CallMetadata): trace.InputActionTraceEvent | null {

View File

@ -21055,6 +21055,62 @@ export interface Touchscreen {
*
*/
export interface Tracing {
/**
* Creates a new inline group within the trace, assigning any subsequent calls to this group until
* [method: Tracing.groupEnd] is invoked.
*
* Groups can be nested and are similar to `test.step` in trace. However, groups are only visualized in the trace
* viewer and, unlike test.step, have no effect on the test reports.
*
* **NOTE** This API is intended for Playwright API users that can not use `test.step`.
*
* **Usage**
*
* ```js
* await context.tracing.start({ screenshots: true, snapshots: true });
* await context.tracing.group('Open Playwright.dev');
* // All actions between group and groupEnd will be shown in the trace viewer as a group.
* const page = await context.newPage();
* await page.goto('https://playwright.dev/');
* await context.tracing.groupEnd();
* await context.tracing.group('Open API Docs of Tracing');
* await page.getByRole('link', { name: 'API' }).click();
* await page.getByRole('link', { name: 'Tracing' }).click();
* await context.tracing.groupEnd();
* // This Trace will have two groups: 'Open Playwright.dev' and 'Open API Docs of Tracing'.
* ```
*
* @param name Group name shown in the actions tree in trace viewer.
* @param options
*/
group(name: string, options?: {
/**
* Specifies a custom location for the group start to be shown in source tab in trace viewer. By default, location of
* the tracing.group() call is shown.
*/
location?: {
/**
* Source file path to be shown in the trace viewer source tab.
*/
file: string;
/**
* Line number in the source file.
*/
line?: number;
/**
* Column number in the source file
*/
column?: number;
};
}): Promise<void>;
/**
* Closes the currently open inline group in the trace.
*/
groupEnd(): Promise<void>;
/**
* Start tracing.
*

View File

@ -20,7 +20,7 @@ import type { APIRequestContext, BrowserContext, Browser, BrowserContextOptions,
import * as playwrightLibrary from 'playwright-core';
import { createGuid, debugMode, addInternalStackPrefix, isString, asLocator, jsonStringifyForceASCII } from 'playwright-core/lib/utils';
import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test';
import type { TestInfoImpl } from './worker/testInfo';
import type { TestInfoImpl, TestStepInternal } from './worker/testInfo';
import { rootTestType } from './common/testType';
import type { ContextReuseMode } from './common/config';
import type { ClientInstrumentation, ClientInstrumentationListener } from '../../playwright-core/src/client/clientInstrumentation';
@ -255,20 +255,28 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
const artifactsRecorder = new ArtifactsRecorder(playwright, tracing().artifactsDir(), screenshot);
await artifactsRecorder.willStartTest(testInfo as TestInfoImpl);
const tracingGroupSteps: TestStepInternal[] = [];
const csiListener: ClientInstrumentationListener = {
onApiCallBegin: (apiName: string, params: Record<string, any>, frames: StackFrame[], userData: any, out: { stepId?: string }) => {
const testInfo = currentTestInfo();
if (!testInfo || apiName.includes('setTestIdAttribute'))
return { userObject: null };
if (apiName === 'tracing.groupEnd') {
tracingGroupSteps.pop();
return { userObject: null };
}
const step = testInfo._addStep({
location: frames[0] as any,
category: 'pw:api',
title: renderApiCall(apiName, params),
apiName,
params,
});
}, tracingGroupSteps[tracingGroupSteps.length - 1]);
userData.userObject = step;
out.stepId = step.stepId;
if (apiName === 'tracing.group')
tracingGroupSteps.push(step);
},
onApiCallEnd: (userData: any, error?: Error) => {
const step = userData.userObject;

View File

@ -238,15 +238,15 @@ export class TestInfoImpl implements TestInfo {
}
}
_addStep(data: Omit<TestStepInternal, 'complete' | 'stepId' | 'steps'>): TestStepInternal {
_addStep(data: Omit<TestStepInternal, 'complete' | 'stepId' | 'steps'>, parentStep?: TestStepInternal): TestStepInternal {
const stepId = `${data.category}@${++this._lastStepId}`;
let parentStep: TestStepInternal | undefined;
if (data.isStage) {
// Predefined stages form a fixed hierarchy - use the current one as parent.
parentStep = this._findLastStageStep(this._steps);
} else {
parentStep = zones.zoneData<TestStepInternal>('stepZone');
if (!parentStep)
parentStep = zones.zoneData<TestStepInternal>('stepZone');
if (!parentStep) {
// If no parent step on stack, assume the current stage as parent.
parentStep = this._findLastStageStep(this._steps);

View File

@ -4086,6 +4086,8 @@ export interface TracingChannel extends TracingEventTarget, Channel {
_type_Tracing: boolean;
tracingStart(params: TracingTracingStartParams, metadata?: CallMetadata): Promise<TracingTracingStartResult>;
tracingStartChunk(params: TracingTracingStartChunkParams, metadata?: CallMetadata): Promise<TracingTracingStartChunkResult>;
tracingGroup(params: TracingTracingGroupParams, metadata?: CallMetadata): Promise<TracingTracingGroupResult>;
tracingGroupEnd(params?: TracingTracingGroupEndParams, metadata?: CallMetadata): Promise<TracingTracingGroupEndResult>;
tracingStopChunk(params: TracingTracingStopChunkParams, metadata?: CallMetadata): Promise<TracingTracingStopChunkResult>;
tracingStop(params?: TracingTracingStopParams, metadata?: CallMetadata): Promise<TracingTracingStopResult>;
}
@ -4113,6 +4115,25 @@ export type TracingTracingStartChunkOptions = {
export type TracingTracingStartChunkResult = {
traceName: string,
};
export type TracingTracingGroupParams = {
name: string,
location?: {
file: string,
line?: number,
column?: number,
},
};
export type TracingTracingGroupOptions = {
location?: {
file: string,
line?: number,
column?: number,
},
};
export type TracingTracingGroupResult = void;
export type TracingTracingGroupEndParams = {};
export type TracingTracingGroupEndOptions = {};
export type TracingTracingGroupEndResult = void;
export type TracingTracingStopChunkParams = {
mode: 'archive' | 'discard' | 'entries',
};

View File

@ -3198,6 +3198,18 @@ Tracing:
returns:
traceName: string
tracingGroup:
parameters:
name: string
location:
type: object?
properties:
file: string
line: number?
column: number?
tracingGroupEnd:
tracingStopChunk:
parameters:
mode:

View File

@ -36,6 +36,7 @@ export type TraceViewerFixtures = {
class TraceViewerPage {
actionTitles: Locator;
actionsTree: Locator;
callLines: Locator;
consoleLines: Locator;
logLines: Locator;
@ -46,9 +47,11 @@ class TraceViewerPage {
networkRequests: Locator;
metadataTab: Locator;
snapshotContainer: Locator;
sourceCodeTab: Locator;
constructor(public page: Page) {
this.actionTitles = page.locator('.action-title');
this.actionsTree = page.getByTestId('actions-tree');
this.callLines = page.locator('.call-tab .call-line');
this.logLines = page.getByTestId('log-list').locator('.list-view-entry');
this.consoleLines = page.locator('.console-line');
@ -59,6 +62,7 @@ class TraceViewerPage {
this.networkRequests = page.getByTestId('network-list').locator('.list-view-entry');
this.snapshotContainer = page.locator('.snapshot-container iframe.snapshot-visible[name=snapshot]');
this.metadataTab = page.getByTestId('metadata-view');
this.sourceCodeTab = page.getByTestId('source-code');
}
async actionIconsText(action: string) {

View File

@ -103,6 +103,106 @@ test('should open trace viewer on specific host', async ({ showTraceViewer }, te
await expect(traceViewer.page).toHaveURL(/127.0.0.1/);
});
test('should show groups as tree in trace viewer', async ({ runAndTrace, page, context }) => {
const outerGroup = 'Outer Group';
const outerGroupContent = 'locator.clickgetByText(\'Click\')';
const firstInnerGroup = 'First Inner Group';
const firstInnerGroupContent = 'locator.clicklocator(\'button\').first()';
const secondInnerGroup = 'Second Inner Group';
const secondInnerGroupContent = 'expect.toBeVisiblegetByText(\'Click\')';
const expandedFailure = 'Expanded Failure';
const traceViewer = await test.step('create trace with groups', async () => {
return await runAndTrace(async () => {
try {
await page.goto(`data:text/html,<!DOCTYPE html><html>Hello world</html>`);
await page.setContent('<!DOCTYPE html><button>Click</button>');
async function doClick() {
await page.getByText('Click').click();
}
await context.tracing.group(outerGroup); // Outer group
await doClick();
await context.tracing.group(firstInnerGroup, { location: { file: `${__dirname}/tracing.spec.ts`, line: 100, column: 10 } });
await page.locator('button >> nth=0').click();
await context.tracing.groupEnd();
await context.tracing.group(secondInnerGroup, { location: { file: __filename } });
await expect(page.getByText('Click')).toBeVisible();
await context.tracing.groupEnd();
await context.tracing.groupEnd();
await context.tracing.group(expandedFailure);
try {
await expect(page.getByText('Click')).toBeHidden({ timeout: 1 });
} catch (e) {}
await context.tracing.groupEnd();
await page.evaluate(() => console.log('ungrouped'), null);
} catch (e) {}
});
}, { box: true });
const treeViewEntries = traceViewer.actionsTree.locator('.tree-view-entry');
await test.step('check automatic expansion of groups on failure', async () => {
await expect(traceViewer.actionTitles).toHaveText([
/page.gotodata:text\/html,<!DOCTYPE html><html>Hello world<\/html>/,
/page.setContent/,
outerGroup,
expandedFailure,
/expect.toBeHiddengetByText\('Click'\)/,
/page.evaluate/,
]);
await expect(traceViewer.actionsTree.locator('.tree-view-entry.selected > .tree-view-indent')).toHaveCount(1);
await expect(traceViewer.actionsTree.locator('.tree-view-entry.selected')).toHaveText(/expect.toBeHiddengetByText\('Click'\)/);
await treeViewEntries.filter({ hasText: expandedFailure }).locator('.codicon-chevron-down').click();
});
await test.step('check outer group', async () => {
await treeViewEntries.filter({ hasText: outerGroup }).locator('.codicon-chevron-right').click();
await expect(traceViewer.actionTitles).toHaveText([
/page.gotodata:text\/html,<!DOCTYPE html><html>Hello world<\/html>/,
/page.setContent/,
outerGroup,
outerGroupContent,
firstInnerGroup,
secondInnerGroup,
expandedFailure,
/page.evaluate/,
]);
await expect(treeViewEntries.filter({ hasText: firstInnerGroup }).locator(' > .tree-view-indent')).toHaveCount(1);
await expect(treeViewEntries.filter({ hasText: secondInnerGroup }).locator(' > .tree-view-indent')).toHaveCount(1);
await test.step('check automatic location of groups', async () => {
await traceViewer.showSourceTab();
await traceViewer.selectAction(outerGroup);
await expect(traceViewer.sourceCodeTab.locator('.source-tab-file-name')).toHaveAttribute('title', __filename);
await expect(traceViewer.sourceCodeTab.locator('.source-line-running')).toHaveText(/\d+\s+await context.tracing.group\(outerGroup\); \/\/ Outer group/);
});
});
await test.step('check inner groups', async () => {
await treeViewEntries.filter({ hasText: firstInnerGroup }).locator('.codicon-chevron-right').click();
await treeViewEntries.filter({ hasText: secondInnerGroup }).locator('.codicon-chevron-right').click();
await expect(traceViewer.actionTitles).toHaveText([
/page.gotodata:text\/html,<!DOCTYPE html><html>Hello world<\/html>/,
/page.setContent/,
outerGroup,
outerGroupContent,
firstInnerGroup,
firstInnerGroupContent,
secondInnerGroup,
secondInnerGroupContent,
expandedFailure,
/page.evaluate/,
]);
await expect(treeViewEntries.filter({ hasText: firstInnerGroupContent }).locator(' > .tree-view-indent')).toHaveCount(2);
await expect(treeViewEntries.filter({ hasText: secondInnerGroupContent }).locator(' > .tree-view-indent')).toHaveCount(2);
await test.step('check location with file, line, column', async () => {
await traceViewer.selectAction(firstInnerGroup);
await expect(traceViewer.sourceCodeTab.locator('.source-tab-file-name')).toHaveAttribute('title', `${__dirname}/tracing.spec.ts`);
});
await test.step('check location with file', async () => {
await traceViewer.selectAction(secondInnerGroup);
await expect(traceViewer.sourceCodeTab.getByText(/Licensed under the Apache License/)).toBeVisible();
});
});
});
test('should open simple trace viewer', async ({ showTraceViewer }) => {
const traceViewer = await showTraceViewer([traceFile]);
await expect(traceViewer.actionTitles).toHaveText([