diff --git a/src/server/trace/common/traceEvents.ts b/src/server/trace/common/traceEvents.ts index a4fe47a90f..8bb3a21365 100644 --- a/src/server/trace/common/traceEvents.ts +++ b/src/server/trace/common/traceEvents.ts @@ -50,15 +50,9 @@ export type FrameSnapshotTraceEvent = { snapshot: FrameSnapshot, }; -export type MarkerTraceEvent = { - type: 'marker', - resetIndex?: number, -}; - export type TraceEvent = ContextCreatedTraceEvent | ScreencastFrameTraceEvent | ActionTraceEvent | ResourceSnapshotTraceEvent | - FrameSnapshotTraceEvent | - MarkerTraceEvent; + FrameSnapshotTraceEvent; diff --git a/src/server/trace/recorder/tracing.ts b/src/server/trace/recorder/tracing.ts index 9dfaabc3c2..14e39314bd 100644 --- a/src/server/trace/recorder/tracing.ts +++ b/src/server/trace/recorder/tracing.ts @@ -17,7 +17,6 @@ import fs from 'fs'; import path from 'path'; import yazl from 'yazl'; -import readline from 'readline'; import { EventEmitter } from 'events'; import { createGuid, mkdirIfNeeded, monotonicTime } from '../../../utils/utils'; import { Artifact } from '../../artifact'; @@ -41,6 +40,8 @@ export const VERSION = 2; type RecordingState = { options: TracerOptions, + traceName: string, + networkFile: string, traceFile: string, lastReset: number, sha1s: Set, @@ -59,12 +60,19 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate { private _isStopping = false; private _tracesDir: string; private _allResources = new Set(); + private _contextCreatedEvent: trace.ContextCreatedTraceEvent; constructor(context: BrowserContext) { this._context = context; this._tracesDir = context._browser.options.tracesDir; this._resourcesDir = path.join(this._tracesDir, 'resources'); this._snapshotter = new Snapshotter(context, this); + this._contextCreatedEvent = { + version: VERSION, + type: 'context-options', + browserName: this._context._browser.options.name, + options: this._context._options + }; } async start(options: TracerOptions): Promise { @@ -76,16 +84,12 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate { if (!state) { // TODO: passing the same name for two contexts makes them write into a single file // and conflict. - const traceFile = path.join(this._tracesDir, (options.name || createGuid()) + '.trace'); - this._recording = { options, traceFile, lastReset: 0, sha1s: new Set() }; - this._writeChain = mkdirIfNeeded(traceFile); - const event: trace.ContextCreatedTraceEvent = { - version: VERSION, - type: 'context-options', - browserName: this._context._browser.options.name, - options: this._context._options - }; - this._appendTraceEvent(event); + 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 (!state?.options?.screenshots && options.screenshots) @@ -111,8 +115,8 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate { if (state) { state.lastReset++; - const markerEvent: trace.MarkerTraceEvent = { type: 'marker', resetIndex: state.lastReset }; - await fs.promises.appendFile(state.traceFile, JSON.stringify(markerEvent) + '\n'); + state.traceFile = path.join(this._tracesDir, `${state.traceName}-${state.lastReset}.trace`); + await fs.promises.appendFile(state.traceFile, JSON.stringify(this._contextCreatedEvent) + '\n'); } }); @@ -170,18 +174,14 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate { 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. + // so that neither trace files nor sha1s change during the export. return await this._appendTraceOperation(async () => { - const recording = this._recording!; - let state = recording; - // Make a filtered trace if needed. - if (recording.lastReset) - state = await this._filterTrace(recording, recording.lastReset); - + 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(async 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)); @@ -193,55 +193,10 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate { artifact.reportFinished(); fulfill(artifact); }); - return Promise.race([failedPromise, succeededPromise]).finally(async () => { - // Remove the filtered trace. - if (recording.lastReset) - await fs.promises.unlink(state.traceFile).catch(() => {}); - }); + return Promise.race([failedPromise, succeededPromise]); }); } - private async _filterTrace(state: RecordingState, sinceResetIndex: number): Promise { - const ext = path.extname(state.traceFile); - const traceFileCopy = state.traceFile.substring(0, state.traceFile.length - ext.length) + '-copy' + sinceResetIndex + ext; - const sha1s = new Set(); - await new Promise((resolve, reject) => { - const fileStream = fs.createReadStream(state.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') { - if (event.resetIndex === sinceResetIndex) - foundMarker = true; - } else if ((event.type === 'resource-snapshot' && state.options.snapshots) || event.type === 'context-options' || foundMarker) { - // We keep: - // - old resource events for snapshots; - // - initial context options event; - // - all events after the marker that are not markers. - 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 { options: state.options, lastReset: state.lastReset, sha1s, traceFile: traceFileCopy }; - } - async _captureSnapshot(name: 'before' | 'after' | 'action' | 'event', sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle) { if (!sdkObject.attribution.page) return; @@ -293,7 +248,11 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate { } onResourceSnapshot(snapshot: ResourceSnapshot): void { - this._appendTraceEvent({ type: 'resource-snapshot', snapshot }); + const event: trace.ResourceSnapshotTraceEvent = { type: 'resource-snapshot', snapshot }; + this._appendTraceOperation(async () => { + visitSha1s(event, this._recording!.sha1s); + await fs.promises.appendFile(this._recording!.networkFile, JSON.stringify(event) + '\n'); + }); } onFrameSnapshot(snapshot: FrameSnapshot): void { @@ -324,7 +283,6 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate { } private _appendTraceEvent(event: trace.TraceEvent) { - // Serialize all writes to the trace file. this._appendTraceOperation(async () => { visitSha1s(event, this._recording!.sha1s); await fs.promises.appendFile(this._recording!.traceFile, JSON.stringify(event) + '\n'); @@ -349,6 +307,7 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate { } private async _appendTraceOperation(cb: () => Promise): Promise { + // This method serializes all writes to the trace. let error: Error | undefined; let result: T | undefined; this._writeChain = this._writeChain.then(async () => { diff --git a/src/server/trace/viewer/traceViewer.ts b/src/server/trace/viewer/traceViewer.ts index ae4751e089..bcaf342d6c 100644 --- a/src/server/trace/viewer/traceViewer.ts +++ b/src/server/trace/viewer/traceViewer.ts @@ -74,19 +74,16 @@ export class TraceViewer { const traceModelHandler: ServerRouteHandler = (request, response) => { const debugName = request.url!.substring('/context/'.length); - const tracePrefix = path.join(tracesDir, debugName); snapshotStorage.clear(); response.statusCode = 200; response.setHeader('Content-Type', 'application/json'); (async () => { - const fileStream = fs.createReadStream(tracePrefix + '.trace', 'utf8'); - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity - }); + const traceFile = path.join(tracesDir, debugName + '.trace'); + const match = debugName.match(/^(.*)-\d+$/); + const networkFile = path.join(tracesDir, (match ? match[1] : debugName) + '.network'); const model = new TraceModel(snapshotStorage); - for await (const line of rl as any) - model.appendEvent(line); + await appendTraceEvents(model, traceFile); + await appendTraceEvents(model, networkFile); model.build(); response.end(JSON.stringify(model.contextEntry)); })().catch(e => console.error(e)); @@ -187,6 +184,16 @@ Please run 'npx playwright install' to install Playwright browsers } } +async function appendTraceEvents(model: TraceModel, file: string) { + const fileStream = fs.createReadStream(file, 'utf8'); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity + }); + for await (const line of rl as any) + model.appendEvent(line); +} + export async function showTraceViewer(tracePath: string, browserName: string, headless = false): Promise { let stat; try { diff --git a/tests/tracing.spec.ts b/tests/tracing.spec.ts index fb87c1eeda..34f2a15736 100644 --- a/tests/tracing.spec.ts +++ b/tests/tracing.spec.ts @@ -213,7 +213,7 @@ test('should reset to different options', async ({ context, page, server }, test 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')).toBeFalsy(); + expect(events.some(e => e.type === 'resource-snapshot')).toBeTruthy(); }); test('should reset and export', async ({ context, page, server }, testInfo) => { @@ -300,7 +300,15 @@ async function parseTrace(file: string): Promise<{ events: any[], resources: Map const resources = new Map(); for (const { name, buffer } of await Promise.all(entries)) resources.set(name, buffer); - const events = resources.get('trace.trace').toString().split('\n').map(line => line ? JSON.parse(line) : false).filter(Boolean); + const events = []; + for (const line of resources.get('trace.trace').toString().split('\n')) { + if (line) + events.push(JSON.parse(line)); + } + for (const line of resources.get('trace.network').toString().split('\n')) { + if (line) + events.push(JSON.parse(line)); + } return { events, resources,