diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 0b37a54309..1c04d7eb71 100755 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -235,9 +235,6 @@ async function launchContext(options: Options, headless: boolean): Promise<{ bro if (contextOptions.isMobile && browserType.name() === 'firefox') contextOptions.isMobile = undefined; - if (process.env.PWTRACE) - (contextOptions as any)._traceDir = path.join(process.cwd(), '.trace'); - // Proxy if (options.proxyServer) { @@ -365,8 +362,6 @@ async function open(options: Options, url: string | undefined, language: string) async function codegen(options: Options, url: string | undefined, language: string, outputFile?: string) { const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWTEST_CLI_HEADLESS); - if (process.env.PWTRACE) - contextOptions._traceDir = path.join(process.cwd(), '.trace'); await context._enableRecorder({ language, launchOptions, diff --git a/src/client/browser.ts b/src/client/browser.ts index b76d17c814..9367b0baf5 100644 --- a/src/client/browser.ts +++ b/src/client/browser.ts @@ -48,8 +48,6 @@ export class Browser extends ChannelOwner { return this._wrapApiCall('browser.newContext', async (channel: channels.BrowserChannel) => { - if (this._isRemote && options._traceDir) - throw new Error(`"_traceDir" is not supported in connected browser`); const contextOptions = await prepareBrowserContextParams(options); const context = BrowserContext.from((await channel.newContext(contextOptions)).context); context._options = contextOptions; diff --git a/src/client/browserContext.ts b/src/client/browserContext.ts index b62792f230..fee2fb9aac 100644 --- a/src/client/browserContext.ts +++ b/src/client/browserContext.ts @@ -279,6 +279,18 @@ export class BrowserContext extends ChannelOwner { + await channel.startTracing(); + }); + } + + async _stopTracing() { + return await this._wrapApiCall('browserContext.stopTracing', async (channel: channels.BrowserContextChannel) => { + await channel.stopTracing(); + }); + } + async close(): Promise { try { await this._wrapApiCall('browserContext.close', async (channel: channels.BrowserContextChannel) => { diff --git a/src/dispatchers/browserContextDispatcher.ts b/src/dispatchers/browserContextDispatcher.ts index 8e05f74631..116a439744 100644 --- a/src/dispatchers/browserContextDispatcher.ts +++ b/src/dispatchers/browserContextDispatcher.ts @@ -157,4 +157,12 @@ export class BrowserContextDispatcher extends Dispatcher { + await this._context.startTracing(); + } + + async stopTracing(): Promise { + await this._context.stopTracing(); + } } diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 46560f3024..57638af175 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -226,6 +226,7 @@ export type BrowserTypeLaunchParams = { password?: string, }, downloadsPath?: string, + _traceDir?: string, chromiumSandbox?: boolean, firefoxUserPrefs?: any, slowMo?: number, @@ -250,6 +251,7 @@ export type BrowserTypeLaunchOptions = { password?: string, }, downloadsPath?: string, + _traceDir?: string, chromiumSandbox?: boolean, firefoxUserPrefs?: any, slowMo?: number, @@ -277,6 +279,7 @@ export type BrowserTypeLaunchPersistentContextParams = { password?: string, }, downloadsPath?: string, + _traceDir?: string, chromiumSandbox?: boolean, sdkLanguage: string, noDefaultViewport?: boolean, @@ -311,7 +314,6 @@ export type BrowserTypeLaunchPersistentContextParams = { hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', acceptDownloads?: boolean, - _traceDir?: string, _debugName?: string, recordVideo?: { dir: string, @@ -347,6 +349,7 @@ export type BrowserTypeLaunchPersistentContextOptions = { password?: string, }, downloadsPath?: string, + _traceDir?: string, chromiumSandbox?: boolean, noDefaultViewport?: boolean, viewport?: { @@ -380,7 +383,6 @@ export type BrowserTypeLaunchPersistentContextOptions = { hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', acceptDownloads?: boolean, - _traceDir?: string, _debugName?: string, recordVideo?: { dir: string, @@ -470,7 +472,6 @@ export type BrowserNewContextParams = { hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', acceptDownloads?: boolean, - _traceDir?: string, _debugName?: string, recordVideo?: { dir: string, @@ -527,7 +528,6 @@ export type BrowserNewContextOptions = { hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', acceptDownloads?: boolean, - _traceDir?: string, _debugName?: string, recordVideo?: { dir: string, @@ -610,6 +610,8 @@ export interface BrowserContextChannel extends Channel { pause(params?: BrowserContextPauseParams, metadata?: Metadata): Promise; recorderSupplementEnable(params: BrowserContextRecorderSupplementEnableParams, metadata?: Metadata): Promise; newCDPSession(params: BrowserContextNewCDPSessionParams, metadata?: Metadata): Promise; + startTracing(params?: BrowserContextStartTracingParams, metadata?: Metadata): Promise; + stopTracing(params?: BrowserContextStopTracingParams, metadata?: Metadata): Promise; } export type BrowserContextBindingCallEvent = { binding: BindingCallChannel, @@ -786,6 +788,12 @@ export type BrowserContextNewCDPSessionOptions = { export type BrowserContextNewCDPSessionResult = { session: CDPSessionChannel, }; +export type BrowserContextStartTracingParams = {}; +export type BrowserContextStartTracingOptions = {}; +export type BrowserContextStartTracingResult = void; +export type BrowserContextStopTracingParams = {}; +export type BrowserContextStopTracingOptions = {}; +export type BrowserContextStopTracingResult = void; // ----------- Page ----------- export type PageInitializer = { @@ -2850,7 +2858,6 @@ export type AndroidDeviceLaunchBrowserParams = { hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', acceptDownloads?: boolean, - _traceDir?: string, _debugName?: string, recordVideo?: { dir: string, @@ -2895,7 +2902,6 @@ export type AndroidDeviceLaunchBrowserOptions = { hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', acceptDownloads?: boolean, - _traceDir?: string, _debugName?: string, recordVideo?: { dir: string, diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index ef4f6eb021..d2bdc7b2ea 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -260,6 +260,7 @@ LaunchOptions: username: string? password: string? downloadsPath: string? + _traceDir: string? chromiumSandbox: boolean? @@ -312,7 +313,6 @@ ContextOptions: - light - no-preference acceptDownloads: boolean? - _traceDir: string? _debugName: string? recordVideo: type: object? @@ -601,6 +601,10 @@ BrowserContext: returns: session: CDPSession + startTracing: + + stopTracing: + events: bindingCall: @@ -2321,7 +2325,6 @@ AndroidDevice: - light - no-preference acceptDownloads: boolean? - _traceDir: string? _debugName: string? recordVideo: type: object? diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index 47b521f850..1712dde503 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -172,6 +172,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { password: tOptional(tString), })), downloadsPath: tOptional(tString), + _traceDir: tOptional(tString), chromiumSandbox: tOptional(tBoolean), firefoxUserPrefs: tOptional(tAny), slowMo: tOptional(tNumber), @@ -196,6 +197,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { password: tOptional(tString), })), downloadsPath: tOptional(tString), + _traceDir: tOptional(tString), chromiumSandbox: tOptional(tBoolean), sdkLanguage: tString, noDefaultViewport: tOptional(tBoolean), @@ -230,7 +232,6 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { hasTouch: tOptional(tBoolean), colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference'])), acceptDownloads: tOptional(tBoolean), - _traceDir: tOptional(tString), _debugName: tOptional(tString), recordVideo: tOptional(tObject({ dir: tString, @@ -289,7 +290,6 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { hasTouch: tOptional(tBoolean), colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference'])), acceptDownloads: tOptional(tBoolean), - _traceDir: tOptional(tString), _debugName: tOptional(tString), recordVideo: tOptional(tObject({ dir: tString, @@ -385,6 +385,8 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { scheme.BrowserContextNewCDPSessionParams = tObject({ page: tChannel('Page'), }); + scheme.BrowserContextStartTracingParams = tOptional(tObject({})); + scheme.BrowserContextStopTracingParams = tOptional(tObject({})); scheme.PageSetDefaultNavigationTimeoutNoReplyParams = tObject({ timeout: tNumber, }); @@ -1091,7 +1093,6 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { hasTouch: tOptional(tBoolean), colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference'])), acceptDownloads: tOptional(tBoolean), - _traceDir: tOptional(tString), _debugName: tOptional(tString), recordVideo: tOptional(tObject({ dir: tString, diff --git a/src/server/browser.ts b/src/server/browser.ts index b07bd3c372..e0a991b5ee 100644 --- a/src/server/browser.ts +++ b/src/server/browser.ts @@ -43,6 +43,7 @@ export type BrowserOptions = PlaywrightOptions & { isChromium: boolean, channel?: types.BrowserChannel, downloadsPath?: string, + traceDir?: string, headful?: boolean, persistent?: types.BrowserContextOptions, // Undefined means no persistent context. browserProcess: BrowserProcess, diff --git a/src/server/browserContext.ts b/src/server/browserContext.ts index dc222aaa4c..33f525c704 100644 --- a/src/server/browserContext.ts +++ b/src/server/browserContext.ts @@ -32,6 +32,7 @@ import { Debugger } from './supplements/debugger'; import { Tracer } from './trace/recorder/tracer'; import { HarTracer } from './supplements/har/harTracer'; import { RecorderSupplement } from './supplements/recorderSupplement'; +import * as consoleApiSource from '../generated/consoleApiSource'; export abstract class BrowserContext extends SdkObject { static Events = { @@ -56,6 +57,7 @@ export abstract class BrowserContext extends SdkObject { private _selectors?: Selectors; private _origins = new Set(); private _harTracer: HarTracer | undefined; + private _tracer: Tracer | null = null; constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) { super(browser, 'browser-context'); @@ -88,10 +90,6 @@ export abstract class BrowserContext extends SdkObject { const contextDebugger = new Debugger(this); this.instrumentation.addListener(contextDebugger); - if (this._options._traceDir) - this.instrumentation.addListener(new Tracer(this, this._options._traceDir)); - - // When PWDEBUG=1, show inspector for each context. if (debugMode() === 'inspector') await RecorderSupplement.show(this, { pauseOnNextStatement: true }); @@ -103,7 +101,8 @@ export abstract class BrowserContext extends SdkObject { RecorderSupplement.showInspector(this); }); - await this.instrumentation.onContextCreated(); + if (debugMode() === 'console') + await this.extendInjectedScript(consoleApiSource.source); } async _ensureVideosPath() { @@ -264,6 +263,7 @@ export abstract class BrowserContext extends SdkObject { this._closedStatus = 'closing'; await this._harTracer?.flush(); + await this._tracer?.stop(); // Cleanup. const promises: Promise[] = []; @@ -292,7 +292,6 @@ export abstract class BrowserContext extends SdkObject { await this._browser.close(); // Bookkeeping. - await this.instrumentation.onContextDestroyed(); this._didCloseInternal(); } await this._closePromise; @@ -371,6 +370,21 @@ export abstract class BrowserContext extends SdkObject { this.on(BrowserContext.Events.Page, installInPage); return Promise.all(this.pages().map(installInPage)); } + + async startTracing() { + if (this._tracer) + throw new Error('Tracing has already been started'); + const traceDir = this._browser.options.traceDir; + if (!traceDir) + throw new Error('Tracing directory is not specified when launching the browser'); + this._tracer = new Tracer(this, traceDir); + await this._tracer.start(); + } + + async stopTracing() { + await this._tracer?.stop(); + this._tracer = null; + } } export function assertBrowserContextIsNotOwned(context: BrowserContext) { diff --git a/src/server/browserType.ts b/src/server/browserType.ts index 1e18336aff..818339aa0f 100644 --- a/src/server/browserType.ts +++ b/src/server/browserType.ts @@ -115,6 +115,7 @@ export abstract class BrowserType extends SdkObject { protocolLogger, browserLogsCollector, wsEndpoint: options.useWebSocket ? (transport as WebSocketTransport).wsEndpoint : undefined, + traceDir: options._traceDir, }; if (persistent) validateBrowserContextOptions(persistent, browserOptions); diff --git a/src/server/instrumentation.ts b/src/server/instrumentation.ts index d1c56f9e05..63c77f9670 100644 --- a/src/server/instrumentation.ts +++ b/src/server/instrumentation.ts @@ -69,8 +69,7 @@ export class SdkObject extends EventEmitter { export interface Instrumentation { addListener(listener: InstrumentationListener): void; - onContextCreated(): Promise; - onContextDestroyed(): Promise; + removeListener(listener: InstrumentationListener): void; onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise; onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise; onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): void; @@ -79,8 +78,6 @@ export interface Instrumentation { } export interface InstrumentationListener { - onContextCreated?(): Promise; - onContextDestroyed?(): Promise; onBeforeCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise; onBeforeInputAction?(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise; onCallLog?(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): void; @@ -94,6 +91,8 @@ export function createInstrumentation(): Instrumentation { get: (obj: any, prop: string) => { if (prop === 'addListener') return (listener: InstrumentationListener) => listeners.push(listener); + if (prop === 'removeListener') + return (listener: InstrumentationListener) => listeners.splice(listeners.indexOf(listener), 1); if (!prop.startsWith('on')) return obj[prop]; return async (...params: any[]) => { diff --git a/src/server/snapshot/snapshotStorage.ts b/src/server/snapshot/snapshotStorage.ts index 66523342a3..37dc75487a 100644 --- a/src/server/snapshot/snapshotStorage.ts +++ b/src/server/snapshot/snapshotStorage.ts @@ -15,9 +15,6 @@ */ import { EventEmitter } from 'events'; -import fs from 'fs'; -import path from 'path'; -import util from 'util'; import { ContextResources, FrameSnapshot, ResourceSnapshot } from './snapshotTypes'; import { SnapshotRenderer } from './snapshotRenderer'; @@ -87,27 +84,3 @@ export abstract class BaseSnapshotStorage extends EventEmitter implements Snapsh return snapshot?.renderer.find(r => r.snapshotName === snapshotName); } } - -const fsReadFileAsync = util.promisify(fs.readFile.bind(fs)); - -export class PersistentSnapshotStorage extends BaseSnapshotStorage { - private _resourcesDir: string; - - constructor(resourcesDir: string) { - super(); - this._resourcesDir = resourcesDir; - } - - async load(tracePrefix: string) { - const networkTrace = await fsReadFileAsync(tracePrefix + '-network.trace', 'utf8'); - const resources = networkTrace.split('\n').map(line => line.trim()).filter(line => !!line).map(line => JSON.parse(line)) as ResourceSnapshot[]; - resources.forEach(r => this.addResource(r)); - const snapshotTrace = await fsReadFileAsync(path.join(tracePrefix + '-dom.trace'), 'utf8'); - const snapshots = snapshotTrace.split('\n').map(line => line.trim()).filter(line => !!line).map(line => JSON.parse(line)) as FrameSnapshot[]; - snapshots.forEach(s => this.addFrameSnapshot(s)); - } - - resourceContent(sha1: string): Buffer | undefined { - return fs.readFileSync(path.join(this._resourcesDir, sha1)); - } -} diff --git a/src/server/supplements/debugger.ts b/src/server/supplements/debugger.ts index f9555b877b..46deba7ba3 100644 --- a/src/server/supplements/debugger.ts +++ b/src/server/supplements/debugger.ts @@ -18,7 +18,6 @@ import { EventEmitter } from 'events'; import { debugMode, isUnderTest, monotonicTime } from '../../utils/utils'; import { BrowserContext } from '../browserContext'; import { CallMetadata, InstrumentationListener, SdkObject } from '../instrumentation'; -import * as consoleApiSource from '../../generated/consoleApiSource'; import { debugLogger } from '../../utils/debugLogger'; const symbol = Symbol('Debugger'); @@ -48,11 +47,6 @@ export class Debugger extends EventEmitter implements InstrumentationListener { return (context as any)[symbol] as Debugger | undefined; } - async onContextCreated() { - if (debugMode() === 'console') - await this._context.extendInjectedScript(consoleApiSource.source); - } - async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { if (shouldPauseOnCall(sdkObject, metadata) || (this._pauseOnNextStatement && shouldPauseOnStep(sdkObject, metadata))) await this.pause(sdkObject, metadata); diff --git a/src/server/trace/common/traceEvents.ts b/src/server/trace/common/traceEvents.ts index 88fcc612a1..0dc36c2a56 100644 --- a/src/server/trace/common/traceEvents.ts +++ b/src/server/trace/common/traceEvents.ts @@ -15,42 +15,33 @@ */ import { CallMetadata } from '../../instrumentation'; +import { FrameSnapshot, ResourceSnapshot } from '../../snapshot/snapshotTypes'; export type ContextCreatedTraceEvent = { timestamp: number, - type: 'context-created', + type: 'context-metadata', browserName: string, - contextId: string, deviceScaleFactor: number, isMobile: boolean, viewportSize?: { width: number, height: number }, debugName?: string, }; -export type ContextDestroyedTraceEvent = { - timestamp: number, - type: 'context-destroyed', - contextId: string, -}; - export type PageCreatedTraceEvent = { timestamp: number, type: 'page-created', - contextId: string, pageId: string, }; export type PageDestroyedTraceEvent = { timestamp: number, type: 'page-destroyed', - contextId: string, pageId: string, }; export type ScreencastFrameTraceEvent = { timestamp: number, type: 'page-screencast-frame', - contextId: string, pageId: string, pageTimestamp: number, sha1: string, @@ -61,14 +52,24 @@ export type ScreencastFrameTraceEvent = { export type ActionTraceEvent = { timestamp: number, type: 'action' | 'event', - contextId: string, metadata: CallMetadata, }; +export type ResourceSnapshotTraceEvent = { + timestamp: number, + type: 'resource-snapshot', + snapshot: ResourceSnapshot, +}; + +export type FrameSnapshotTraceEvent = { + timestamp: number, + type: 'frame-snapshot', + snapshot: FrameSnapshot, +}; + export type DialogOpenedEvent = { timestamp: number, type: 'dialog-opened', - contextId: string, pageId: string, dialogType: string, message?: string, @@ -77,7 +78,6 @@ export type DialogOpenedEvent = { export type DialogClosedEvent = { timestamp: number, type: 'dialog-closed', - contextId: string, pageId: string, dialogType: string, }; @@ -85,7 +85,6 @@ export type DialogClosedEvent = { export type NavigationEvent = { timestamp: number, type: 'navigation', - contextId: string, pageId: string, url: string, sameDocument: boolean, @@ -94,17 +93,17 @@ export type NavigationEvent = { export type LoadEvent = { timestamp: number, type: 'load', - contextId: string, pageId: string, }; export type TraceEvent = ContextCreatedTraceEvent | - ContextDestroyedTraceEvent | PageCreatedTraceEvent | PageDestroyedTraceEvent | ScreencastFrameTraceEvent | ActionTraceEvent | + ResourceSnapshotTraceEvent | + FrameSnapshotTraceEvent | DialogOpenedEvent | DialogClosedEvent | NavigationEvent | diff --git a/src/server/snapshot/persistentSnapshotter.ts b/src/server/trace/recorder/traceSnapshotter.ts similarity index 55% rename from src/server/snapshot/persistentSnapshotter.ts rename to src/server/trace/recorder/traceSnapshotter.ts index 419f1be8ca..7367297cb0 100644 --- a/src/server/snapshot/persistentSnapshotter.ts +++ b/src/server/trace/recorder/traceSnapshotter.ts @@ -18,41 +18,35 @@ import { EventEmitter } from 'events'; import fs from 'fs'; import path from 'path'; import util from 'util'; -import { BrowserContext } from '../browserContext'; -import { Page } from '../page'; -import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes'; -import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from './snapshotter'; -import { ElementHandle } from '../dom'; - +import { BrowserContext } from '../../browserContext'; +import { Page } from '../../page'; +import { FrameSnapshot, ResourceSnapshot } from '../../snapshot/snapshotTypes'; +import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from '../../snapshot/snapshotter'; +import { ElementHandle } from '../../dom'; +import { TraceEvent } from '../common/traceEvents'; +import { monotonicTime } from '../../../utils/utils'; const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs)); -const fsAppendFileAsync = util.promisify(fs.appendFile.bind(fs)); const fsMkdirAsync = util.promisify(fs.mkdir.bind(fs)); -const kSnapshotInterval = 100; - -export class PersistentSnapshotter extends EventEmitter implements SnapshotterDelegate { +export class TraceSnapshotter extends EventEmitter implements SnapshotterDelegate { private _snapshotter: Snapshotter; private _resourcesDir: string; private _writeArtifactChain = Promise.resolve(); - private _networkTrace: string; - private _snapshotTrace: string; + private _appendTraceEvent: (traceEvent: TraceEvent) => void; + private _context: BrowserContext; - constructor(context: BrowserContext, tracePrefix: string, resourcesDir: string) { + constructor(context: BrowserContext, resourcesDir: string, appendTraceEvent: (traceEvent: TraceEvent) => void) { super(); + this._context = context; this._resourcesDir = resourcesDir; - this._networkTrace = tracePrefix + '-network.trace'; - this._snapshotTrace = tracePrefix + '-dom.trace'; this._snapshotter = new Snapshotter(context, this); + this._appendTraceEvent = appendTraceEvent; + this._writeArtifactChain = fsMkdirAsync(resourcesDir, { recursive: true }); } - async start(autoSnapshots: boolean): Promise { - await fsMkdirAsync(this._resourcesDir, {recursive: true}).catch(() => {}); - await fsAppendFileAsync(this._networkTrace, Buffer.from([])); - await fsAppendFileAsync(this._snapshotTrace, Buffer.from([])); + async start(): Promise { await this._snapshotter.initialize(); - if (autoSnapshots) - await this._snapshotter.setAutoSnapshotInterval(kSnapshotInterval); } async dispose() { @@ -70,15 +64,19 @@ export class PersistentSnapshotter extends EventEmitter implements SnapshotterDe }); } - onResourceSnapshot(resource: ResourceSnapshot): void { - this._writeArtifactChain = this._writeArtifactChain.then(async () => { - await fsAppendFileAsync(this._networkTrace, JSON.stringify(resource) + '\n'); + onResourceSnapshot(snapshot: ResourceSnapshot): void { + this._appendTraceEvent({ + timestamp: monotonicTime(), + type: 'resource-snapshot', + snapshot, }); } onFrameSnapshot(snapshot: FrameSnapshot): void { - this._writeArtifactChain = this._writeArtifactChain.then(async () => { - await fsAppendFileAsync(this._snapshotTrace, JSON.stringify(snapshot) + '\n'); + this._appendTraceEvent({ + timestamp: monotonicTime(), + type: 'frame-snapshot', + snapshot, }); } } diff --git a/src/server/trace/recorder/tracer.ts b/src/server/trace/recorder/tracer.ts index bd0ec39b80..d3e173d19e 100644 --- a/src/server/trace/recorder/tracer.ts +++ b/src/server/trace/recorder/tracer.ts @@ -17,7 +17,7 @@ import fs from 'fs'; import path from 'path'; import * as util from 'util'; -import { calculateSha1, createGuid, getFromENV, mkdirIfNeeded, monotonicTime } from '../../../utils/utils'; +import { calculateSha1, getFromENV, mkdirIfNeeded, monotonicTime } from '../../../utils/utils'; import { BrowserContext } from '../../browserContext'; import { Dialog } from '../../dialog'; import { ElementHandle } from '../../dom'; @@ -25,43 +25,57 @@ import { Frame, NavigationEvent } from '../../frames'; import { helper, RegisteredListener } from '../../helper'; import { CallMetadata, InstrumentationListener, SdkObject } from '../../instrumentation'; import { Page } from '../../page'; -import { PersistentSnapshotter } from '../../snapshot/persistentSnapshotter'; import * as trace from '../common/traceEvents'; +import { TraceSnapshotter } from './traceSnapshotter'; const fsAppendFileAsync = util.promisify(fs.appendFile.bind(fs)); const envTrace = getFromENV('PWTRACE_RESOURCE_DIR'); export class Tracer implements InstrumentationListener { - private _contextId: string; private _appendEventChain: Promise; - private _snapshotter: PersistentSnapshotter; - private _eventListeners: RegisteredListener[]; + private _snapshotter: TraceSnapshotter; + private _eventListeners: RegisteredListener[] = []; private _disposed = false; private _pendingCalls = new Map(); private _context: BrowserContext; constructor(context: BrowserContext, traceDir: string) { this._context = context; + this._context.instrumentation.addListener(this); const resourcesDir = envTrace || path.join(traceDir, 'resources'); const tracePrefix = path.join(traceDir, context._options._debugName!); - const traceFile = tracePrefix + '-actions.trace'; - this._contextId = 'context@' + createGuid(); + const traceFile = tracePrefix + '.trace'; this._appendEventChain = mkdirIfNeeded(traceFile).then(() => traceFile); + this._snapshotter = new TraceSnapshotter(context, resourcesDir, traceEvent => this._appendTraceEvent(traceEvent)); + } + + async start(): Promise { const event: trace.ContextCreatedTraceEvent = { timestamp: monotonicTime(), - type: 'context-created', - browserName: context._browser.options.name, - contextId: this._contextId, - isMobile: !!context._options.isMobile, - deviceScaleFactor: context._options.deviceScaleFactor || 1, - viewportSize: context._options.viewport || undefined, - debugName: context._options._debugName, + type: 'context-metadata', + browserName: this._context._browser.options.name, + isMobile: !!this._context._options.isMobile, + deviceScaleFactor: this._context._options.deviceScaleFactor || 1, + viewportSize: this._context._options.viewport || undefined, + debugName: this._context._options._debugName, }; this._appendTraceEvent(event); - this._snapshotter = new PersistentSnapshotter(context, tracePrefix, resourcesDir); this._eventListeners = [ - helper.addEventListener(context, BrowserContext.Events.Page, this._onPage.bind(this)), + helper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)), ]; + await this._snapshotter.start(); + } + + async stop() { + this._disposed = true; + this._context.instrumentation.removeListener(this); + helper.removeEventListeners(this._eventListeners); + await this._snapshotter.dispose(); + for (const { sdkObject, metadata } of this._pendingCalls.values()) + this.onAfterCall(sdkObject, metadata); + + // Ensure all writes are finished. + await this._appendEventChain; } _captureSnapshot(name: 'before' | 'after' | 'action' | 'event', sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle) { @@ -72,10 +86,6 @@ export class Tracer implements InstrumentationListener { this._snapshotter.captureSnapshot(sdkObject.attribution.page, snapshotName, element); } - async onContextCreated(): Promise { - await this._snapshotter.start(false); - } - async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) { this._captureSnapshot('before', sdkObject, metadata); this._pendingCalls.set(metadata.id, { sdkObject, metadata }); @@ -86,13 +96,14 @@ export class Tracer implements InstrumentationListener { } async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata) { + if (!this._pendingCalls.has(metadata.id)) + return; this._captureSnapshot('after', sdkObject, metadata); if (!sdkObject.attribution.page) return; const event: trace.ActionTraceEvent = { timestamp: metadata.startTime, type: 'action', - contextId: this._contextId, metadata, }; this._appendTraceEvent(event); @@ -105,7 +116,6 @@ export class Tracer implements InstrumentationListener { const event: trace.ActionTraceEvent = { timestamp: metadata.startTime, type: 'event', - contextId: this._contextId, metadata, }; this._appendTraceEvent(event); @@ -117,7 +127,6 @@ export class Tracer implements InstrumentationListener { const event: trace.PageCreatedTraceEvent = { timestamp: monotonicTime(), type: 'page-created', - contextId: this._contextId, pageId, }; this._appendTraceEvent(event); @@ -128,7 +137,6 @@ export class Tracer implements InstrumentationListener { const event: trace.DialogOpenedEvent = { timestamp: monotonicTime(), type: 'dialog-opened', - contextId: this._contextId, pageId, dialogType: dialog.type(), message: dialog.message(), @@ -142,7 +150,6 @@ export class Tracer implements InstrumentationListener { const event: trace.DialogClosedEvent = { timestamp: monotonicTime(), type: 'dialog-closed', - contextId: this._contextId, pageId, dialogType: dialog.type(), }; @@ -155,7 +162,6 @@ export class Tracer implements InstrumentationListener { const event: trace.NavigationEvent = { timestamp: monotonicTime(), type: 'navigation', - contextId: this._contextId, pageId, url: navigationEvent.url, sameDocument: !navigationEvent.newDocument, @@ -169,7 +175,6 @@ export class Tracer implements InstrumentationListener { const event: trace.LoadEvent = { timestamp: monotonicTime(), type: 'load', - contextId: this._contextId, pageId, }; this._appendTraceEvent(event); @@ -180,7 +185,6 @@ export class Tracer implements InstrumentationListener { const event: trace.ScreencastFrameTraceEvent = { type: 'page-screencast-frame', pageId: page.guid, - contextId: this._contextId, sha1, pageTimestamp: params.timestamp, width: params.width, @@ -197,30 +201,12 @@ export class Tracer implements InstrumentationListener { const event: trace.PageDestroyedTraceEvent = { timestamp: monotonicTime(), type: 'page-destroyed', - contextId: this._contextId, pageId, }; this._appendTraceEvent(event); }); } - async onContextDestroyed() { - this._disposed = true; - helper.removeEventListeners(this._eventListeners); - await this._snapshotter.dispose(); - for (const { sdkObject, metadata } of this._pendingCalls.values()) - this.onAfterCall(sdkObject, metadata); - const event: trace.ContextDestroyedTraceEvent = { - timestamp: monotonicTime(), - type: 'context-destroyed', - contextId: this._contextId, - }; - this._appendTraceEvent(event); - - // Ensure all writes are finished. - await this._appendEventChain; - } - private _appendTraceEvent(event: any) { // Serialize all writes to the trace file. this._appendEventChain = this._appendEventChain.then(async traceFile => { diff --git a/src/server/trace/viewer/traceModel.ts b/src/server/trace/viewer/traceModel.ts index 7d58be7564..3365c22430 100644 --- a/src/server/trace/viewer/traceModel.ts +++ b/src/server/trace/viewer/traceModel.ts @@ -14,24 +14,29 @@ * limitations under the License. */ +import fs from 'fs'; +import path from 'path'; import * as trace from '../common/traceEvents'; import { ContextResources, ResourceSnapshot } from '../../snapshot/snapshotTypes'; -import { SnapshotStorage } from '../../snapshot/snapshotStorage'; +import { BaseSnapshotStorage, SnapshotStorage } from '../../snapshot/snapshotStorage'; export * as trace from '../common/traceEvents'; export class TraceModel { - contextEntries = new Map(); - pageEntries = new Map(); + contextEntry: ContextEntry | undefined; + pageEntries = new Map(); contextResources = new Map(); + private _snapshotStorage: PersistentSnapshotStorage; + + constructor(snapshotStorage: PersistentSnapshotStorage) { + this._snapshotStorage = snapshotStorage; + } appendEvents(events: trace.TraceEvent[], snapshotStorage: SnapshotStorage) { for (const event of events) this.appendEvent(event); const actions: ActionEntry[] = []; - for (const context of this.contextEntries.values()) { - for (const page of context.pages) - actions.push(...page.actions); - } + for (const page of this.contextEntry!.pages) + actions.push(...page.actions); const resources = snapshotStorage.resources().reverse(); actions.reverse(); @@ -45,19 +50,13 @@ export class TraceModel { appendEvent(event: trace.TraceEvent) { switch (event.type) { - case 'context-created': { - this.contextEntries.set(event.contextId, { + case 'context-metadata': { + this.contextEntry = { startTime: Number.MAX_VALUE, endTime: Number.MIN_VALUE, created: event, - destroyed: undefined as any, pages: [], - }); - this.contextResources.set(event.contextId, new Map()); - break; - } - case 'context-destroyed': { - this.contextEntries.get(event.contextId)!.destroyed = event; + }; break; } case 'page-created': { @@ -68,22 +67,21 @@ export class TraceModel { interestingEvents: [], screencastFrames: [], }; - const contextEntry = this.contextEntries.get(event.contextId)!; - this.pageEntries.set(event.pageId, { pageEntry, contextEntry }); - contextEntry.pages.push(pageEntry); + this.pageEntries.set(event.pageId, pageEntry); + this.contextEntry!.pages.push(pageEntry); break; } case 'page-destroyed': { - this.pageEntries.get(event.pageId)!.pageEntry.destroyed = event; + this.pageEntries.get(event.pageId)!.destroyed = event; break; } case 'page-screencast-frame': { - this.pageEntries.get(event.pageId)!.pageEntry.screencastFrames.push(event); + this.pageEntries.get(event.pageId)!.screencastFrames.push(event); break; } case 'action': { const metadata = event.metadata; - const { pageEntry } = this.pageEntries.get(metadata.pageId!)!; + const pageEntry = this.pageEntries.get(metadata.pageId!)!; const action: ActionEntry = { actionId: metadata.id, resources: [], @@ -96,14 +94,19 @@ export class TraceModel { case 'dialog-closed': case 'navigation': case 'load': { - const { pageEntry } = this.pageEntries.get(event.pageId)!; + const pageEntry = this.pageEntries.get(event.pageId)!; pageEntry.interestingEvents.push(event); break; } + case 'resource-snapshot': + this._snapshotStorage.addResource(event.snapshot); + break; + case 'frame-snapshot': + this._snapshotStorage.addFrameSnapshot(event.snapshot); + break; } - const contextEntry = this.contextEntries.get(event.contextId)!; - contextEntry.startTime = Math.min(contextEntry.startTime, event.timestamp); - contextEntry.endTime = Math.max(contextEntry.endTime, event.timestamp); + this.contextEntry!.startTime = Math.min(this.contextEntry!.startTime, event.timestamp); + this.contextEntry!.endTime = Math.max(this.contextEntry!.endTime, event.timestamp); } } @@ -111,7 +114,6 @@ export type ContextEntry = { startTime: number; endTime: number; created: trace.ContextCreatedTraceEvent; - destroyed: trace.ContextDestroyedTraceEvent; pages: PageEntry[]; } @@ -134,3 +136,16 @@ export type ActionEntry = trace.ActionTraceEvent & { actionId: string; resources: ResourceSnapshot[] }; + +export class PersistentSnapshotStorage extends BaseSnapshotStorage { + private _resourcesDir: string; + + constructor(resourcesDir: string) { + super(); + this._resourcesDir = resourcesDir; + } + + resourceContent(sha1: string): Buffer | undefined { + return fs.readFileSync(path.join(this._resourcesDir, sha1)); + } +} diff --git a/src/server/trace/viewer/traceViewer.ts b/src/server/trace/viewer/traceViewer.ts index ce208d8ebb..4efe31d3c4 100644 --- a/src/server/trace/viewer/traceViewer.ts +++ b/src/server/trace/viewer/traceViewer.ts @@ -18,11 +18,10 @@ import fs from 'fs'; import path from 'path'; import { createPlaywright } from '../../playwright'; import * as util from 'util'; -import { TraceModel } from './traceModel'; +import { PersistentSnapshotStorage, TraceModel } from './traceModel'; import { TraceEvent } from '../common/traceEvents'; import { ServerRouteHandler, HttpServer } from '../../../utils/httpServer'; import { SnapshotServer } from '../../snapshot/snapshotServer'; -import { PersistentSnapshotStorage } from '../../snapshot/snapshotStorage'; import * as consoleApiSource from '../../../generated/consoleApiSource'; import { isUnderTest } from '../../../utils/utils'; import { internalCallMetadata } from '../../instrumentation'; @@ -51,9 +50,9 @@ class TraceViewer { // - "/snapshot/pageId/..." - actual snapshot html. // - "/snapshot/service-worker.js" - service worker that intercepts snapshot resources // and translates them into "/resources/". - const actionTraces = fs.readdirSync(traceDir).filter(name => name.endsWith('-actions.trace')); + const actionTraces = fs.readdirSync(traceDir).filter(name => name.endsWith('.trace')); const debugNames = actionTraces.map(name => { - const tracePrefix = path.join(traceDir, name.substring(0, name.indexOf('-actions.trace'))); + const tracePrefix = path.join(traceDir, name.substring(0, name.indexOf('.trace'))); return path.basename(tracePrefix); }); @@ -76,12 +75,11 @@ class TraceViewer { response.statusCode = 200; response.setHeader('Content-Type', 'application/json'); (async () => { - await snapshotStorage.load(tracePrefix); - const traceContent = await fsReadFileAsync(tracePrefix + '-actions.trace', 'utf8'); + const traceContent = await fsReadFileAsync(tracePrefix + '.trace', 'utf8'); const events = traceContent.split('\n').map(line => line.trim()).filter(line => !!line).map(line => JSON.parse(line)) as TraceEvent[]; - const model = new TraceModel(); + const model = new TraceModel(snapshotStorage); model.appendEvents(events, snapshotStorage); - response.end(JSON.stringify(model.contextEntries.values().next().value)); + response.end(JSON.stringify(model.contextEntry)); })().catch(e => console.error(e)); return true; }; diff --git a/src/server/types.ts b/src/server/types.ts index abf81d1ea9..1d808845b2 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -247,7 +247,6 @@ export type BrowserContextOptions = { path: string }, proxy?: ProxySettings, - _traceDir?: string, _debugName?: string, }; @@ -273,6 +272,7 @@ type LaunchOptionsBase = { chromiumSandbox?: boolean, slowMo?: number, useWebSocket?: boolean, + _traceDir?: string, }; export type LaunchOptions = LaunchOptionsBase & { firefoxUserPrefs?: { [key: string]: string | number | boolean }, diff --git a/src/web/traceViewer/ui/workbench.tsx b/src/web/traceViewer/ui/workbench.tsx index a0958ffd61..dd7b385df4 100644 --- a/src/web/traceViewer/ui/workbench.tsx +++ b/src/web/traceViewer/ui/workbench.tsx @@ -103,18 +103,12 @@ const emptyContext: ContextEntry = { endTime: now, created: { timestamp: now, - type: 'context-created', + type: 'context-metadata', browserName: '', - contextId: '', deviceScaleFactor: 1, isMobile: false, viewportSize: { width: 1280, height: 800 }, debugName: '', }, - destroyed: { - timestamp: now, - type: 'context-destroyed', - contextId: '', - }, pages: [] }; diff --git a/tests/config/browserEnv.ts b/tests/config/browserEnv.ts index 3f229d731b..1c38173c48 100644 --- a/tests/config/browserEnv.ts +++ b/tests/config/browserEnv.ts @@ -121,10 +121,12 @@ export class PlaywrightEnv implements Env { require('../../lib/utils/utils').setUnderTest(); this._playwright = await this._mode.setup(workerInfo); this._browserType = this._playwright[this._browserName]; - this._browserOptions = { + const options = { ...this._options, + _traceDir: this._options.traceDir, handleSIGINT: false, }; + this._browserOptions = options; } private async _createUserDataDir() { @@ -169,8 +171,6 @@ export class PlaywrightEnv implements Env { testInfo.data.mode = this._options.mode; if (this._options.video) testInfo.data.video = true; - if (this._options.traceDir) - testInfo.data.trace = true; return { playwright: this._playwright, browserName: this._browserName, @@ -236,7 +236,6 @@ export class BrowserEnv extends PlaywrightEnv implements Env { const debugName = path.relative(testInfo.config.outputDir, testInfo.outputPath('')).replace(/[\/\\]/g, '-'); const contextOptions = { recordVideo: this._options.video ? { dir: testInfo.outputPath('') } : undefined, - _traceDir: this._options.traceDir, _debugName: debugName, ...this._contextOptions, } as BrowserContextOptions;