diff --git a/docs/src/api/class-tracing.md b/docs/src/api/class-tracing.md index 065896925fa74..353a220516698 100644 --- a/docs/src/api/class-tracing.md +++ b/docs/src/api/class-tracing.md @@ -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 diff --git a/packages/playwright-core/src/client/tracing.ts b/packages/playwright-core/src/client/tracing.ts index b5c411cc6533e..a85d951388a89 100644 --- a/packages/playwright-core/src/client/tracing.ts +++ b/packages/playwright-core/src/client/tracing.ts @@ -51,6 +51,14 @@ export class Tracing extends ChannelOwner implements ap await this._startCollectingStacks(traceName); } + async group(name: string, options: { location?: { file: string, line?: number, column?: number } } = {}) { + await this._channel.tracingGroup({ name, location: options.location }); + } + + async groupEnd() { + await this._channel.tracingGroupEnd(); + } + private async _startCollectingStacks(traceName: string) { if (!this._isTracing) { this._isTracing = true; diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 24ddf0014c0d1..1962826ae5041 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -2295,6 +2295,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']), }); diff --git a/packages/playwright-core/src/server/dispatchers/tracingDispatcher.ts b/packages/playwright-core/src/server/dispatchers/tracingDispatcher.ts index b8214fbe31b5f..5555de15d1e1b 100644 --- a/packages/playwright-core/src/server/dispatchers/tracingDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/tracingDispatcher.ts @@ -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 { + const { name, location } = params; + await this._object.group(name, location, metadata); + } + + async tracingGroupEnd(params: channels.TracingTracingGroupEndParams): Promise { + await this._object.groupEnd(); + } + async tracingStopChunk(params: channels.TracingTracingStopChunkParams): Promise { const { artifact, entries } = await this._object.stopChunk(params); return { artifact: artifact ? ArtifactDispatcher.from(this, artifact) : undefined, entries }; diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index 97476d4b3122a..0184c1a2a5322 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -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, recording: boolean; callIds: Set; + 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,48 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps return { traceName: this._state.traceName }; } + async group(name: string, location: { file: string, line?: number, column?: number } | undefined, metadata: CallMetadata): Promise { + 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: 'group', + params: { }, + stack: stackFrames, + }; + if (this._state.groupStack.length) + event.parentId = this._state.groupStack[this._state.groupStack.length - 1]; + this._state.groupStack.push(event.callId); + this._appendTraceEvent(event); + } + + async groupEnd(): Promise { + 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 +280,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 +309,11 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps await this._fs.syncAndGetError(); } + async _closeAllGroups() { + while (this._state?.groupStack.length) + await this.groupEnd(); + } + async stopChunk(params: TracingTracingStopChunkParams): Promise<{ artifact?: Artifact, entries?: NameValue[] }> { if (this._isStopping) throw new Error(`Tracing is already stopping`); @@ -276,6 +326,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 +406,10 @@ 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._state?.groupStack.length ? this._state.groupStack[this._state.groupStack.length - 1] : undefined + ); if (!event) return Promise.resolve(); sdkObject.attribution.page?.temporarilyDisableTracingScreencastThrottling(); @@ -571,10 +626,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 +640,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 { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 092f75b2636bc..ee8cb8b1016a7 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -21058,6 +21058,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; + + /** + * Closes the currently open inline group in the trace. + */ + groupEnd(): Promise; + /** * Start tracing. * diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 5ecc2f4077e07..073f9c5486684 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -4085,6 +4085,8 @@ export interface TracingChannel extends TracingEventTarget, Channel { _type_Tracing: boolean; tracingStart(params: TracingTracingStartParams, metadata?: CallMetadata): Promise; tracingStartChunk(params: TracingTracingStartChunkParams, metadata?: CallMetadata): Promise; + tracingGroup(params: TracingTracingGroupParams, metadata?: CallMetadata): Promise; + tracingGroupEnd(params?: TracingTracingGroupEndParams, metadata?: CallMetadata): Promise; tracingStopChunk(params: TracingTracingStopChunkParams, metadata?: CallMetadata): Promise; tracingStop(params?: TracingTracingStopParams, metadata?: CallMetadata): Promise; } @@ -4112,6 +4114,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', }; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index c91cecbe6cab7..f46e973a7b713 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -3196,6 +3196,18 @@ Tracing: returns: traceName: string + tracingGroup: + parameters: + name: string + location: + type: object? + properties: + file: string + line: number? + column: number? + + tracingGroupEnd: + tracingStopChunk: parameters: mode: diff --git a/tests/config/traceViewerFixtures.ts b/tests/config/traceViewerFixtures.ts index 3eb3b11a153c7..837b953ef4f28 100644 --- a/tests/config/traceViewerFixtures.ts +++ b/tests/config/traceViewerFixtures.ts @@ -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) { diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 0eda4b092a111..10c0e490fa70b 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -62,6 +62,15 @@ test.beforeAll(async function recordTrace({ browser, browserName, browserType, s } await doClick(); + await context.tracing.group('High-level Group'); + await context.tracing.group('First Mid-level Group', { 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('Second Mid-level Group', { location: { file: __filename } }); + await expect(page.getByText('Click')).toBeVisible(); + await context.tracing.groupEnd(); + await context.tracing.groupEnd(); + await Promise.all([ page.waitForNavigation(), page.waitForResponse(server.PREFIX + '/frames/frame.html'), @@ -102,6 +111,67 @@ 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 ({ showTraceViewer }) => { + const traceViewer = await showTraceViewer([traceFile]); + await expect(traceViewer.actionTitles).toHaveText([ + /browserContext.newPage/, + /page.gotodata:text\/html,Hello world<\/html>/, + /page.setContent/, + /expect.toHaveTextlocator\('button'\)/, + /expect.toBeHiddengetByTestId\('amazing-btn'\)/, + /expect.toBeHiddengetByTestId\(\/amazing-btn-regex\/\)/, + /page.evaluate/, + /page.evaluate/, + /locator.clickgetByText\('Click'\)/, + /High-level Group/, + /page.waitForNavigation/, + /page.waitForResponse/, + /page.waitForTimeout/, + /page.gotohttp:\/\/localhost:\d+\/frames\/frame.html/, + /page.setViewportSize/, + ]); + await traceViewer.actionsTree.locator('.tree-view-entry:has-text("High-level Group") .codicon-chevron-right').click(); + await traceViewer.actionsTree.locator('.tree-view-entry:has-text("First Mid-level Group") .codicon-chevron-right').click(); + await traceViewer.actionsTree.locator('.tree-view-entry:has-text("Second Mid-level Group") .codicon-chevron-right').click(); + await expect(traceViewer.actionTitles).toHaveText([ + /browserContext.newPage/, + /page.gotodata:text\/html,Hello world<\/html>/, + /page.setContent/, + /expect.toHaveTextlocator\('button'\)/, + /expect.toBeHiddengetByTestId\('amazing-btn'\)/, + /expect.toBeHiddengetByTestId\(\/amazing-btn-regex\/\)/, + /page.evaluate/, + /page.evaluate/, + /locator.clickgetByText\('Click'\)/, + /High-level Group/, + /First Mid-level Group/, + /locator\.clicklocator\('button'\)\.first\(\)/, + /Second Mid-level Group/, + /expect\.toBeVisiblegetByText\('Click'\)/, + /page.waitForNavigation/, + /page.waitForResponse/, + /page.waitForTimeout/, + /page.gotohttp:\/\/localhost:\d+\/frames\/frame.html/, + /page.setViewportSize/, + ]); + await expect(traceViewer.actionsTree.locator('.tree-view-entry:has-text("First Mid-level Group") > .tree-view-indent')).toHaveCount(1); + await expect(traceViewer.actionsTree.locator('.tree-view-entry:has-text("Second Mid-level Group") > .tree-view-indent')).toHaveCount(1); + await expect(traceViewer.actionsTree.locator('.tree-view-entry:has-text("locator.clicklocator(\'button\').first()") > .tree-view-indent')).toHaveCount(2); + await expect(traceViewer.actionsTree.locator('.tree-view-entry:has-text("expect.toBeVisiblegetByText(\'Click\')") > .tree-view-indent')).toHaveCount(2); + + await traceViewer.showSourceTab(); + await traceViewer.selectAction('High-level Group'); + 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\('High-level Group'\);/); + + await traceViewer.selectAction('First Mid-level Group'); + await expect(traceViewer.sourceCodeTab.locator('.source-tab-file-name')).toHaveAttribute('title', `${__dirname}/tracing.spec.ts`); + + await traceViewer.selectAction('Second Mid-level Group'); + 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([ @@ -114,6 +184,7 @@ test('should open simple trace viewer', async ({ showTraceViewer }) => { /page.evaluate/, /page.evaluate/, /locator.clickgetByText\('Click'\)/, + /High-level Group/, /page.waitForNavigation/, /page.waitForResponse/, /page.waitForTimeout/,