chore: migrate tracing to har (#8417)

chore: migrate tracing to har

- `HarTracer` is used by both `HarRecorder` that implements
  `recordHar` context option, and by tracing.
- We keep the `trace.network` format for now, so it is not
  yet a valid har file, but it contains har entries.
This commit is contained in:
Dmitry Gozman 2021-08-24 21:09:41 -07:00 committed by GitHub
parent cccc2ac4bc
commit 29eb6cb777
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 253 additions and 176 deletions

View file

@ -31,7 +31,7 @@ import path from 'path';
import { CallMetadata, internalCallMetadata, createInstrumentation, SdkObject } from './instrumentation'; import { CallMetadata, internalCallMetadata, createInstrumentation, SdkObject } from './instrumentation';
import { Debugger } from './supplements/debugger'; import { Debugger } from './supplements/debugger';
import { Tracing } from './trace/recorder/tracing'; import { Tracing } from './trace/recorder/tracing';
import { HarTracer } from './supplements/har/harTracer'; import { HarRecorder } from './supplements/har/harRecorder';
import { RecorderSupplement } from './supplements/recorderSupplement'; import { RecorderSupplement } from './supplements/recorderSupplement';
import * as consoleApiSource from '../generated/consoleApiSource'; import * as consoleApiSource from '../generated/consoleApiSource';
@ -61,7 +61,7 @@ export abstract class BrowserContext extends SdkObject {
readonly _browserContextId: string | undefined; readonly _browserContextId: string | undefined;
private _selectors?: Selectors; private _selectors?: Selectors;
private _origins = new Set<string>(); private _origins = new Set<string>();
private _harTracer: HarTracer | undefined; private _harRecorder: HarRecorder | undefined;
readonly tracing: Tracing; readonly tracing: Tracing;
constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) { constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) {
@ -74,7 +74,7 @@ export abstract class BrowserContext extends SdkObject {
this._closePromise = new Promise(fulfill => this._closePromiseFulfill = fulfill); this._closePromise = new Promise(fulfill => this._closePromiseFulfill = fulfill);
if (this._options.recordHar) if (this._options.recordHar)
this._harTracer = new HarTracer(this, this._options.recordHar); this._harRecorder = new HarRecorder(this, this._options.recordHar);
this.tracing = new Tracing(this); this.tracing = new Tracing(this);
} }
@ -272,7 +272,7 @@ export abstract class BrowserContext extends SdkObject {
this.emit(BrowserContext.Events.BeforeClose); this.emit(BrowserContext.Events.BeforeClose);
this._closedStatus = 'closing'; this._closedStatus = 'closing';
await this._harTracer?.flush(); await this._harRecorder?.flush();
await this.tracing.dispose(); await this.tracing.dispose();
// Cleanup. // Cleanup.

View file

@ -18,37 +18,45 @@ import { HttpServer } from '../../utils/httpServer';
import { BrowserContext } from '../browserContext'; import { BrowserContext } from '../browserContext';
import { eventsHelper } from '../../utils/eventsHelper'; import { eventsHelper } from '../../utils/eventsHelper';
import { Page } from '../page'; import { Page } from '../page';
import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes'; import { FrameSnapshot } from './snapshotTypes';
import { SnapshotRenderer } from './snapshotRenderer'; import { SnapshotRenderer } from './snapshotRenderer';
import { SnapshotServer } from './snapshotServer'; import { SnapshotServer } from './snapshotServer';
import { BaseSnapshotStorage } from './snapshotStorage'; import { BaseSnapshotStorage } from './snapshotStorage';
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from './snapshotter'; import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from './snapshotter';
import { ElementHandle } from '../dom'; import { ElementHandle } from '../dom';
import { HarTracer, HarTracerDelegate } from '../supplements/har/harTracer';
import * as har from '../supplements/har/har';
export class InMemorySnapshotter extends BaseSnapshotStorage implements SnapshotterDelegate { export class InMemorySnapshotter extends BaseSnapshotStorage implements SnapshotterDelegate, HarTracerDelegate {
private _blobs = new Map<string, Buffer>(); private _blobs = new Map<string, Buffer>();
private _server: HttpServer; private _server: HttpServer;
private _snapshotter: Snapshotter; private _snapshotter: Snapshotter;
private _harTracer: HarTracer;
constructor(context: BrowserContext) { constructor(context: BrowserContext) {
super(); super();
this._server = new HttpServer(); this._server = new HttpServer();
new SnapshotServer(this._server, this); new SnapshotServer(this._server, this);
this._snapshotter = new Snapshotter(context, this); this._snapshotter = new Snapshotter(context, this);
this._harTracer = new HarTracer(context, this, { content: 'sha1', waitForContentOnStop: false, skipScripts: true });
} }
async initialize(): Promise<string> { async initialize(): Promise<string> {
await this._snapshotter.start(); await this._snapshotter.start();
this._harTracer.start();
return await this._server.start(); return await this._server.start();
} }
async reset() { async reset() {
await this._snapshotter.reset(); await this._snapshotter.reset();
await this._harTracer.stop();
this._harTracer.start();
this.clear(); this.clear();
} }
async dispose() { async dispose() {
this._snapshotter.dispose(); this._snapshotter.dispose();
await this._harTracer.stop();
await this._server.stop(); await this._server.stop();
} }
@ -67,12 +75,19 @@ export class InMemorySnapshotter extends BaseSnapshotStorage implements Snapshot
}); });
} }
onBlob(blob: SnapshotterBlob): void { onEntryStarted(entry: har.Entry) {
this._blobs.set(blob.sha1, blob.buffer);
} }
onResourceSnapshot(resource: ResourceSnapshot): void { onEntryFinished(entry: har.Entry) {
this.addResource(resource); this.addResource(entry);
}
onContentBlob(sha1: string, buffer: Buffer) {
this._blobs.set(sha1, buffer);
}
onSnapshotterBlob(blob: SnapshotterBlob): void {
this._blobs.set(blob.sha1, blob.buffer);
} }
onFrameSnapshot(snapshot: FrameSnapshot): void { onFrameSnapshot(snapshot: FrameSnapshot): void {

View file

@ -14,27 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
export type ResourceSnapshot = { import { Entry as HAREntry } from '../supplements/har/har';
_frameref: string,
request: { export type ResourceSnapshot = HAREntry;
url: string,
method: string,
headers: { name: string, value: string }[],
postData?: {
text: string,
_sha1?: string,
},
},
response: {
status: number,
headers: { name: string, value: string }[],
content: {
mimeType: string,
_sha1?: string,
},
},
_monotonicTime: number,
};
export type NodeSnapshot = export type NodeSnapshot =
// Text node. // Text node.

View file

@ -16,13 +16,12 @@
import { BrowserContext } from '../browserContext'; import { BrowserContext } from '../browserContext';
import { Page } from '../page'; import { Page } from '../page';
import * as network from '../network';
import { eventsHelper, RegisteredListener } from '../../utils/eventsHelper'; import { eventsHelper, RegisteredListener } from '../../utils/eventsHelper';
import { debugLogger } from '../../utils/debugLogger'; import { debugLogger } from '../../utils/debugLogger';
import { Frame } from '../frames'; import { Frame } from '../frames';
import { frameSnapshotStreamer, SnapshotData } from './snapshotterInjected'; import { frameSnapshotStreamer, SnapshotData } from './snapshotterInjected';
import { calculateSha1, createGuid, monotonicTime } from '../../utils/utils'; import { calculateSha1, createGuid, monotonicTime } from '../../utils/utils';
import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes'; import { FrameSnapshot } from './snapshotTypes';
import { ElementHandle } from '../dom'; import { ElementHandle } from '../dom';
export type SnapshotterBlob = { export type SnapshotterBlob = {
@ -31,8 +30,7 @@ export type SnapshotterBlob = {
}; };
export interface SnapshotterDelegate { export interface SnapshotterDelegate {
onBlob(blob: SnapshotterBlob): void; onSnapshotterBlob(blob: SnapshotterBlob): void;
onResourceSnapshot(resource: ResourceSnapshot): void;
onFrameSnapshot(snapshot: FrameSnapshot): void; onFrameSnapshot(snapshot: FrameSnapshot): void;
} }
@ -43,7 +41,6 @@ export class Snapshotter {
private _snapshotStreamer: string; private _snapshotStreamer: string;
private _initialized = false; private _initialized = false;
private _started = false; private _started = false;
private _fetchedResponses = new Map<network.Response, string>();
constructor(context: BrowserContext, delegate: SnapshotterDelegate) { constructor(context: BrowserContext, delegate: SnapshotterDelegate) {
this._context = context; this._context = context;
@ -63,12 +60,6 @@ export class Snapshotter {
await this._initialize(); await this._initialize();
} }
await this.reset(); await this.reset();
// Replay resources loaded in all pages.
for (const page of this._context.pages()) {
for (const response of page._frameManager._responses)
this._saveResource(response).catch(e => debugLogger.log('error', e));
}
} }
async reset() { async reset() {
@ -85,9 +76,6 @@ export class Snapshotter {
this._onPage(page); this._onPage(page);
this._eventListeners = [ this._eventListeners = [
eventsHelper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)), eventsHelper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)),
eventsHelper.addEventListener(this._context, BrowserContext.Events.Response, (response: network.Response) => {
this._saveResource(response).catch(e => debugLogger.log('error', e));
}),
]; ];
const initScript = `(${frameSnapshotStreamer})("${this._snapshotStreamer}")`; const initScript = `(${frameSnapshotStreamer})("${this._snapshotStreamer}")`;
@ -141,7 +129,7 @@ export class Snapshotter {
if (typeof content === 'string') { if (typeof content === 'string') {
const buffer = Buffer.from(content); const buffer = Buffer.from(content);
const sha1 = calculateSha1(buffer) + mimeToExtension(contentType); const sha1 = calculateSha1(buffer) + mimeToExtension(contentType);
this._delegate.onBlob({ sha1, buffer }); this._delegate.onSnapshotterBlob({ sha1, buffer });
snapshot.resourceOverrides.push({ url, sha1 }); snapshot.resourceOverrides.push({ url, sha1 });
} else { } else {
snapshot.resourceOverrides.push({ url, ref: content }); snapshot.resourceOverrides.push({ url, ref: content });
@ -159,72 +147,6 @@ export class Snapshotter {
this._eventListeners.push(eventsHelper.addEventListener(page, Page.Events.FrameAttached, frame => this._annotateFrameHierarchy(frame))); this._eventListeners.push(eventsHelper.addEventListener(page, Page.Events.FrameAttached, frame => this._annotateFrameHierarchy(frame)));
} }
private async _saveResource(response: network.Response) {
if (!this._started)
return;
const isRedirect = response.status() >= 300 && response.status() <= 399;
if (isRedirect)
return;
// We do not need scripts for snapshots.
if (response.request().resourceType() === 'script')
return;
// Shortcut all redirects - we cannot intercept them properly.
let original = response.request();
while (original.redirectedFrom())
original = original.redirectedFrom()!;
const url = original.url();
let contentType = '';
for (const { name, value } of response.headers()) {
if (name.toLowerCase() === 'content-type')
contentType = value;
}
const method = original.method();
const status = response.status();
const requestBody = original.postDataBuffer();
const requestSha1 = requestBody ? calculateSha1(requestBody) + mimeToExtension(contentType) : '';
if (requestBody)
this._delegate.onBlob({ sha1: requestSha1, buffer: requestBody });
const requestHeaders = original.headers();
// Only fetch response bodies once.
let responseSha1 = this._fetchedResponses.get(response);
{
if (responseSha1 === undefined) {
const body = await response.body().catch(e => debugLogger.log('error', e));
// Bail out after each async hop.
if (!this._started)
return;
responseSha1 = body ? calculateSha1(body) + mimeToExtension(contentType) : '';
if (body)
this._delegate.onBlob({ sha1: responseSha1, buffer: body });
this._fetchedResponses.set(response, responseSha1);
}
}
const resource: ResourceSnapshot = {
_frameref: response.frame().guid,
request: {
url,
method,
headers: requestHeaders,
postData: requestSha1 ? { text: '', _sha1: requestSha1 } : undefined,
},
response: {
status,
headers: response.headers(),
content: {
mimeType: contentType,
_sha1: responseSha1,
},
},
_monotonicTime: monotonicTime()
};
this._delegate.onResourceSnapshot(resource);
}
private async _annotateFrameHierarchy(frame: Frame) { private async _annotateFrameHierarchy(frame: Frame) {
try { try {
const frameElement = await frame.frameElement(); const frameElement = await frame.frameElement();

View file

@ -0,0 +1,57 @@
/**
* 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 fs from 'fs';
import { BrowserContext } from '../../browserContext';
import * as har from './har';
import { HarTracer } from './harTracer';
type HarOptions = {
path: string;
omitContent?: boolean;
};
export class HarRecorder {
private _options: HarOptions;
private _tracer: HarTracer;
private _entries: har.Entry[] = [];
constructor(context: BrowserContext, options: HarOptions) {
this._options = options;
this._tracer = new HarTracer(context, this, {
content: options.omitContent ? 'omit' : 'embedded',
waitForContentOnStop: true,
skipScripts: false,
});
this._tracer.start();
}
onEntryStarted(entry: har.Entry) {
this._entries.push(entry);
}
onEntryFinished(entry: har.Entry) {
}
onContentBlob(sha1: string, buffer: Buffer) {
}
async flush() {
const log = await this._tracer.stop();
log.entries = this._entries;
await fs.promises.writeFile(this._options.path, JSON.stringify({ log }, undefined, 2));
}
}

View file

@ -15,49 +15,60 @@
*/ */
import { URL } from 'url'; import { URL } from 'url';
import fs from 'fs';
import { BrowserContext } from '../../browserContext'; import { BrowserContext } from '../../browserContext';
import { helper } from '../../helper'; import { helper } from '../../helper';
import * as network from '../../network'; import * as network from '../../network';
import { Page } from '../../page'; import { Page } from '../../page';
import * as har from './har'; import * as har from './har';
import * as types from '../../types'; import * as types from '../../types';
import { monotonicTime } from '../../../utils/utils'; import { calculateSha1, monotonicTime } from '../../../utils/utils';
import { eventsHelper, RegisteredListener } from '../../../utils/eventsHelper';
const FALLBACK_HTTP_VERSION = 'HTTP/1.1'; const FALLBACK_HTTP_VERSION = 'HTTP/1.1';
type HarOptions = { export interface HarTracerDelegate {
path: string; onEntryStarted(entry: har.Entry): void;
omitContent?: boolean; onEntryFinished(entry: har.Entry): void;
onContentBlob(sha1: string, buffer: Buffer): void;
}
type HarTracerOptions = {
content: 'omit' | 'sha1' | 'embedded';
skipScripts: boolean;
waitForContentOnStop: boolean;
}; };
export class HarTracer { export class HarTracer {
private _options: HarOptions; private _context: BrowserContext;
private _log: har.Log;
private _pageEntries = new Map<Page, har.Page>();
private _entries = new Map<network.Request, har.Entry>();
private _lastPage = 0;
private _barrierPromises = new Set<Promise<void>>(); private _barrierPromises = new Set<Promise<void>>();
private _delegate: HarTracerDelegate;
private _options: HarTracerOptions;
private _pageEntries = new Map<Page, har.Page>();
private _eventListeners: RegisteredListener[] = [];
private _started = false;
private _entrySymbol: symbol;
constructor(context: BrowserContext, options: HarOptions) { constructor(context: BrowserContext, delegate: HarTracerDelegate, options: HarTracerOptions) {
this._context = context;
this._delegate = delegate;
this._options = options; this._options = options;
this._log = { this._entrySymbol = Symbol('requestHarEntry');
version: '1.2', }
creator: {
name: 'Playwright', start() {
version: require('../../../../package.json')['version'], if (this._started)
}, return;
browser: { this._started = true;
name: context._browser.options.name, this._eventListeners = [
version: context._browser.version() eventsHelper.addEventListener(this._context, BrowserContext.Events.Page, (page: Page) => this._ensurePageEntry(page)),
}, eventsHelper.addEventListener(this._context, BrowserContext.Events.Request, (request: network.Request) => this._onRequest(request)),
pages: [], eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestFinished, (request: network.Request) => this._onRequestFinished(request).catch(() => {})),
entries: [] eventsHelper.addEventListener(this._context, BrowserContext.Events.Response, (response: network.Response) => this._onResponse(response)),
}; ];
context.on(BrowserContext.Events.Page, (page: Page) => this._ensurePageEntry(page)); }
context.on(BrowserContext.Events.Request, (request: network.Request) => this._onRequest(request));
context.on(BrowserContext.Events.RequestFinished, (request: network.Request) => this._onRequestFinished(request).catch(() => {})); private _entryForRequest(request: network.Request): har.Entry | undefined {
context.on(BrowserContext.Events.Response, (response: network.Response) => this._onResponse(response)); return (request as any)[this._entrySymbol];
} }
private _ensurePageEntry(page: Page) { private _ensurePageEntry(page: Page) {
@ -68,7 +79,7 @@ export class HarTracer {
pageEntry = { pageEntry = {
startedDateTime: new Date(), startedDateTime: new Date(),
id: `page_${this._lastPage++}`, id: page.guid,
title: '', title: '',
pageTimings: { pageTimings: {
onContentLoad: -1, onContentLoad: -1,
@ -76,7 +87,6 @@ export class HarTracer {
}, },
}; };
this._pageEntries.set(page, pageEntry); this._pageEntries.set(page, pageEntry);
this._log.pages.push(pageEntry);
} }
return pageEntry; return pageEntry;
} }
@ -110,6 +120,8 @@ export class HarTracer {
} }
private _addBarrier(page: Page, promise: Promise<void>) { private _addBarrier(page: Page, promise: Promise<void>) {
if (!this._options.waitForContentOnStop)
return;
const race = Promise.race([ const race = Promise.race([
new Promise<void>(f => page.on('close', () => { new Promise<void>(f => page.on('close', () => {
this._barrierPromises.delete(race); this._barrierPromises.delete(race);
@ -121,6 +133,9 @@ export class HarTracer {
} }
private _onRequest(request: network.Request) { private _onRequest(request: network.Request) {
if (this._options.skipScripts && request.resourceType() === 'script')
return;
const page = request.frame()._page; const page = request.frame()._page;
const url = network.parsedURL(request.url()); const url = network.parsedURL(request.url());
if (!url) if (!url)
@ -140,7 +155,7 @@ export class HarTracer {
cookies: [], cookies: [],
headers: [], headers: [],
queryString: [...url.searchParams].map(e => ({ name: e[0], value: e[1] })), queryString: [...url.searchParams].map(e => ({ name: e[0], value: e[1] })),
postData: postDataForHar(request), postData: postDataForHar(request, this._options.content),
headersSize: -1, headersSize: -1,
bodySize: calculateRequestBodySize(request) || 0, bodySize: calculateRequestBodySize(request) || 0,
}, },
@ -170,18 +185,21 @@ export class HarTracer {
}, },
}; };
if (request.redirectedFrom()) { if (request.redirectedFrom()) {
const fromEntry = this._entries.get(request.redirectedFrom()!)!; const fromEntry = this._entryForRequest(request.redirectedFrom()!);
if (fromEntry)
fromEntry.response.redirectURL = request.url(); fromEntry.response.redirectURL = request.url();
} }
this._log.entries.push(harEntry); (request as any)[this._entrySymbol] = harEntry;
this._entries.set(request, harEntry); if (this._started)
this._delegate.onEntryStarted(harEntry);
} }
private async _onRequestFinished(request: network.Request) { private async _onRequestFinished(request: network.Request) {
const page = request.frame()._page; const page = request.frame()._page;
const harEntry = this._entries.get(request)!; const harEntry = this._entryForRequest(request);
if (!harEntry)
return;
const response = await request.response(); const response = await request.response();
if (!response) if (!response)
return; return;
@ -200,25 +218,41 @@ export class HarTracer {
const content = harEntry.response.content; const content = harEntry.response.content;
content.size = buffer.length; content.size = buffer.length;
content.compression = harEntry.response.bodySize !== -1 ? buffer.length - harEntry.response.bodySize : 0; content.compression = harEntry.response.bodySize !== -1 ? buffer.length - harEntry.response.bodySize : 0;
if (buffer && buffer.length > 0) {
if (!this._options.omitContent && buffer && buffer.length > 0) { if (this._options.content === 'embedded') {
content.text = buffer.toString('base64'); content.text = buffer.toString('base64');
content.encoding = 'base64'; content.encoding = 'base64';
} else if (this._options.content === 'sha1') {
content._sha1 = calculateSha1(buffer) + mimeToExtension(content.mimeType);
if (this._started)
this._delegate.onContentBlob(content._sha1, buffer);
} }
}).catch(() => {}); }
}).catch(() => {}).then(() => {
const postData = response.request().postDataBuffer();
if (postData && harEntry.request.postData && this._options.content === 'sha1') {
harEntry.request.postData._sha1 = calculateSha1(postData) + mimeToExtension(harEntry.request.postData.mimeType);
if (this._started)
this._delegate.onContentBlob(harEntry.request.postData._sha1, postData);
}
if (this._started)
this._delegate.onEntryFinished(harEntry);
});
this._addBarrier(page, promise); this._addBarrier(page, promise);
} }
private _onResponse(response: network.Response) { private _onResponse(response: network.Response) {
const page = response.frame()._page; const page = response.frame()._page;
const pageEntry = this._ensurePageEntry(page); const pageEntry = this._ensurePageEntry(page);
const harEntry = this._entries.get(response.request())!; const harEntry = this._entryForRequest(response.request());
// Rewrite provisional headers with actual if (!harEntry)
return;
const request = response.request(); const request = response.request();
// Rewrite provisional headers with actual
harEntry.request.headers = request.headers().map(header => ({ name: header.name, value: header.value })); harEntry.request.headers = request.headers().map(header => ({ name: header.name, value: header.value }));
harEntry.request.cookies = cookiesForHar(request.headerValue('cookie'), ';'); harEntry.request.cookies = cookiesForHar(request.headerValue('cookie'), ';');
harEntry.request.postData = postDataForHar(request); harEntry.request.postData = postDataForHar(request, this._options.content);
harEntry.response = { harEntry.response = {
status: response.status(), status: response.status(),
@ -252,7 +286,6 @@ export class HarTracer {
receive, receive,
}; };
harEntry.time = [dns, connect, ssl, wait, receive].reduce((pre, cur) => cur > 0 ? cur + pre : pre, 0); harEntry.time = [dns, connect, ssl, wait, receive].reduce((pre, cur) => cur > 0 ? cur + pre : pre, 0);
this._addBarrier(page, response.serverAddr().then(server => { this._addBarrier(page, response.serverAddr().then(server => {
if (server?.ipAddress) if (server?.ipAddress)
harEntry.serverIPAddress = server.ipAddress; harEntry.serverIPAddress = server.ipAddress;
@ -265,9 +298,27 @@ export class HarTracer {
})); }));
} }
async flush() { async stop() {
this._started = false;
eventsHelper.removeEventListeners(this._eventListeners);
await Promise.all(this._barrierPromises); await Promise.all(this._barrierPromises);
for (const pageEntry of this._log.pages) { this._barrierPromises.clear();
const log: har.Log = {
version: '1.2',
creator: {
name: 'Playwright',
version: require('../../../../package.json')['version'],
},
browser: {
name: this._context._browser.options.name,
version: this._context._browser.version()
},
pages: Array.from(this._pageEntries.values()),
entries: [],
};
for (const pageEntry of log.pages) {
if (pageEntry.pageTimings.onContentLoad >= 0) if (pageEntry.pageTimings.onContentLoad >= 0)
pageEntry.pageTimings.onContentLoad -= pageEntry.startedDateTime.valueOf(); pageEntry.pageTimings.onContentLoad -= pageEntry.startedDateTime.valueOf();
else else
@ -277,11 +328,12 @@ export class HarTracer {
else else
pageEntry.pageTimings.onLoad = -1; pageEntry.pageTimings.onLoad = -1;
} }
await fs.promises.writeFile(this._options.path, JSON.stringify({ log: this._log }, undefined, 2)); this._pageEntries.clear();
return log;
} }
} }
function postDataForHar(request: network.Request): har.PostData | undefined { function postDataForHar(request: network.Request, content: 'omit' | 'sha1' | 'embedded'): har.PostData | undefined {
const postData = request.postDataBuffer(); const postData = request.postDataBuffer();
if (!postData) if (!postData)
return; return;
@ -289,9 +341,13 @@ function postDataForHar(request: network.Request): har.PostData | undefined {
const contentType = request.headerValue('content-type') || 'application/octet-stream'; const contentType = request.headerValue('content-type') || 'application/octet-stream';
const result: har.PostData = { const result: har.PostData = {
mimeType: contentType, mimeType: contentType,
text: contentType === 'application/octet-stream' ? '' : postData.toString(), text: '',
params: [] params: []
}; };
if (content === 'embedded' && contentType !== 'application/octet-stream')
result.text = postData.toString();
if (contentType === 'application/x-www-form-urlencoded') { if (contentType === 'application/x-www-form-urlencoded') {
const parsed = new URLSearchParams(postData.toString()); const parsed = new URLSearchParams(postData.toString());
for (const [name, value] of parsed.entries()) for (const [name, value] of parsed.entries())
@ -370,3 +426,31 @@ function calculateRequestBodySize(request: network.Request): number|undefined {
return; return;
return new TextEncoder().encode(postData.toString('utf8')).length; return new TextEncoder().encode(postData.toString('utf8')).length;
} }
const kMimeToExtension: { [key: string]: string } = {
'application/javascript': 'js',
'application/json': 'json',
'application/json5': 'json5',
'application/pdf': 'pdf',
'application/xhtml+xml': 'xhtml',
'application/zip': 'zip',
'font/otf': 'otf',
'font/woff': 'woff',
'font/woff2': 'woff2',
'image/bmp': 'bmp',
'image/gif': 'gif',
'image/jpeg': 'jpeg',
'image/png': 'png',
'image/tiff': 'tiff',
'image/svg+xml': 'svg',
'text/css': 'css',
'text/csv': 'csv',
'text/html': 'html',
'text/plain': 'text',
'video/mp4': 'mp4',
'video/mpeg': 'mpeg',
};
function mimeToExtension(contentType: string): string {
return '.' + (kMimeToExtension[contentType] || 'dat');
}

View file

@ -28,7 +28,9 @@ import { Page } from '../../page';
import * as trace from '../common/traceEvents'; import * as trace from '../common/traceEvents';
import { commandsWithTracingSnapshots } from '../../../protocol/channels'; import { commandsWithTracingSnapshots } from '../../../protocol/channels';
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from '../../snapshot/snapshotter'; import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from '../../snapshot/snapshotter';
import { FrameSnapshot, ResourceSnapshot } from '../../snapshot/snapshotTypes'; import { FrameSnapshot } from '../../snapshot/snapshotTypes';
import { HarTracer, HarTracerDelegate } from '../../supplements/har/harTracer';
import * as har from '../../supplements/har/har';
export type TracerOptions = { export type TracerOptions = {
name?: string; name?: string;
@ -49,9 +51,10 @@ type RecordingState = {
const kScreencastOptions = { width: 800, height: 600, quality: 90 }; const kScreencastOptions = { width: 800, height: 600, quality: 90 };
export class Tracing implements InstrumentationListener, SnapshotterDelegate { export class Tracing implements InstrumentationListener, SnapshotterDelegate, HarTracerDelegate {
private _writeChain = Promise.resolve(); private _writeChain = Promise.resolve();
private _snapshotter: Snapshotter; private _snapshotter: Snapshotter;
private _harTracer: HarTracer;
private _screencastListeners: RegisteredListener[] = []; private _screencastListeners: RegisteredListener[] = [];
private _pendingCalls = new Map<string, { sdkObject: SdkObject, metadata: CallMetadata, beforeSnapshot: Promise<void>, actionSnapshot?: Promise<void>, afterSnapshot?: Promise<void> }>(); private _pendingCalls = new Map<string, { sdkObject: SdkObject, metadata: CallMetadata, beforeSnapshot: Promise<void>, actionSnapshot?: Promise<void>, afterSnapshot?: Promise<void> }>();
private _context: BrowserContext; private _context: BrowserContext;
@ -67,6 +70,11 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate {
this._tracesDir = context._browser.options.tracesDir; this._tracesDir = context._browser.options.tracesDir;
this._resourcesDir = path.join(this._tracesDir, 'resources'); this._resourcesDir = path.join(this._tracesDir, 'resources');
this._snapshotter = new Snapshotter(context, this); this._snapshotter = new Snapshotter(context, this);
this._harTracer = new HarTracer(context, this, {
content: 'sha1',
waitForContentOnStop: false,
skipScripts: true,
});
this._contextCreatedEvent = { this._contextCreatedEvent = {
version: VERSION, version: VERSION,
type: 'context-options', type: 'context-options',
@ -109,8 +117,10 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate {
await this._snapshotter.reset(); await this._snapshotter.reset();
} else if (options.snapshots) { } else if (options.snapshots) {
await this._snapshotter.start(); await this._snapshotter.start();
this._harTracer.start();
} else if (state?.options?.snapshots) { } else if (state?.options?.snapshots) {
await this._snapshotter.stop(); await this._snapshotter.stop();
await this._harTracer.stop();
} }
if (state) { if (state) {
@ -145,6 +155,7 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate {
this._context.instrumentation.removeListener(this); this._context.instrumentation.removeListener(this);
this._stopScreencast(); this._stopScreencast();
await this._snapshotter.stop(); await this._snapshotter.stop();
await this._harTracer.stop();
// Ensure all writes are finished. // Ensure all writes are finished.
await this._writeChain; await this._writeChain;
this._recording = undefined; this._recording = undefined;
@ -243,18 +254,25 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate {
this._appendTraceEvent(event); this._appendTraceEvent(event);
} }
onBlob(blob: SnapshotterBlob): void { onEntryStarted(entry: har.Entry) {
this._appendResource(blob.sha1, blob.buffer);
} }
onResourceSnapshot(snapshot: ResourceSnapshot): void { onEntryFinished(entry: har.Entry) {
const event: trace.ResourceSnapshotTraceEvent = { type: 'resource-snapshot', snapshot }; const event: trace.ResourceSnapshotTraceEvent = { type: 'resource-snapshot', snapshot: entry };
this._appendTraceOperation(async () => { this._appendTraceOperation(async () => {
visitSha1s(event, this._recording!.sha1s); visitSha1s(event, this._recording!.sha1s);
await fs.promises.appendFile(this._recording!.networkFile, JSON.stringify(event) + '\n'); await fs.promises.appendFile(this._recording!.networkFile, JSON.stringify(event) + '\n');
}); });
} }
onContentBlob(sha1: string, buffer: Buffer) {
this._appendResource(sha1, buffer);
}
onSnapshotterBlob(blob: SnapshotterBlob): void {
this._appendResource(blob.sha1, blob.buffer);
}
onFrameSnapshot(snapshot: FrameSnapshot): void { onFrameSnapshot(snapshot: FrameSnapshot): void {
this._appendTraceEvent({ type: 'frame-snapshot', snapshot }); this._appendTraceEvent({ type: 'frame-snapshot', snapshot });
} }

View file

@ -21,6 +21,7 @@ import fs from 'fs';
import http2 from 'http2'; import http2 from 'http2';
import type { BrowserContext, BrowserContextOptions } from '../index'; import type { BrowserContext, BrowserContextOptions } from '../index';
import type { AddressInfo } from 'net'; import type { AddressInfo } from 'net';
import type { Log } from '../src/server/supplements/har/har';
async function pageWithHar(contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>, testInfo: any) { async function pageWithHar(contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>, testInfo: any) {
const harPath = testInfo.outputPath('test.har'); const harPath = testInfo.outputPath('test.har');
@ -31,7 +32,7 @@ async function pageWithHar(contextFactory: (options?: BrowserContextOptions) =>
context, context,
getLog: async () => { getLog: async () => {
await context.close(); await context.close();
return JSON.parse(fs.readFileSync(harPath).toString())['log']; return JSON.parse(fs.readFileSync(harPath).toString())['log'] as Log;
} }
}; };
} }
@ -66,7 +67,7 @@ it('should have pages', async ({ contextFactory, server }, testInfo) => {
const log = await getLog(); const log = await getLog();
expect(log.pages.length).toBe(1); expect(log.pages.length).toBe(1);
const pageEntry = log.pages[0]; const pageEntry = log.pages[0];
expect(pageEntry.id).toBe('page_0'); expect(pageEntry.id).toBeTruthy();
expect(pageEntry.title).toBe('Hello'); expect(pageEntry.title).toBe('Hello');
expect(new Date(pageEntry.startedDateTime).valueOf()).toBeGreaterThan(Date.now() - 3600 * 1000); expect(new Date(pageEntry.startedDateTime).valueOf()).toBeGreaterThan(Date.now() - 3600 * 1000);
expect(pageEntry.pageTimings.onContentLoad).toBeGreaterThan(0); expect(pageEntry.pageTimings.onContentLoad).toBeGreaterThan(0);
@ -83,7 +84,7 @@ it('should have pages in persistent context', async ({ launchPersistent }, testI
const log = JSON.parse(fs.readFileSync(harPath).toString())['log']; const log = JSON.parse(fs.readFileSync(harPath).toString())['log'];
expect(log.pages.length).toBe(1); expect(log.pages.length).toBe(1);
const pageEntry = log.pages[0]; const pageEntry = log.pages[0];
expect(pageEntry.id).toBe('page_0'); expect(pageEntry.id).toBeTruthy();
expect(pageEntry.title).toBe('Hello'); expect(pageEntry.title).toBe('Hello');
}); });
@ -93,7 +94,7 @@ it('should include request', async ({ contextFactory, server }, testInfo) => {
const log = await getLog(); const log = await getLog();
expect(log.entries.length).toBe(1); expect(log.entries.length).toBe(1);
const entry = log.entries[0]; const entry = log.entries[0];
expect(entry.pageref).toBe('page_0'); expect(entry.pageref).toBe(log.pages[0].id);
expect(entry.request.url).toBe(server.EMPTY_PAGE); expect(entry.request.url).toBe(server.EMPTY_PAGE);
expect(entry.request.method).toBe('GET'); expect(entry.request.method).toBe('GET');
expect(entry.request.httpVersion).toBe('HTTP/1.1'); expect(entry.request.httpVersion).toBe('HTTP/1.1');
@ -339,10 +340,8 @@ it('should have popup requests', async ({ contextFactory, server }, testInfo) =>
const log = await getLog(); const log = await getLog();
expect(log.pages.length).toBe(2); expect(log.pages.length).toBe(2);
expect(log.pages[0].id).toBe('page_0');
expect(log.pages[1].id).toBe('page_1');
const entries = log.entries.filter(entry => entry.pageref === 'page_1'); const entries = log.entries.filter(entry => entry.pageref === log.pages[1].id);
expect(entries.length).toBe(2); expect(entries.length).toBe(2);
expect(entries[0].request.url).toBe(server.PREFIX + '/one-style.html'); expect(entries[0].request.url).toBe(server.PREFIX + '/one-style.html');
expect(entries[0].response.status).toBe(200); expect(entries[0].response.status).toBe(200);