diff --git a/docs/src/api/class-tracing.md b/docs/src/api/class-tracing.md index 964b0492fc..62b86f28c1 100644 --- a/docs/src/api/class-tracing.md +++ b/docs/src/api/class-tracing.md @@ -1,9 +1,8 @@ # class: Tracing -API for collecting and saving Playwright traces. Playwright traces can be opened using the Playwright CLI after -Playwright script runs. +API for collecting and saving Playwright traces. Playwright traces can be opened in [Trace Viewer](./trace-viewer.md) after Playwright script runs. -Start with specifying the folder traces will be stored in: +Start recording a trace before performing actions. At the end, stop tracing and save it to a file. ```js const browser = await chromium.launch(); @@ -30,6 +29,7 @@ context.tracing().stop(new Tracing.StopOptions() browser = await chromium.launch() context = await browser.new_context() await context.tracing.start(screenshots=True, snapshots=True) +page = await context.new_page() await page.goto("https://playwright.dev") await context.tracing.stop(path = "trace.zip") ``` @@ -38,6 +38,7 @@ await context.tracing.stop(path = "trace.zip") browser = chromium.launch() context = browser.new_context() context.tracing.start(screenshots=True, snapshots=True) +page = context.new_page() page.goto("https://playwright.dev") context.tracing.stop(path = "trace.zip") ``` @@ -81,15 +82,15 @@ context.tracing().stop(new Tracing.StopOptions() ```python async await context.tracing.start(name="trace", screenshots=True, snapshots=True) +page = await context.new_page() await page.goto("https://playwright.dev") -await context.tracing.stop() await context.tracing.stop(path = "trace.zip") ``` ```python sync context.tracing.start(name="trace", screenshots=True, snapshots=True) +page = context.new_page() page.goto("https://playwright.dev") -context.tracing.stop() context.tracing.stop(path = "trace.zip") ``` @@ -126,6 +127,110 @@ a timeline preview. Whether to capture DOM snapshot on every action. + + +## async method: Tracing.startChunk + +Start a new trace chunk. If you'd like to record multiple traces on the same [BrowserContext], use [`method: Tracing.start`] once, and then create multiple trace chunks with [`method: Tracing.startChunk`] and [`method: Tracing.stopChunk`]. + + +```js +await context.tracing.start({ screenshots: true, snapshots: true }); +const page = await context.newPage(); +await page.goto('https://playwright.dev'); + +await context.tracing.startChunk(); +await page.click('text=Get Started'); +// Everything between startChunk and stopChunk will be recorded in the trace. +await context.tracing.stopChunk({ path: 'trace1.zip' }); + +await context.tracing.startChunk(); +await page.goto('http://example.com'); +// Save a second trace file with different actions. +await context.tracing.stopChunk({ path: 'trace2.zip' }); +``` + +```java +context.tracing().start(new Tracing.StartOptions() + .setScreenshots(true) + .setSnapshots(true)); +Page page = context.newPage(); +page.navigate("https://playwright.dev"); + +context.tracing().startChunk(); +page.click("text=Get Started"); +// Everything between startChunk and stopChunk will be recorded in the trace. +context.tracing().stopChunk(new Tracing.StopChunkOptions() + .setPath(Paths.get("trace1.zip"))); + +context.tracing().startChunk(); +page.navigate("http://example.com"); +// Save a second trace file with different actions. +context.tracing().stopChunk(new Tracing.StopChunkOptions() + .setPath(Paths.get("trace2.zip"))); +``` + +```python async +await context.tracing.start(name="trace", screenshots=True, snapshots=True) +page = await context.new_page() +await page.goto("https://playwright.dev") + +await context.tracing.start_chunk() +await page.click("text=Get Started") +# Everything between start_chunk and stop_chunk will be recorded in the trace. +await context.tracing.stop_chunk(path = "trace1.zip") + +await context.tracing.start_chunk() +await page.goto("http://example.com") +# Save a second trace file with different actions. +await context.tracing.stop_chunk(path = "trace2.zip") +``` + +```python sync +context.tracing.start(name="trace", screenshots=True, snapshots=True) +page = context.new_page() +page.goto("https://playwright.dev") + +context.tracing.start_chunk() +page.click("text=Get Started") +# Everything between start_chunk and stop_chunk will be recorded in the trace. +context.tracing.stop_chunk(path = "trace1.zip") + +context.tracing.start_chunk() +page.goto("http://example.com") +# Save a second trace file with different actions. +context.tracing.stop_chunk(path = "trace2.zip") +``` + +```csharp +await using var browser = playwright.Chromium.LaunchAsync(); +await using var context = await browser.NewContextAsync(); +await context.Tracing.StartAsync(new TracingStartOptions +{ + Screenshots: true, + Snapshots: true +}); +var page = context.NewPageAsync(); +await page.GotoAsync("https://playwright.dev"); + +await context.Tracing.StartChunkAsync(); +await page.ClickAsync("text=Get Started"); +// Everything between StartChunkAsync and StopChunkAsync will be recorded in the trace. +await context.Tracing.StopChunkAsync(new TracingStopChunkOptions +{ + Path: "trace1.zip" +}); + +await context.Tracing.StartChunkAsync(); +await page.GotoAsync("http://example.com"); +// Save a second trace file with different actions. +await context.Tracing.StopChunkAsync(new TracingStopChunkOptions +{ + Path: "trace2.zip" +}); +``` + + ## async method: Tracing.stop Stop tracing. @@ -133,4 +238,15 @@ Stop tracing. ### option: Tracing.stop.path - `path` <[path]> -Export trace into the file with the given name. +Export trace into the file with the given path. + + + +## async method: Tracing.stopChunk + +Stop the trace chunk. See [`method: Tracing.startChunk`] for more details about multiple trace chunks. + +### option: Tracing.stopChunk.path +- `path` <[path]> + +Export trace collected since the last [`method: Tracing.startChunk`] call into the file with the given path. diff --git a/src/client/tracing.ts b/src/client/tracing.ts index d845e6a5d8..10cd63faa2 100644 --- a/src/client/tracing.ts +++ b/src/client/tracing.ts @@ -28,30 +28,38 @@ export class Tracing implements api.Tracing { async start(options: { name?: string, snapshots?: boolean, screenshots?: boolean } = {}) { await this._context._wrapApiCall(async (channel: channels.BrowserContextChannel) => { - return await channel.tracingStart(options); + await channel.tracingStart(options); + await channel.tracingStartChunk(); }); } - async _export(options: { path: string }) { + async startChunk() { await this._context._wrapApiCall(async (channel: channels.BrowserContextChannel) => { - await this._doExport(channel, options.path); + await channel.tracingStartChunk(); + }); + } + + async stopChunk(options: { path: string }) { + await this._context._wrapApiCall(async (channel: channels.BrowserContextChannel) => { + await this._doStopChunk(channel, options.path); }); } async stop(options: { path?: string } = {}) { await this._context._wrapApiCall(async (channel: channels.BrowserContextChannel) => { - if (options.path) - await this._doExport(channel, options.path); + await this._doStopChunk(channel, options.path); await channel.tracingStop(); }); } - private async _doExport(channel: channels.BrowserContextChannel, path: string) { - const result = await channel.tracingExport(); + private async _doStopChunk(channel: channels.BrowserContextChannel, path: string | undefined) { + const result = await channel.tracingStopChunk({ save: !!path }); + if (!result.artifact) + return; const artifact = Artifact.from(result.artifact); - if (this._context.browser()?._remoteType) + if (this._context._browser?._remoteType) artifact._isRemote = true; - await artifact.saveAs(path); + await artifact.saveAs(path!); await artifact.delete(); } } diff --git a/src/dispatchers/browserContextDispatcher.ts b/src/dispatchers/browserContextDispatcher.ts index 4d1a2a9385..4233ccfe90 100644 --- a/src/dispatchers/browserContextDispatcher.ts +++ b/src/dispatchers/browserContextDispatcher.ts @@ -212,13 +212,17 @@ export class BrowserContextDispatcher extends Dispatcher { - await this._context.tracing.stop(); + async tracingStartChunk(params: channels.BrowserContextTracingStartChunkParams): Promise { + await this._context.tracing.startChunk(); } - async tracingExport(params: channels.BrowserContextTracingExportParams): Promise { - const artifact = await this._context.tracing.export(); - return { artifact: new ArtifactDispatcher(this._scope, artifact) }; + async tracingStopChunk(params: channels.BrowserContextTracingStopChunkParams): Promise { + const artifact = await this._context.tracing.stopChunk(params.save); + return { artifact: artifact ? new ArtifactDispatcher(this._scope, artifact) : undefined }; + } + + async tracingStop(params: channels.BrowserContextTracingStopParams): Promise { + await this._context.tracing.stop(); } async harExport(params: channels.BrowserContextHarExportParams): Promise { diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 4b8d419800..ef1df1206f 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -763,8 +763,9 @@ export interface BrowserContextChannel extends EventTargetChannel { recorderSupplementEnable(params: BrowserContextRecorderSupplementEnableParams, metadata?: Metadata): Promise; newCDPSession(params: BrowserContextNewCDPSessionParams, metadata?: Metadata): Promise; tracingStart(params: BrowserContextTracingStartParams, metadata?: Metadata): Promise; + tracingStartChunk(params?: BrowserContextTracingStartChunkParams, metadata?: Metadata): Promise; + tracingStopChunk(params: BrowserContextTracingStopChunkParams, metadata?: Metadata): Promise; tracingStop(params?: BrowserContextTracingStopParams, metadata?: Metadata): Promise; - tracingExport(params?: BrowserContextTracingExportParams, metadata?: Metadata): Promise; harExport(params?: BrowserContextHarExportParams, metadata?: Metadata): Promise; } export type BrowserContextBindingCallEvent = { @@ -992,14 +993,21 @@ export type BrowserContextTracingStartOptions = { screenshots?: boolean, }; export type BrowserContextTracingStartResult = void; +export type BrowserContextTracingStartChunkParams = {}; +export type BrowserContextTracingStartChunkOptions = {}; +export type BrowserContextTracingStartChunkResult = void; +export type BrowserContextTracingStopChunkParams = { + save: boolean, +}; +export type BrowserContextTracingStopChunkOptions = { + +}; +export type BrowserContextTracingStopChunkResult = { + artifact?: ArtifactChannel, +}; export type BrowserContextTracingStopParams = {}; export type BrowserContextTracingStopOptions = {}; export type BrowserContextTracingStopResult = void; -export type BrowserContextTracingExportParams = {}; -export type BrowserContextTracingExportOptions = {}; -export type BrowserContextTracingExportResult = { - artifact: ArtifactChannel, -}; export type BrowserContextHarExportParams = {}; export type BrowserContextHarExportOptions = {}; export type BrowserContextHarExportResult = { diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index aa3ca05828..b108d06449 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -706,11 +706,15 @@ BrowserContext: snapshots: boolean? screenshots: boolean? - tracingStop: + tracingStartChunk: - tracingExport: + tracingStopChunk: + parameters: + save: boolean returns: - artifact: Artifact + artifact: Artifact? + + tracingStop: harExport: returns: diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index a2a53170a1..a974813f98 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -449,8 +449,11 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { snapshots: tOptional(tBoolean), screenshots: tOptional(tBoolean), }); + scheme.BrowserContextTracingStartChunkParams = tOptional(tObject({})); + scheme.BrowserContextTracingStopChunkParams = tObject({ + save: tBoolean, + }); scheme.BrowserContextTracingStopParams = tOptional(tObject({})); - scheme.BrowserContextTracingExportParams = tOptional(tObject({})); scheme.BrowserContextHarExportParams = tOptional(tObject({})); scheme.PageSetDefaultNavigationTimeoutNoReplyParams = tObject({ timeout: tNumber, diff --git a/src/server/snapshot/inMemorySnapshotter.ts b/src/server/snapshot/inMemorySnapshotter.ts index c64b2d1f69..421ab804c3 100644 --- a/src/server/snapshot/inMemorySnapshotter.ts +++ b/src/server/snapshot/inMemorySnapshotter.ts @@ -49,14 +49,16 @@ export class InMemorySnapshotter extends BaseSnapshotStorage implements Snapshot async reset() { await this._snapshotter.reset(); - await this._harTracer.stop(); + await this._harTracer.flush(); + this._harTracer.stop(); this._harTracer.start(); this.clear(); } async dispose() { this._snapshotter.dispose(); - await this._harTracer.stop(); + await this._harTracer.flush(); + this._harTracer.stop(); await this._server.stop(); } diff --git a/src/server/supplements/har/harRecorder.ts b/src/server/supplements/har/harRecorder.ts index 31789cfab3..3f38f47ef4 100644 --- a/src/server/supplements/har/harRecorder.ts +++ b/src/server/supplements/har/harRecorder.ts @@ -57,7 +57,8 @@ export class HarRecorder { if (this._isFlushed) return; this._isFlushed = true; - const log = await this._tracer.stop(); + await this._tracer.flush(); + const log = this._tracer.stop(); log.entries = this._entries; await fs.promises.writeFile(this._options.path, JSON.stringify({ log }, undefined, 2)); } diff --git a/src/server/supplements/har/harTracer.ts b/src/server/supplements/har/harTracer.ts index 9ad7e273a8..7bcc93ff44 100644 --- a/src/server/supplements/har/harTracer.ts +++ b/src/server/supplements/har/harTracer.ts @@ -295,11 +295,13 @@ export class HarTracer { })); } - async stop() { + async flush() { + await Promise.all(this._barrierPromises); + } + + stop() { this._started = false; eventsHelper.removeEventListeners(this._eventListeners); - - await Promise.all(this._barrierPromises); this._barrierPromises.clear(); const log: har.Log = { diff --git a/src/server/trace/recorder/tracing.ts b/src/server/trace/recorder/tracing.ts index efe46b0097..cd79015903 100644 --- a/src/server/trace/recorder/tracing.ts +++ b/src/server/trace/recorder/tracing.ts @@ -45,8 +45,9 @@ type RecordingState = { traceName: string, networkFile: string, traceFile: string, - lastReset: number, + filesCount: number, sha1s: Set, + recording: boolean; }; const kScreencastOptions = { width: 800, height: 600, quality: 90 }; @@ -59,7 +60,7 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate, Ha private _pendingCalls = new Map, actionSnapshot?: Promise, afterSnapshot?: Promise }>(); private _context: BrowserContext; private _resourcesDir: string; - private _recording: RecordingState | undefined; + private _state: RecordingState | undefined; private _isStopping = false; private _tracesDir: string; private _allResources = new Set(); @@ -83,55 +84,53 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate, Ha }; } - async start(options: TracerOptions): Promise { + start(options: TracerOptions) { if (this._isStopping) throw new Error('Cannot start tracing while stopping'); - // context + page must be the first events added, this method can't have awaits before them. - - const state = this._recording; - if (!state) { - // TODO: passing the same name for two contexts makes them write into a single file - // and conflict. - const traceName = options.name || createGuid(); - const traceFile = path.join(this._tracesDir, traceName + '.trace'); - const networkFile = path.join(this._tracesDir, traceName + '.network'); - this._recording = { options, traceName, traceFile, networkFile, lastReset: 0, sha1s: new Set() }; - this._writeChain = mkdirIfNeeded(traceFile).then(() => fs.promises.writeFile(networkFile, '')); - this._appendTraceEvent(this._contextCreatedEvent); + if (this._state) { + const o = this._state.options; + if (o.name !== options.name || !o.screenshots !== !options.screenshots || !o.snapshots !== !options.snapshots) + throw new Error('Tracing has been already started with different options'); + return; } - if (!state?.options?.screenshots && options.screenshots) - this._startScreencast(); - else if (state?.options?.screenshots && !options.screenshots) - this._stopScreencast(); + // TODO: passing the same name for two contexts makes them write into a single file + // and conflict. + const traceName = options.name || createGuid(); + const traceFile = path.join(this._tracesDir, traceName + '.trace'); + const networkFile = path.join(this._tracesDir, traceName + '.network'); + this._state = { options, traceName, traceFile, networkFile, filesCount: 0, sha1s: new Set(), recording: false }; - // context + page must be the first events added, no awaits above this line. - await fs.promises.mkdir(this._resourcesDir, { recursive: true }); + this._writeChain = fs.promises.mkdir(this._resourcesDir, { recursive: true }).then(() => fs.promises.writeFile(networkFile, '')); + if (options.snapshots) + this._harTracer.start(); + } - if (!state) - this._context.instrumentation.addListener(this); + async startChunk() { + if (this._state && this._state.recording) + await this.stopChunk(false); - await this._appendTraceOperation(async () => { - if (options.snapshots && state?.options?.snapshots) { - // Reset snapshots to avoid back-references. - await this._snapshotter.reset(); - } else if (options.snapshots) { - await this._snapshotter.start(); - this._harTracer.start(); - } else if (state?.options?.snapshots) { - await this._snapshotter.stop(); - await this._harTracer.stop(); - } + if (!this._state) + throw new Error('Must start tracing before starting a new chunk'); + if (this._isStopping) + throw new Error('Cannot start a trace chunk while stopping'); - if (state) { - state.lastReset++; - state.traceFile = path.join(this._tracesDir, `${state.traceName}-${state.lastReset}.trace`); - await fs.promises.appendFile(state.traceFile, JSON.stringify(this._contextCreatedEvent) + '\n'); - } + const state = this._state; + const suffix = state.filesCount ? `-${state.filesCount}` : ``; + state.filesCount++; + state.traceFile = path.join(this._tracesDir, `${state.traceName}${suffix}.trace`); + state.recording = true; + + this._appendTraceOperation(async () => { + await mkdirIfNeeded(state.traceFile); + await fs.promises.appendFile(state.traceFile, JSON.stringify(this._contextCreatedEvent) + '\n'); }); - if (this._recording) - this._recording.options = options; + this._context.instrumentation.addListener(this); + if (state.options.screenshots) + this._startScreencast(); + if (state.options.snapshots) + await this._snapshotter.start(); } private _startScreencast() { @@ -148,18 +147,16 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate, Ha page.setScreencastOptions(null); } - async stop(): Promise { - if (!this._recording || this._isStopping) + async stop() { + if (!this._state) return; - this._isStopping = true; - this._context.instrumentation.removeListener(this); - this._stopScreencast(); - await this._snapshotter.stop(); - await this._harTracer.stop(); - // Ensure all writes are finished. + if (this._isStopping) + throw new Error(`Tracing is already stopping`); + if (this._state.recording) + throw new Error(`Must stop trace file before stopping tracing`); + this._harTracer.stop(); await this._writeChain; - this._recording = undefined; - this._isStopping = false; + this._state = undefined; } async dispose() { @@ -167,7 +164,11 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate, Ha await this._writeChain; } - async export(): Promise { + async stopChunk(save: boolean): Promise { + if (this._isStopping) + throw new Error(`Tracing is already stopping`); + this._isStopping = true; + for (const { sdkObject, metadata, beforeSnapshot, actionSnapshot, afterSnapshot } of this._pendingCalls.values()) { await Promise.all([beforeSnapshot, actionSnapshot, afterSnapshot]); let callMetadata = metadata; @@ -181,32 +182,50 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate, Ha await this.onAfterCall(sdkObject, callMetadata); } - if (!this._recording) - throw new Error('Must start tracing before exporting'); + if (!this._state || !this._state.recording) { + this._isStopping = false; + if (save) + throw new Error(`Must start tracing before stopping`); + return null; + } + + const state = this._state!; + this._context.instrumentation.removeListener(this); + if (state.options.screenshots) + this._stopScreencast(); + if (state.options.snapshots) + await this._snapshotter.stop(); // Chain the export operation against write operations, // so that neither trace files nor sha1s change during the export. return await this._appendTraceOperation(async () => { - const state = this._recording!; - const zipFile = new yazl.ZipFile(); - const failedPromise = new Promise((_, reject) => (zipFile as any as EventEmitter).on('error', reject)); - const succeededPromise = new Promise(fulfill => { - zipFile.addFile(state.traceFile, 'trace.trace'); - zipFile.addFile(state.networkFile, 'trace.network'); - const zipFileName = state.traceFile + '.zip'; - for (const sha1 of state.sha1s) - zipFile.addFile(path.join(this._resourcesDir, sha1), path.join('resources', sha1)); - zipFile.end(); - zipFile.outputStream.pipe(fs.createWriteStream(zipFileName)).on('close', () => { - const artifact = new Artifact(this._context, zipFileName); - artifact.reportFinished(); - fulfill(artifact); - }); + const result = save ? this._export(state) : Promise.resolve(null); + return result.finally(async () => { + this._isStopping = false; + state.recording = false; }); - return Promise.race([failedPromise, succeededPromise]); }); } + private async _export(state: RecordingState): Promise { + const zipFile = new yazl.ZipFile(); + const failedPromise = new Promise((_, reject) => (zipFile as any as EventEmitter).on('error', reject)); + const succeededPromise = new Promise(fulfill => { + zipFile.addFile(state.traceFile, 'trace.trace'); + zipFile.addFile(state.networkFile, 'trace.network'); + const zipFileName = state.traceFile + '.zip'; + for (const sha1 of state.sha1s) + zipFile.addFile(path.join(this._resourcesDir, sha1), path.join('resources', sha1)); + zipFile.end(); + zipFile.outputStream.pipe(fs.createWriteStream(zipFileName)).on('close', () => { + const artifact = new Artifact(this._context, zipFileName); + artifact.reportFinished(); + fulfill(artifact); + }); + }); + return Promise.race([failedPromise, succeededPromise]); + } + async _captureSnapshot(name: 'before' | 'after' | 'action' | 'event', sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle) { if (!sdkObject.attribution.page) return; @@ -259,8 +278,8 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate, Ha onEntryFinished(entry: har.Entry) { const event: trace.ResourceSnapshotTraceEvent = { type: 'resource-snapshot', snapshot: entry }; this._appendTraceOperation(async () => { - visitSha1s(event, this._recording!.sha1s); - await fs.promises.appendFile(this._recording!.networkFile, JSON.stringify(event) + '\n'); + visitSha1s(event, this._state!.sha1s); + await fs.promises.appendFile(this._state!.networkFile, JSON.stringify(event) + '\n'); }); } @@ -301,8 +320,8 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate, Ha private _appendTraceEvent(event: trace.TraceEvent) { this._appendTraceOperation(async () => { - visitSha1s(event, this._recording!.sha1s); - await fs.promises.appendFile(this._recording!.traceFile, JSON.stringify(event) + '\n'); + visitSha1s(event, this._state!.sha1s); + await fs.promises.appendFile(this._state!.traceFile, JSON.stringify(event) + '\n'); }); } diff --git a/src/test/index.ts b/src/test/index.ts index a73a062fb2..015efe416e 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -193,10 +193,15 @@ export const test = _baseTest.extend({ const onDidCreateContext = async (context: BrowserContext) => { context.setDefaultTimeout(actionTimeout || 0); context.setDefaultNavigationTimeout(navigationTimeout || actionTimeout || 0); - if (captureTrace) - await context.tracing.start({ screenshots: true, snapshots: true }); - else + if (captureTrace) { + if (!(context.tracing as any)[kTracingStarted]) { + await context.tracing.start({ screenshots: true, snapshots: true }); + (context.tracing as any)[kTracingStarted] = true; + } + await context.tracing.startChunk(); + } else { await context.tracing.stop(); + } (context as any)._csi = { onApiCall: (stackTrace: ParsedStackTrace) => { if ((testInfo as TestInfoImpl)._currentSteps().some(step => step.category === 'pw:api' || step.category === 'expect')) @@ -212,7 +217,7 @@ export const test = _baseTest.extend({ // after the test finishes. const tracePath = path.join(_artifactsDir(), createGuid() + '.zip'); temporaryTraceFiles.push(tracePath); - await (context.tracing as any)._export({ path: tracePath }); + await context.tracing.stopChunk({ path: tracePath }); } if (screenshot === 'on' || screenshot === 'only-on-failure') { // Capture screenshot for now. We'll know whether we have to preserve them @@ -267,7 +272,7 @@ export const test = _baseTest.extend({ // 5. Collect artifacts from any non-closed contexts. await Promise.all(leftoverContexts.map(async context => { if (preserveTrace) - await (context.tracing as any)._export({ path: addTraceAttachment() }); + await context.tracing.stopChunk({ path: addTraceAttachment() }); if (captureScreenshots) await Promise.all(context.pages().map(page => page.screenshot({ timeout: 5000, path: addScreenshotAttachment() }).catch(() => {}))); })); @@ -374,3 +379,5 @@ type ParsedStackTrace = { frameTexts: string[]; apiName: string; }; + +const kTracingStarted = Symbol('kTracingStarted'); diff --git a/src/web/traceViewer/ui/networkResourceDetails.tsx b/src/web/traceViewer/ui/networkResourceDetails.tsx index 7474f44463..bbc24afdcc 100644 --- a/src/web/traceViewer/ui/networkResourceDetails.tsx +++ b/src/web/traceViewer/ui/networkResourceDetails.tsx @@ -38,7 +38,7 @@ export const NetworkResourceDetails: React.FunctionComponent<{ const readResources = async () => { if (resource.request.postData) { if (resource.request.postData._sha1) { - const response = await fetch(`/sha1/${resource.request.postData}`); + const response = await fetch(`/sha1/${resource.request.postData._sha1}`); const requestResource = await response.text(); setRequestBody(requestResource); } else { diff --git a/tests/playwright-test/playwright.artifacts.spec.ts b/tests/playwright-test/playwright.artifacts.spec.ts index ba7c620fe3..c0d67a68d9 100644 --- a/tests/playwright-test/playwright.artifacts.spec.ts +++ b/tests/playwright-test/playwright.artifacts.spec.ts @@ -301,8 +301,8 @@ test('should stop tracing with trace: on-first-retry, when not retrying', async }); test('no tracing', async ({}, testInfo) => { - const error = await page.context().tracing._export({ path: testInfo.outputPath('none.zip') }).catch(e => e); - expect(error.message).toContain('Must start tracing before exporting'); + const e = await page.context().tracing.stop({ path: 'ignored' }).catch(e => e); + expect(e.message).toContain('Must start tracing before stopping'); }); }); `, @@ -314,7 +314,6 @@ test('should stop tracing with trace: on-first-retry, when not retrying', async expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ 'a-shared-flaky-retry1', ' trace.zip', - 'a-shared-no-tracing', // Empty dir created because of testInfo.outputPath() call. 'report.json', ]); }); diff --git a/tests/tracing.spec.ts b/tests/tracing.spec.ts index 8d1c9f555f..bbacba9197 100644 --- a/tests/tracing.spec.ts +++ b/tests/tracing.spec.ts @@ -198,38 +198,34 @@ test('should include interrupted actions', async ({ context, page, server }, tes expect(clickEvent.metadata.error.error.message).toBe('Action was interrupted'); }); -test('should reset to different options', async ({ context, page, server }, testInfo) => { +test('should throw when starting with different options', async ({ context }) => { await context.tracing.start({ screenshots: true, snapshots: true }); - await page.goto(server.PREFIX + '/frames/frame.html'); - await context.tracing.start({ screenshots: false, snapshots: false }); - await page.setContent(''); - await page.click('"Click"'); - await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); - - const { events } = await parseTrace(testInfo.outputPath('trace.zip')); - expect(events[0].type).toBe('context-options'); - expect(events.find(e => e.metadata?.apiName === 'page.goto')).toBeFalsy(); - expect(events.find(e => e.metadata?.apiName === 'page.setContent')).toBeTruthy(); - expect(events.find(e => e.metadata?.apiName === 'page.click')).toBeTruthy(); - - expect(events.some(e => e.type === 'frame-snapshot')).toBeFalsy(); - expect(events.some(e => e.type === 'resource-snapshot')).toBeTruthy(); + const error = await context.tracing.start({ screenshots: false, snapshots: false }).catch(e => e); + expect(error.message).toContain('Tracing has been already started with different options'); }); -test('should reset and export', async ({ context, page, server }, testInfo) => { +test('should throw when stopping without start', async ({ context }, testInfo) => { + const error = await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }).catch(e => e); + expect(error.message).toContain('Must start tracing before stopping'); +}); + +test('should not throw when stopping without start but not exporting', async ({ context }, testInfo) => { + await context.tracing.stop(); +}); + +test('should work with multiple chunks', async ({ context, page, server }, testInfo) => { await context.tracing.start({ screenshots: true, snapshots: true }); await page.goto(server.PREFIX + '/frames/frame.html'); - await context.tracing.start({ screenshots: true, snapshots: true }); + await context.tracing.startChunk(); await page.setContent(''); await page.click('"Click"'); page.click('"ClickNoButton"').catch(() => {}); - // @ts-expect-error - await context.tracing._export({ path: testInfo.outputPath('trace.zip') }); + await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') }); - await context.tracing.start({ screenshots: true, snapshots: true }); + await context.tracing.startChunk(); await page.hover('"Click"'); - await context.tracing.stop({ path: testInfo.outputPath('trace2.zip') }); + await context.tracing.stopChunk({ path: testInfo.outputPath('trace2.zip') }); const trace1 = await parseTrace(testInfo.outputPath('trace.zip')); expect(trace1.events[0].type).toBe('context-options'); @@ -248,6 +244,7 @@ test('should reset and export', async ({ context, page, server }, testInfo) => { expect(trace2.events.find(e => e.metadata?.apiName === 'page.click')).toBeFalsy(); expect(trace2.events.find(e => e.metadata?.apiName === 'page.hover')).toBeTruthy(); expect(trace2.events.some(e => e.type === 'frame-snapshot')).toBeTruthy(); + expect(trace2.events.some(e => e.type === 'resource-snapshot' && e.snapshot.request.url.endsWith('style.css'))).toBeTruthy(); }); test('should export trace concurrently to second navigation', async ({ context, page, server }, testInfo) => { diff --git a/types/types.d.ts b/types/types.d.ts index 3164934478..5b1eacf81e 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -13518,10 +13518,10 @@ export interface Touchscreen { } /** - * API for collecting and saving Playwright traces. Playwright traces can be opened using the Playwright CLI after - * Playwright script runs. + * API for collecting and saving Playwright traces. Playwright traces can be opened in [Trace Viewer](https://playwright.dev/docs/trace-viewer) + * after Playwright script runs. * - * Start with specifying the folder traces will be stored in: + * Start recording a trace before performing actions. At the end, stop tracing and save it to a file. * * ```js * const browser = await chromium.launch(); @@ -13564,13 +13564,52 @@ export interface Tracing { snapshots?: boolean; }): Promise; + /** + * Start a new trace chunk. If you'd like to record multiple traces on the same [BrowserContext], use + * [tracing.start([options])](https://playwright.dev/docs/api/class-tracing#tracing-start) once, and then create multiple + * trace chunks with [tracing.startChunk()](https://playwright.dev/docs/api/class-tracing#tracing-start-chunk) and + * [tracing.stopChunk([options])](https://playwright.dev/docs/api/class-tracing#tracing-stop-chunk). + * + * ```js + * await context.tracing.start({ screenshots: true, snapshots: true }); + * const page = await context.newPage(); + * await page.goto('https://playwright.dev'); + * + * await context.tracing.startChunk(); + * await page.click('text=Get Started'); + * // Everything between startChunk and stopChunk will be recorded in the trace. + * await context.tracing.stopChunk({ path: 'trace1.zip' }); + * + * await context.tracing.startChunk(); + * await page.goto('http://example.com'); + * // Save a second trace file with different actions. + * await context.tracing.stopChunk({ path: 'trace2.zip' }); + * ``` + * + */ + startChunk(): Promise; + /** * Stop tracing. * @param options */ stop(options?: { /** - * Export trace into the file with the given name. + * Export trace into the file with the given path. + */ + path?: string; + }): Promise; + + /** + * Stop the trace chunk. See [tracing.startChunk()](https://playwright.dev/docs/api/class-tracing#tracing-start-chunk) for + * more details about multiple trace chunks. + * @param options + */ + stopChunk(options?: { + /** + * Export trace collected since the last + * [tracing.startChunk()](https://playwright.dev/docs/api/class-tracing#tracing-start-chunk) call into the file with the + * given path. */ path?: string; }): Promise;