diff --git a/src/client/tracing.ts b/src/client/tracing.ts index e0e44a35c1..bf3546bb9c 100644 --- a/src/client/tracing.ts +++ b/src/client/tracing.ts @@ -32,17 +32,32 @@ export class Tracing implements api.Tracing { }); } + async _reset() { + await this._context._wrapApiCall(async (channel: channels.BrowserContextChannel) => { + return await channel.tracingReset(); + }); + } + + async _export(options: { path: string }) { + await this._context._wrapApiCall(async (channel: channels.BrowserContextChannel) => { + await this._doExport(channel, options.path); + }); + } + async stop(options: { path?: string } = {}) { await this._context._wrapApiCall(async (channel: channels.BrowserContextChannel) => { await channel.tracingStop(); - if (options.path) { - const result = await channel.tracingExport(); - const artifact = Artifact.from(result.artifact); - if (this._context.browser()?._remoteType) - artifact._isRemote = true; - await artifact.saveAs(options.path); - await artifact.delete(); - } + if (options.path) + await this._doExport(channel, options.path); }); } + + private async _doExport(channel: channels.BrowserContextChannel, path: string) { + const result = await channel.tracingExport(); + const artifact = Artifact.from(result.artifact); + if (this._context.browser()?._remoteType) + artifact._isRemote = true; + await artifact.saveAs(path); + await artifact.delete(); + } } diff --git a/src/dispatchers/browserContextDispatcher.ts b/src/dispatchers/browserContextDispatcher.ts index 535e15c6c4..74e2b32fee 100644 --- a/src/dispatchers/browserContextDispatcher.ts +++ b/src/dispatchers/browserContextDispatcher.ts @@ -184,6 +184,10 @@ export class BrowserContextDispatcher extends Dispatcher { + await this._context.tracing.reset(); + } + async tracingStop(params: channels.BrowserContextTracingStopParams): Promise { await this._context.tracing.stop(); } diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 4af0bbb277..c756d85697 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -656,6 +656,7 @@ export interface BrowserContextChannel extends EventTargetChannel { recorderSupplementEnable(params: BrowserContextRecorderSupplementEnableParams, metadata?: Metadata): Promise; newCDPSession(params: BrowserContextNewCDPSessionParams, metadata?: Metadata): Promise; tracingStart(params: BrowserContextTracingStartParams, metadata?: Metadata): Promise; + tracingReset(params?: BrowserContextTracingResetParams, metadata?: Metadata): Promise; tracingStop(params?: BrowserContextTracingStopParams, metadata?: Metadata): Promise; tracingExport(params?: BrowserContextTracingExportParams, metadata?: Metadata): Promise; } @@ -864,6 +865,9 @@ export type BrowserContextTracingStartOptions = { screenshots?: boolean, }; export type BrowserContextTracingStartResult = void; +export type BrowserContextTracingResetParams = {}; +export type BrowserContextTracingResetOptions = {}; +export type BrowserContextTracingResetResult = void; export type BrowserContextTracingStopParams = {}; export type BrowserContextTracingStopOptions = {}; export type BrowserContextTracingStopResult = void; diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index d84a70c815..72fc5f1f62 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -639,6 +639,8 @@ BrowserContext: snapshots: boolean? screenshots: boolean? + tracingReset: + tracingStop: tracingExport: diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index 941e5da705..df92f6a6c0 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -413,6 +413,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { snapshots: tOptional(tBoolean), screenshots: tOptional(tBoolean), }); + scheme.BrowserContextTracingResetParams = tOptional(tObject({})); scheme.BrowserContextTracingStopParams = tOptional(tObject({})); scheme.BrowserContextTracingExportParams = tOptional(tObject({})); scheme.PageSetDefaultNavigationTimeoutNoReplyParams = tObject({ diff --git a/src/server/snapshot/snapshotter.ts b/src/server/snapshot/snapshotter.ts index ad05d36692..f8cf5428de 100644 --- a/src/server/snapshot/snapshotter.ts +++ b/src/server/snapshot/snapshotter.ts @@ -62,7 +62,7 @@ export class Snapshotter { this._initialized = true; await this._initialize(); } - await this._runInAllFrames(`window["${this._snapshotStreamer}"].reset()`); + await this.reset(); // Replay resources loaded in all pages. for (const page of this._context.pages()) { @@ -71,6 +71,11 @@ export class Snapshotter { } } + async reset() { + if (this._started) + await this._runInAllFrames(`window["${this._snapshotStreamer}"].reset()`); + } + async stop() { this._started = false; } diff --git a/src/server/trace/common/traceEvents.ts b/src/server/trace/common/traceEvents.ts index 8bb3a21365..a4fe47a90f 100644 --- a/src/server/trace/common/traceEvents.ts +++ b/src/server/trace/common/traceEvents.ts @@ -50,9 +50,15 @@ export type FrameSnapshotTraceEvent = { snapshot: FrameSnapshot, }; +export type MarkerTraceEvent = { + type: 'marker', + resetIndex?: number, +}; + export type TraceEvent = ContextCreatedTraceEvent | ScreencastFrameTraceEvent | ActionTraceEvent | ResourceSnapshotTraceEvent | - FrameSnapshotTraceEvent; + FrameSnapshotTraceEvent | + MarkerTraceEvent; diff --git a/src/server/trace/recorder/traceSnapshotter.ts b/src/server/trace/recorder/traceSnapshotter.ts index 0088d16d62..86efc3c6c0 100644 --- a/src/server/trace/recorder/traceSnapshotter.ts +++ b/src/server/trace/recorder/traceSnapshotter.ts @@ -46,6 +46,14 @@ export class TraceSnapshotter extends EventEmitter implements SnapshotterDelegat await this._snapshotter.start(); } + async reset() { + await this._snapshotter.reset(); + } + + async checkpoint() { + await this._writeArtifactChain; + } + async stop(): Promise { await this._snapshotter.stop(); await this._writeArtifactChain; diff --git a/src/server/trace/recorder/tracing.ts b/src/server/trace/recorder/tracing.ts index 092ecddeb3..6e168a0fc3 100644 --- a/src/server/trace/recorder/tracing.ts +++ b/src/server/trace/recorder/tracing.ts @@ -17,6 +17,7 @@ import fs from 'fs'; import path from 'path'; import yazl from 'yazl'; +import readline from 'readline'; import { EventEmitter } from 'events'; import { calculateSha1, createGuid, mkdirIfNeeded, monotonicTime } from '../../../utils/utils'; import { Artifact } from '../../artifact'; @@ -45,9 +46,10 @@ export class Tracing implements InstrumentationListener { private _context: BrowserContext; private _traceFile: string | undefined; private _resourcesDir: string; - private _sha1s: string[] = []; + private _sha1s = new Set(); private _recordingTraceEvents = false; private _tracesDir: string; + private _lastReset = 0; constructor(context: BrowserContext) { this._context = context; @@ -64,6 +66,7 @@ export class Tracing implements InstrumentationListener { // TODO: passing the same name for two contexts makes them write into a single file // and conflict. this._traceFile = path.join(this._tracesDir, (options.name || createGuid()) + '.trace'); + this._lastReset = 0; this._appendEventChain = mkdirIfNeeded(this._traceFile); const event: trace.ContextCreatedTraceEvent = { @@ -87,6 +90,16 @@ export class Tracing implements InstrumentationListener { await this._snapshotter.start(); } + async reset(): Promise { + await this._appendTraceOperation(async () => { + // Reset snapshots to avoid back-references. + await this._snapshotter.reset(); + this._lastReset++; + const markerEvent: trace.MarkerTraceEvent = { type: 'marker', resetIndex: this._lastReset }; + await fs.promises.appendFile(this._traceFile!, JSON.stringify(markerEvent) + '\n'); + }); + } + async stop(): Promise { if (!this._eventListeners.length) return; @@ -112,25 +125,77 @@ export class Tracing implements InstrumentationListener { } async export(): Promise { - if (!this._traceFile || this._recordingTraceEvents) - throw new Error('Must start and stop tracing before exporting'); - const zipFile = new yazl.ZipFile(); - const failedPromise = new Promise((_, reject) => (zipFile as any as EventEmitter).on('error', reject)); + if (!this._traceFile) + throw new Error('Must start tracing before exporting'); + // Chain the export operation against write operations, + // so that neither trace file nor sha1s change during the export. + return await this._appendTraceOperation(async () => { + await this._snapshotter.checkpoint(); - const succeededPromise = new Promise(async fulfill => { - zipFile.addFile(this._traceFile!, 'trace.trace'); - const zipFileName = this._traceFile! + '.zip'; - for (const sha1 of this._sha1s) - zipFile.addFile(path.join(this._resourcesDir!, sha1), path.join('resources', sha1)); - zipFile.end(); - await new Promise(f => { - zipFile.outputStream.pipe(fs.createWriteStream(zipFileName)).on('close', f); + const resetIndex = this._lastReset; + let trace = { file: this._traceFile!, sha1s: this._sha1s }; + // Make a filtered trace if needed. + if (resetIndex) + trace = await this._filterTrace(this._traceFile!, resetIndex); + + const zipFile = new yazl.ZipFile(); + const failedPromise = new Promise((_, reject) => (zipFile as any as EventEmitter).on('error', reject)); + const succeededPromise = new Promise(async fulfill => { + zipFile.addFile(trace.file, 'trace.trace'); + const zipFileName = trace.file + '.zip'; + for (const sha1 of trace.sha1s) + zipFile.addFile(path.join(this._resourcesDir!, sha1), path.join('resources', sha1)); + zipFile.end(); + await new Promise(f => { + zipFile.outputStream.pipe(fs.createWriteStream(zipFileName)).on('close', f); + }); + const artifact = new Artifact(this._context, zipFileName); + artifact.reportFinished(); + fulfill(artifact); + }); + return Promise.race([failedPromise, succeededPromise]).finally(async () => { + // Remove the filtered trace. + if (resetIndex) + await fs.promises.unlink(trace.file).catch(() => {}); }); - const artifact = new Artifact(this._context, zipFileName); - artifact.reportFinished(); - fulfill(artifact); }); - return Promise.race([failedPromise, succeededPromise]); + } + + private async _filterTrace(traceFile: string, resetIndex: number): Promise<{ file: string, sha1s: Set }> { + const ext = path.extname(traceFile); + const traceFileCopy = traceFile.substring(0, traceFile.length - ext.length) + '-copy' + resetIndex + ext; + const sha1s = new Set(); + await new Promise((resolve, reject) => { + const fileStream = fs.createReadStream(traceFile, 'utf8'); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity + }); + let copyChain = Promise.resolve(); + let foundMarker = false; + rl.on('line', line => { + try { + const event = JSON.parse(line) as trace.TraceEvent; + if (event.type === 'marker' && event.resetIndex === resetIndex) { + foundMarker = true; + } else if (event.type === 'resource-snapshot' || event.type === 'context-options' || foundMarker) { + // We keep all resources for snapshots, context options and all events after the marker. + visitSha1s(event, sha1s); + copyChain = copyChain.then(() => fs.promises.appendFile(traceFileCopy, line + '\n')); + } + } catch (e) { + reject(e); + fileStream.close(); + rl.close(); + } + }); + rl.on('error', reject); + rl.on('close', async () => { + await copyChain; + resolve(); + }); + }); + return { file: traceFileCopy, sha1s }; } async _captureSnapshot(name: 'before' | 'after' | 'action' | 'event', sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle) { @@ -194,10 +259,11 @@ export class Tracing implements InstrumentationListener { height: params.height, timestamp: monotonicTime() }; - this._appendTraceEvent(event); - this._appendEventChain = this._appendEventChain.then(async () => { + // Make sure to write the screencast frame before adding a reference to it. + this._appendTraceOperation(async () => { await fs.promises.writeFile(path.join(this._resourcesDir!, sha1), params.buffer).catch(() => {}); }); + this._appendTraceEvent(event); }), ); } @@ -205,31 +271,46 @@ export class Tracing implements InstrumentationListener { private _appendTraceEvent(event: any) { if (!this._recordingTraceEvents) return; - - const visit = (object: any) => { - if (Array.isArray(object)) { - object.forEach(visit); - return; - } - if (typeof object === 'object') { - for (const key in object) { - if (key === 'sha1' || key.endsWith('Sha1')) { - const sha1 = object[key]; - if (sha1) - this._sha1s.push(sha1); - } - visit(object[key]); - } - return; - } - }; - visit(event); - // Serialize all writes to the trace file. - this._appendEventChain = this._appendEventChain.then(async () => { + this._appendTraceOperation(async () => { + visitSha1s(event, this._sha1s); await fs.promises.appendFile(this._traceFile!, JSON.stringify(event) + '\n'); }); } + + private async _appendTraceOperation(cb: () => Promise): Promise { + let error: Error | undefined; + let result: T | undefined; + this._appendEventChain = this._appendEventChain.then(async () => { + try { + result = await cb(); + } catch (e) { + error = e; + } + }); + await this._appendEventChain; + if (error) + throw error; + return result!; + } +} + +function visitSha1s(object: any, sha1s: Set) { + if (Array.isArray(object)) { + object.forEach(o => visitSha1s(o, sha1s)); + return; + } + if (typeof object === 'object') { + for (const key in object) { + if (key === 'sha1' || key.endsWith('Sha1')) { + const sha1 = object[key]; + if (sha1) + sha1s.add(sha1); + } + visitSha1s(object[key], sha1s); + } + return; + } } export function shouldCaptureSnapshot(metadata: CallMetadata): boolean { diff --git a/tests/tracing.spec.ts b/tests/tracing.spec.ts index ff97f1c7b8..a4ea51acf7 100644 --- a/tests/tracing.spec.ts +++ b/tests/tracing.spec.ts @@ -196,6 +196,27 @@ test('should include interrupted actions', async ({ context, page, server }, tes expect(clickEvent.metadata.error.error.message).toBe('Action was interrupted'); }); +test('should reset and export', async ({ context, page, server }, testInfo) => { + await context.tracing.start({ screenshots: true, snapshots: true }); + await page.goto(server.PREFIX + '/frames/frame.html'); + // @ts-expect-error + await context.tracing._reset(); + await page.setContent(''); + await page.click('"Click"'); + // @ts-expect-error + await context.tracing._export({ path: testInfo.outputPath('trace.zip') }); + await context.tracing.stop(); + + 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')).toBeTruthy(); + expect(events.some(e => e.type === 'resource-snapshot' && e.snapshot.url.endsWith('style.css'))).toBeTruthy(); +}); + async function parseTrace(file: string): Promise<{ events: any[], resources: Map }> { const entries = await new Promise(f => {