playwright/src/trace/tracer.ts

308 lines
10 KiB
TypeScript
Raw Normal View History

/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ActionListener, ActionMetadata, BrowserContext, ContextListener, Video } from '../server/browserContext';
2020-09-14 16:56:04 +02:00
import type { SnapshotterResource as SnapshotterResource, SnapshotterBlob, SnapshotterDelegate } from './snapshotter';
import * as trace from './traceTypes';
import * as path from 'path';
import * as util from 'util';
import * as fs from 'fs';
import { createGuid, getFromENV, mkdirIfNeeded, monotonicTime } from '../utils/utils';
import { Page } from '../server/page';
import { Snapshotter } from './snapshotter';
import { helper, RegisteredListener } from '../server/helper';
import { ProgressResult } from '../server/progress';
import { Dialog } from '../server/dialog';
import { Frame, NavigationEvent } from '../server/frames';
import { snapshotScript } from './snapshotterInjected';
const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs));
const fsAppendFileAsync = util.promisify(fs.appendFile.bind(fs));
const fsAccessAsync = util.promisify(fs.access.bind(fs));
const envTrace = getFromENV('PW_TRACE_DIR');
export class Tracer implements ContextListener {
private _contextTracers = new Map<BrowserContext, ContextTracer>();
async onContextCreated(context: BrowserContext): Promise<void> {
const traceDir = envTrace || context._options._traceDir;
if (!traceDir)
return;
const traceStorageDir = path.join(traceDir, 'resources');
const tracePath = path.join(traceDir, createGuid() + '.trace');
const contextTracer = new ContextTracer(context, traceStorageDir, tracePath);
this._contextTracers.set(context, contextTracer);
}
async onContextWillDestroy(context: BrowserContext): Promise<void> {}
async onContextDidDestroy(context: BrowserContext): Promise<void> {
const contextTracer = this._contextTracers.get(context);
if (contextTracer) {
await contextTracer.dispose().catch(e => {});
this._contextTracers.delete(context);
}
}
}
const pageIdSymbol = Symbol('pageId');
const snapshotsSymbol = Symbol('snapshots');
// TODO: this is a hacky way to pass snapshots between onActionCheckpoint and onAfterAction.
function snapshotsForMetadata(metadata: ActionMetadata): { name: string, snapshotId: string }[] {
if (!(metadata as any)[snapshotsSymbol])
(metadata as any)[snapshotsSymbol] = [];
return (metadata as any)[snapshotsSymbol];
}
class ContextTracer implements SnapshotterDelegate, ActionListener {
private _context: BrowserContext;
private _contextId: string;
private _traceStoragePromise: Promise<string>;
private _appendEventChain: Promise<string>;
private _writeArtifactChain: Promise<void>;
private _snapshotter: Snapshotter;
private _eventListeners: RegisteredListener[];
private _disposed = false;
private _traceFile: string;
constructor(context: BrowserContext, traceStorageDir: string, traceFile: string) {
this._context = context;
this._contextId = 'context@' + createGuid();
this._traceFile = traceFile;
this._traceStoragePromise = mkdirIfNeeded(path.join(traceStorageDir, 'sha1')).then(() => traceStorageDir);
this._appendEventChain = mkdirIfNeeded(traceFile).then(() => traceFile);
this._writeArtifactChain = Promise.resolve();
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,
snapshotScript: snapshotScript(),
};
this._appendTraceEvent(event);
this._snapshotter = new Snapshotter(context, this);
this._eventListeners = [
helper.addEventListener(context, BrowserContext.Events.Page, this._onPage.bind(this)),
];
this._context._actionListeners.add(this);
}
onBlob(blob: SnapshotterBlob): void {
this._writeArtifact(blob.sha1, blob.buffer);
}
2020-09-14 16:56:04 +02:00
onResource(resource: SnapshotterResource): void {
const event: trace.NetworkResourceTraceEvent = {
timestamp: monotonicTime(),
type: 'resource',
contextId: this._contextId,
2020-09-14 16:56:04 +02:00
pageId: resource.pageId,
frameId: resource.frameId,
resourceId: 'resource@' + createGuid(),
url: resource.url,
contentType: resource.contentType,
responseHeaders: resource.responseHeaders,
requestHeaders: resource.requestHeaders,
method: resource.method,
status: resource.status,
requestSha1: resource.requestSha1,
responseSha1: resource.responseSha1,
};
this._appendTraceEvent(event);
}
onFrameSnapshot(frame: Frame, frameUrl: string, snapshot: trace.FrameSnapshot, snapshotId?: string): void {
const event: trace.FrameSnapshotTraceEvent = {
timestamp: monotonicTime(),
type: 'snapshot',
contextId: this._contextId,
pageId: this.pageId(frame._page),
frameId: frame._page.mainFrame() === frame ? '' : frame._id,
snapshot: snapshot,
frameUrl,
snapshotId,
};
this._appendTraceEvent(event);
}
2020-09-14 16:56:04 +02:00
pageId(page: Page): string {
return (page as any)[pageIdSymbol];
2020-09-14 16:56:04 +02:00
}
async onActionCheckpoint(name: string, metadata: ActionMetadata): Promise<void> {
const snapshotId = createGuid();
snapshotsForMetadata(metadata).push({ name, snapshotId });
await this._snapshotter.forceSnapshot(metadata.page, snapshotId);
}
async onAfterAction(result: ProgressResult, metadata: ActionMetadata): Promise<void> {
const event: trace.ActionTraceEvent = {
timestamp: monotonicTime(),
type: 'action',
contextId: this._contextId,
pageId: this.pageId(metadata.page),
action: metadata.type,
selector: typeof metadata.target === 'string' ? metadata.target : undefined,
value: metadata.value,
startTime: result.startTime,
endTime: result.endTime,
stack: metadata.stack,
logs: result.logs.slice(),
error: result.error ? result.error.stack : undefined,
snapshots: snapshotsForMetadata(metadata),
};
this._appendTraceEvent(event);
}
private _onPage(page: Page) {
const pageId = 'page@' + createGuid();
(page as any)[pageIdSymbol] = pageId;
const event: trace.PageCreatedTraceEvent = {
timestamp: monotonicTime(),
type: 'page-created',
contextId: this._contextId,
pageId,
};
this._appendTraceEvent(event);
page.on(Page.Events.VideoStarted, (video: Video) => {
if (this._disposed)
return;
const event: trace.PageVideoTraceEvent = {
timestamp: monotonicTime(),
type: 'page-video',
contextId: this._contextId,
pageId,
fileName: path.relative(path.dirname(this._traceFile), video._path),
};
this._appendTraceEvent(event);
});
page.on(Page.Events.Dialog, (dialog: Dialog) => {
if (this._disposed)
return;
const event: trace.DialogOpenedEvent = {
timestamp: monotonicTime(),
type: 'dialog-opened',
contextId: this._contextId,
pageId,
dialogType: dialog.type(),
message: dialog.message(),
};
this._appendTraceEvent(event);
});
page.on(Page.Events.InternalDialogClosed, (dialog: Dialog) => {
if (this._disposed)
return;
const event: trace.DialogClosedEvent = {
timestamp: monotonicTime(),
type: 'dialog-closed',
contextId: this._contextId,
pageId,
dialogType: dialog.type(),
};
this._appendTraceEvent(event);
});
page.mainFrame().on(Frame.Events.Navigation, (navigationEvent: NavigationEvent) => {
if (this._disposed || page.mainFrame().url() === 'about:blank')
return;
const event: trace.NavigationEvent = {
timestamp: monotonicTime(),
type: 'navigation',
contextId: this._contextId,
pageId,
url: navigationEvent.url,
sameDocument: !navigationEvent.newDocument,
};
this._appendTraceEvent(event);
});
page.on(Page.Events.Load, () => {
if (this._disposed || page.mainFrame().url() === 'about:blank')
return;
const event: trace.LoadEvent = {
timestamp: monotonicTime(),
type: 'load',
contextId: this._contextId,
pageId,
};
this._appendTraceEvent(event);
});
page.once(Page.Events.Close, () => {
if (this._disposed)
return;
const event: trace.PageDestroyedTraceEvent = {
timestamp: monotonicTime(),
type: 'page-destroyed',
contextId: this._contextId,
pageId,
};
this._appendTraceEvent(event);
});
}
async dispose() {
this._disposed = true;
this._context._actionListeners.delete(this);
helper.removeEventListeners(this._eventListeners);
this._snapshotter.dispose();
const event: trace.ContextDestroyedTraceEvent = {
timestamp: monotonicTime(),
type: 'context-destroyed',
contextId: this._contextId,
};
this._appendTraceEvent(event);
// Ensure all writes are finished.
await this._appendEventChain;
await this._writeArtifactChain;
}
private _writeArtifact(sha1: string, buffer: Buffer) {
// Save all write promises to wait for them in dispose.
const promise = this._innerWriteArtifact(sha1, buffer);
this._writeArtifactChain = this._writeArtifactChain.then(() => promise);
}
private async _innerWriteArtifact(sha1: string, buffer: Buffer): Promise<void> {
const traceDirectory = await this._traceStoragePromise;
const filePath = path.join(traceDirectory, sha1);
try {
await fsAccessAsync(filePath);
} catch (e) {
// File does not exist - write it.
await fsWriteFileAsync(filePath, buffer);
}
}
private _appendTraceEvent(event: any) {
// Serialize all writes to the trace file.
this._appendEventChain = this._appendEventChain.then(async traceFile => {
await fsAppendFileAsync(traceFile, JSON.stringify(event) + '\n');
return traceFile;
});
}
}