chore: start / stop context tracing (#6309)

This commit is contained in:
Pavel Feldman 2021-04-23 20:39:09 -07:00 committed by GitHub
parent 97cf86d20a
commit a9219aa8b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 191 additions and 197 deletions

View file

@ -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,

View file

@ -48,8 +48,6 @@ export class Browser extends ChannelOwner<channels.BrowserChannel, channels.Brow
async newContext(options: BrowserContextOptions = {}): Promise<BrowserContext> {
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;

View file

@ -279,6 +279,18 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
this.emit(Events.BrowserContext.Close, this);
}
async _startTracing() {
return await this._wrapApiCall('browserContext.startTracing', async (channel: channels.BrowserContextChannel) => {
await channel.startTracing();
});
}
async _stopTracing() {
return await this._wrapApiCall('browserContext.stopTracing', async (channel: channels.BrowserContextChannel) => {
await channel.stopTracing();
});
}
async close(): Promise<void> {
try {
await this._wrapApiCall('browserContext.close', async (channel: channels.BrowserContextChannel) => {

View file

@ -157,4 +157,12 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
const crBrowserContext = this._object as CRBrowserContext;
return { session: new CDPSessionDispatcher(this._scope, await crBrowserContext.newCDPSession((params.page as PageDispatcher)._object)) };
}
async startTracing(params: channels.BrowserContextStartTracingParams): Promise<void> {
await this._context.startTracing();
}
async stopTracing(): Promise<channels.BrowserContextStopTracingResult> {
await this._context.stopTracing();
}
}

View file

@ -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<BrowserContextPauseResult>;
recorderSupplementEnable(params: BrowserContextRecorderSupplementEnableParams, metadata?: Metadata): Promise<BrowserContextRecorderSupplementEnableResult>;
newCDPSession(params: BrowserContextNewCDPSessionParams, metadata?: Metadata): Promise<BrowserContextNewCDPSessionResult>;
startTracing(params?: BrowserContextStartTracingParams, metadata?: Metadata): Promise<BrowserContextStartTracingResult>;
stopTracing(params?: BrowserContextStopTracingParams, metadata?: Metadata): Promise<BrowserContextStopTracingResult>;
}
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,

View file

@ -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?

View file

@ -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,

View file

@ -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,

View file

@ -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<string>();
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<void>[] = [];
@ -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) {

View file

@ -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);

View file

@ -69,8 +69,7 @@ export class SdkObject extends EventEmitter {
export interface Instrumentation {
addListener(listener: InstrumentationListener): void;
onContextCreated(): Promise<void>;
onContextDestroyed(): Promise<void>;
removeListener(listener: InstrumentationListener): void;
onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise<void>;
onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): void;
@ -79,8 +78,6 @@ export interface Instrumentation {
}
export interface InstrumentationListener {
onContextCreated?(): Promise<void>;
onContextDestroyed?(): Promise<void>;
onBeforeCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onBeforeInputAction?(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise<void>;
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[]) => {

View file

@ -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));
}
}

View file

@ -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<void> {
if (shouldPauseOnCall(sdkObject, metadata) || (this._pauseOnNextStatement && shouldPauseOnStep(sdkObject, metadata)))
await this.pause(sdkObject, metadata);

View file

@ -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 |

View file

@ -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<void> {
await fsMkdirAsync(this._resourcesDir, {recursive: true}).catch(() => {});
await fsAppendFileAsync(this._networkTrace, Buffer.from([]));
await fsAppendFileAsync(this._snapshotTrace, Buffer.from([]));
async start(): Promise<void> {
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,
});
}
}

View file

@ -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<string>;
private _snapshotter: PersistentSnapshotter;
private _eventListeners: RegisteredListener[];
private _snapshotter: TraceSnapshotter;
private _eventListeners: RegisteredListener[] = [];
private _disposed = false;
private _pendingCalls = new Map<string, { sdkObject: SdkObject, metadata: CallMetadata }>();
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<void> {
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<void> {
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 => {

View file

@ -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<string, ContextEntry>();
pageEntries = new Map<string, { contextEntry: ContextEntry, pageEntry: PageEntry }>();
contextEntry: ContextEntry | undefined;
pageEntries = new Map<string, PageEntry>();
contextResources = new Map<string, ContextResources>();
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));
}
}

View file

@ -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/<resourceId>".
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;
};

View file

@ -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 },

View file

@ -103,18 +103,12 @@ const emptyContext: ContextEntry = {
endTime: now,
created: {
timestamp: now,
type: 'context-created',
type: 'context-metadata',
browserName: '',
contextId: '<empty>',
deviceScaleFactor: 1,
isMobile: false,
viewportSize: { width: 1280, height: 800 },
debugName: '<empty>',
},
destroyed: {
timestamp: now,
type: 'context-destroyed',
contextId: '<empty>',
},
pages: []
};

View file

@ -121,10 +121,12 @@ export class PlaywrightEnv implements Env<PlaywrightTestArgs> {
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<PlaywrightTestArgs> {
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<BrowserTestArgs> {
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;