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:
parent
cccc2ac4bc
commit
29eb6cb777
|
|
@ -31,7 +31,7 @@ import path from 'path';
|
|||
import { CallMetadata, internalCallMetadata, createInstrumentation, SdkObject } from './instrumentation';
|
||||
import { Debugger } from './supplements/debugger';
|
||||
import { Tracing } from './trace/recorder/tracing';
|
||||
import { HarTracer } from './supplements/har/harTracer';
|
||||
import { HarRecorder } from './supplements/har/harRecorder';
|
||||
import { RecorderSupplement } from './supplements/recorderSupplement';
|
||||
import * as consoleApiSource from '../generated/consoleApiSource';
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ export abstract class BrowserContext extends SdkObject {
|
|||
readonly _browserContextId: string | undefined;
|
||||
private _selectors?: Selectors;
|
||||
private _origins = new Set<string>();
|
||||
private _harTracer: HarTracer | undefined;
|
||||
private _harRecorder: HarRecorder | undefined;
|
||||
readonly tracing: Tracing;
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -272,7 +272,7 @@ export abstract class BrowserContext extends SdkObject {
|
|||
this.emit(BrowserContext.Events.BeforeClose);
|
||||
this._closedStatus = 'closing';
|
||||
|
||||
await this._harTracer?.flush();
|
||||
await this._harRecorder?.flush();
|
||||
await this.tracing.dispose();
|
||||
|
||||
// Cleanup.
|
||||
|
|
|
|||
|
|
@ -18,37 +18,45 @@ import { HttpServer } from '../../utils/httpServer';
|
|||
import { BrowserContext } from '../browserContext';
|
||||
import { eventsHelper } from '../../utils/eventsHelper';
|
||||
import { Page } from '../page';
|
||||
import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes';
|
||||
import { FrameSnapshot } from './snapshotTypes';
|
||||
import { SnapshotRenderer } from './snapshotRenderer';
|
||||
import { SnapshotServer } from './snapshotServer';
|
||||
import { BaseSnapshotStorage } from './snapshotStorage';
|
||||
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from './snapshotter';
|
||||
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 _server: HttpServer;
|
||||
private _snapshotter: Snapshotter;
|
||||
private _harTracer: HarTracer;
|
||||
|
||||
constructor(context: BrowserContext) {
|
||||
super();
|
||||
this._server = new HttpServer();
|
||||
new SnapshotServer(this._server, this);
|
||||
this._snapshotter = new Snapshotter(context, this);
|
||||
this._harTracer = new HarTracer(context, this, { content: 'sha1', waitForContentOnStop: false, skipScripts: true });
|
||||
}
|
||||
|
||||
async initialize(): Promise<string> {
|
||||
await this._snapshotter.start();
|
||||
this._harTracer.start();
|
||||
return await this._server.start();
|
||||
}
|
||||
|
||||
async reset() {
|
||||
await this._snapshotter.reset();
|
||||
await this._harTracer.stop();
|
||||
this._harTracer.start();
|
||||
this.clear();
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
this._snapshotter.dispose();
|
||||
await this._harTracer.stop();
|
||||
await this._server.stop();
|
||||
}
|
||||
|
||||
|
|
@ -67,12 +75,19 @@ export class InMemorySnapshotter extends BaseSnapshotStorage implements Snapshot
|
|||
});
|
||||
}
|
||||
|
||||
onBlob(blob: SnapshotterBlob): void {
|
||||
this._blobs.set(blob.sha1, blob.buffer);
|
||||
onEntryStarted(entry: har.Entry) {
|
||||
}
|
||||
|
||||
onResourceSnapshot(resource: ResourceSnapshot): void {
|
||||
this.addResource(resource);
|
||||
onEntryFinished(entry: har.Entry) {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -14,27 +14,9 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type ResourceSnapshot = {
|
||||
_frameref: string,
|
||||
request: {
|
||||
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,
|
||||
};
|
||||
import { Entry as HAREntry } from '../supplements/har/har';
|
||||
|
||||
export type ResourceSnapshot = HAREntry;
|
||||
|
||||
export type NodeSnapshot =
|
||||
// Text node.
|
||||
|
|
|
|||
|
|
@ -16,13 +16,12 @@
|
|||
|
||||
import { BrowserContext } from '../browserContext';
|
||||
import { Page } from '../page';
|
||||
import * as network from '../network';
|
||||
import { eventsHelper, RegisteredListener } from '../../utils/eventsHelper';
|
||||
import { debugLogger } from '../../utils/debugLogger';
|
||||
import { Frame } from '../frames';
|
||||
import { frameSnapshotStreamer, SnapshotData } from './snapshotterInjected';
|
||||
import { calculateSha1, createGuid, monotonicTime } from '../../utils/utils';
|
||||
import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes';
|
||||
import { FrameSnapshot } from './snapshotTypes';
|
||||
import { ElementHandle } from '../dom';
|
||||
|
||||
export type SnapshotterBlob = {
|
||||
|
|
@ -31,8 +30,7 @@ export type SnapshotterBlob = {
|
|||
};
|
||||
|
||||
export interface SnapshotterDelegate {
|
||||
onBlob(blob: SnapshotterBlob): void;
|
||||
onResourceSnapshot(resource: ResourceSnapshot): void;
|
||||
onSnapshotterBlob(blob: SnapshotterBlob): void;
|
||||
onFrameSnapshot(snapshot: FrameSnapshot): void;
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +41,6 @@ export class Snapshotter {
|
|||
private _snapshotStreamer: string;
|
||||
private _initialized = false;
|
||||
private _started = false;
|
||||
private _fetchedResponses = new Map<network.Response, string>();
|
||||
|
||||
constructor(context: BrowserContext, delegate: SnapshotterDelegate) {
|
||||
this._context = context;
|
||||
|
|
@ -63,12 +60,6 @@ export class Snapshotter {
|
|||
await this._initialize();
|
||||
}
|
||||
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() {
|
||||
|
|
@ -85,9 +76,6 @@ export class Snapshotter {
|
|||
this._onPage(page);
|
||||
this._eventListeners = [
|
||||
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}")`;
|
||||
|
|
@ -141,7 +129,7 @@ export class Snapshotter {
|
|||
if (typeof content === 'string') {
|
||||
const buffer = Buffer.from(content);
|
||||
const sha1 = calculateSha1(buffer) + mimeToExtension(contentType);
|
||||
this._delegate.onBlob({ sha1, buffer });
|
||||
this._delegate.onSnapshotterBlob({ sha1, buffer });
|
||||
snapshot.resourceOverrides.push({ url, sha1 });
|
||||
} else {
|
||||
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)));
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
const frameElement = await frame.frameElement();
|
||||
|
|
|
|||
57
src/server/supplements/har/harRecorder.ts
Normal file
57
src/server/supplements/har/harRecorder.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -15,49 +15,60 @@
|
|||
*/
|
||||
|
||||
import { URL } from 'url';
|
||||
import fs from 'fs';
|
||||
import { BrowserContext } from '../../browserContext';
|
||||
import { helper } from '../../helper';
|
||||
import * as network from '../../network';
|
||||
import { Page } from '../../page';
|
||||
import * as har from './har';
|
||||
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';
|
||||
|
||||
type HarOptions = {
|
||||
path: string;
|
||||
omitContent?: boolean;
|
||||
export interface HarTracerDelegate {
|
||||
onEntryStarted(entry: har.Entry): void;
|
||||
onEntryFinished(entry: har.Entry): void;
|
||||
onContentBlob(sha1: string, buffer: Buffer): void;
|
||||
}
|
||||
|
||||
type HarTracerOptions = {
|
||||
content: 'omit' | 'sha1' | 'embedded';
|
||||
skipScripts: boolean;
|
||||
waitForContentOnStop: boolean;
|
||||
};
|
||||
|
||||
export class HarTracer {
|
||||
private _options: HarOptions;
|
||||
private _log: har.Log;
|
||||
private _pageEntries = new Map<Page, har.Page>();
|
||||
private _entries = new Map<network.Request, har.Entry>();
|
||||
private _lastPage = 0;
|
||||
private _context: BrowserContext;
|
||||
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._log = {
|
||||
version: '1.2',
|
||||
creator: {
|
||||
name: 'Playwright',
|
||||
version: require('../../../../package.json')['version'],
|
||||
},
|
||||
browser: {
|
||||
name: context._browser.options.name,
|
||||
version: context._browser.version()
|
||||
},
|
||||
pages: [],
|
||||
entries: []
|
||||
};
|
||||
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(() => {}));
|
||||
context.on(BrowserContext.Events.Response, (response: network.Response) => this._onResponse(response));
|
||||
this._entrySymbol = Symbol('requestHarEntry');
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this._started)
|
||||
return;
|
||||
this._started = true;
|
||||
this._eventListeners = [
|
||||
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)),
|
||||
eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestFinished, (request: network.Request) => this._onRequestFinished(request).catch(() => {})),
|
||||
eventsHelper.addEventListener(this._context, BrowserContext.Events.Response, (response: network.Response) => this._onResponse(response)),
|
||||
];
|
||||
}
|
||||
|
||||
private _entryForRequest(request: network.Request): har.Entry | undefined {
|
||||
return (request as any)[this._entrySymbol];
|
||||
}
|
||||
|
||||
private _ensurePageEntry(page: Page) {
|
||||
|
|
@ -68,7 +79,7 @@ export class HarTracer {
|
|||
|
||||
pageEntry = {
|
||||
startedDateTime: new Date(),
|
||||
id: `page_${this._lastPage++}`,
|
||||
id: page.guid,
|
||||
title: '',
|
||||
pageTimings: {
|
||||
onContentLoad: -1,
|
||||
|
|
@ -76,7 +87,6 @@ export class HarTracer {
|
|||
},
|
||||
};
|
||||
this._pageEntries.set(page, pageEntry);
|
||||
this._log.pages.push(pageEntry);
|
||||
}
|
||||
return pageEntry;
|
||||
}
|
||||
|
|
@ -110,6 +120,8 @@ export class HarTracer {
|
|||
}
|
||||
|
||||
private _addBarrier(page: Page, promise: Promise<void>) {
|
||||
if (!this._options.waitForContentOnStop)
|
||||
return;
|
||||
const race = Promise.race([
|
||||
new Promise<void>(f => page.on('close', () => {
|
||||
this._barrierPromises.delete(race);
|
||||
|
|
@ -121,6 +133,9 @@ export class HarTracer {
|
|||
}
|
||||
|
||||
private _onRequest(request: network.Request) {
|
||||
if (this._options.skipScripts && request.resourceType() === 'script')
|
||||
return;
|
||||
|
||||
const page = request.frame()._page;
|
||||
const url = network.parsedURL(request.url());
|
||||
if (!url)
|
||||
|
|
@ -140,7 +155,7 @@ export class HarTracer {
|
|||
cookies: [],
|
||||
headers: [],
|
||||
queryString: [...url.searchParams].map(e => ({ name: e[0], value: e[1] })),
|
||||
postData: postDataForHar(request),
|
||||
postData: postDataForHar(request, this._options.content),
|
||||
headersSize: -1,
|
||||
bodySize: calculateRequestBodySize(request) || 0,
|
||||
},
|
||||
|
|
@ -170,18 +185,21 @@ export class HarTracer {
|
|||
},
|
||||
};
|
||||
if (request.redirectedFrom()) {
|
||||
const fromEntry = this._entries.get(request.redirectedFrom()!)!;
|
||||
fromEntry.response.redirectURL = request.url();
|
||||
const fromEntry = this._entryForRequest(request.redirectedFrom()!);
|
||||
if (fromEntry)
|
||||
fromEntry.response.redirectURL = request.url();
|
||||
}
|
||||
this._log.entries.push(harEntry);
|
||||
this._entries.set(request, harEntry);
|
||||
(request as any)[this._entrySymbol] = harEntry;
|
||||
if (this._started)
|
||||
this._delegate.onEntryStarted(harEntry);
|
||||
}
|
||||
|
||||
private async _onRequestFinished(request: network.Request) {
|
||||
const page = request.frame()._page;
|
||||
const harEntry = this._entries.get(request)!;
|
||||
const harEntry = this._entryForRequest(request);
|
||||
if (!harEntry)
|
||||
return;
|
||||
const response = await request.response();
|
||||
|
||||
if (!response)
|
||||
return;
|
||||
|
||||
|
|
@ -200,25 +218,41 @@ export class HarTracer {
|
|||
const content = harEntry.response.content;
|
||||
content.size = buffer.length;
|
||||
content.compression = harEntry.response.bodySize !== -1 ? buffer.length - harEntry.response.bodySize : 0;
|
||||
|
||||
if (!this._options.omitContent && buffer && buffer.length > 0) {
|
||||
content.text = buffer.toString('base64');
|
||||
content.encoding = 'base64';
|
||||
if (buffer && buffer.length > 0) {
|
||||
if (this._options.content === 'embedded') {
|
||||
content.text = buffer.toString('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);
|
||||
}
|
||||
|
||||
private _onResponse(response: network.Response) {
|
||||
const page = response.frame()._page;
|
||||
const pageEntry = this._ensurePageEntry(page);
|
||||
const harEntry = this._entries.get(response.request())!;
|
||||
// Rewrite provisional headers with actual
|
||||
const harEntry = this._entryForRequest(response.request());
|
||||
if (!harEntry)
|
||||
return;
|
||||
const request = response.request();
|
||||
|
||||
// Rewrite provisional headers with actual
|
||||
harEntry.request.headers = request.headers().map(header => ({ name: header.name, value: header.value }));
|
||||
harEntry.request.cookies = cookiesForHar(request.headerValue('cookie'), ';');
|
||||
harEntry.request.postData = postDataForHar(request);
|
||||
harEntry.request.postData = postDataForHar(request, this._options.content);
|
||||
|
||||
harEntry.response = {
|
||||
status: response.status(),
|
||||
|
|
@ -252,7 +286,6 @@ export class HarTracer {
|
|||
receive,
|
||||
};
|
||||
harEntry.time = [dns, connect, ssl, wait, receive].reduce((pre, cur) => cur > 0 ? cur + pre : pre, 0);
|
||||
|
||||
this._addBarrier(page, response.serverAddr().then(server => {
|
||||
if (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);
|
||||
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)
|
||||
pageEntry.pageTimings.onContentLoad -= pageEntry.startedDateTime.valueOf();
|
||||
else
|
||||
|
|
@ -277,11 +328,12 @@ export class HarTracer {
|
|||
else
|
||||
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();
|
||||
if (!postData)
|
||||
return;
|
||||
|
|
@ -289,9 +341,13 @@ function postDataForHar(request: network.Request): har.PostData | undefined {
|
|||
const contentType = request.headerValue('content-type') || 'application/octet-stream';
|
||||
const result: har.PostData = {
|
||||
mimeType: contentType,
|
||||
text: contentType === 'application/octet-stream' ? '' : postData.toString(),
|
||||
text: '',
|
||||
params: []
|
||||
};
|
||||
|
||||
if (content === 'embedded' && contentType !== 'application/octet-stream')
|
||||
result.text = postData.toString();
|
||||
|
||||
if (contentType === 'application/x-www-form-urlencoded') {
|
||||
const parsed = new URLSearchParams(postData.toString());
|
||||
for (const [name, value] of parsed.entries())
|
||||
|
|
@ -370,3 +426,31 @@ function calculateRequestBodySize(request: network.Request): number|undefined {
|
|||
return;
|
||||
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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ import { Page } from '../../page';
|
|||
import * as trace from '../common/traceEvents';
|
||||
import { commandsWithTracingSnapshots } from '../../../protocol/channels';
|
||||
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 = {
|
||||
name?: string;
|
||||
|
|
@ -49,9 +51,10 @@ type RecordingState = {
|
|||
|
||||
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 _snapshotter: Snapshotter;
|
||||
private _harTracer: HarTracer;
|
||||
private _screencastListeners: RegisteredListener[] = [];
|
||||
private _pendingCalls = new Map<string, { sdkObject: SdkObject, metadata: CallMetadata, beforeSnapshot: Promise<void>, actionSnapshot?: Promise<void>, afterSnapshot?: Promise<void> }>();
|
||||
private _context: BrowserContext;
|
||||
|
|
@ -67,6 +70,11 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate {
|
|||
this._tracesDir = context._browser.options.tracesDir;
|
||||
this._resourcesDir = path.join(this._tracesDir, 'resources');
|
||||
this._snapshotter = new Snapshotter(context, this);
|
||||
this._harTracer = new HarTracer(context, this, {
|
||||
content: 'sha1',
|
||||
waitForContentOnStop: false,
|
||||
skipScripts: true,
|
||||
});
|
||||
this._contextCreatedEvent = {
|
||||
version: VERSION,
|
||||
type: 'context-options',
|
||||
|
|
@ -109,8 +117,10 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate {
|
|||
await this._snapshotter.reset();
|
||||
} else if (options.snapshots) {
|
||||
await this._snapshotter.start();
|
||||
this._harTracer.start();
|
||||
} else if (state?.options?.snapshots) {
|
||||
await this._snapshotter.stop();
|
||||
await this._harTracer.stop();
|
||||
}
|
||||
|
||||
if (state) {
|
||||
|
|
@ -145,6 +155,7 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate {
|
|||
this._context.instrumentation.removeListener(this);
|
||||
this._stopScreencast();
|
||||
await this._snapshotter.stop();
|
||||
await this._harTracer.stop();
|
||||
// Ensure all writes are finished.
|
||||
await this._writeChain;
|
||||
this._recording = undefined;
|
||||
|
|
@ -243,18 +254,25 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate {
|
|||
this._appendTraceEvent(event);
|
||||
}
|
||||
|
||||
onBlob(blob: SnapshotterBlob): void {
|
||||
this._appendResource(blob.sha1, blob.buffer);
|
||||
onEntryStarted(entry: har.Entry) {
|
||||
}
|
||||
|
||||
onResourceSnapshot(snapshot: ResourceSnapshot): void {
|
||||
const event: trace.ResourceSnapshotTraceEvent = { type: 'resource-snapshot', snapshot };
|
||||
onEntryFinished(entry: har.Entry) {
|
||||
const event: trace.ResourceSnapshotTraceEvent = { type: 'resource-snapshot', snapshot: entry };
|
||||
this._appendTraceOperation(async () => {
|
||||
visitSha1s(event, this._recording!.sha1s);
|
||||
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 {
|
||||
this._appendTraceEvent({ type: 'frame-snapshot', snapshot });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import fs from 'fs';
|
|||
import http2 from 'http2';
|
||||
import type { BrowserContext, BrowserContextOptions } from '../index';
|
||||
import type { AddressInfo } from 'net';
|
||||
import type { Log } from '../src/server/supplements/har/har';
|
||||
|
||||
async function pageWithHar(contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>, testInfo: any) {
|
||||
const harPath = testInfo.outputPath('test.har');
|
||||
|
|
@ -31,7 +32,7 @@ async function pageWithHar(contextFactory: (options?: BrowserContextOptions) =>
|
|||
context,
|
||||
getLog: async () => {
|
||||
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();
|
||||
expect(log.pages.length).toBe(1);
|
||||
const pageEntry = log.pages[0];
|
||||
expect(pageEntry.id).toBe('page_0');
|
||||
expect(pageEntry.id).toBeTruthy();
|
||||
expect(pageEntry.title).toBe('Hello');
|
||||
expect(new Date(pageEntry.startedDateTime).valueOf()).toBeGreaterThan(Date.now() - 3600 * 1000);
|
||||
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'];
|
||||
expect(log.pages.length).toBe(1);
|
||||
const pageEntry = log.pages[0];
|
||||
expect(pageEntry.id).toBe('page_0');
|
||||
expect(pageEntry.id).toBeTruthy();
|
||||
expect(pageEntry.title).toBe('Hello');
|
||||
});
|
||||
|
||||
|
|
@ -93,7 +94,7 @@ it('should include request', async ({ contextFactory, server }, testInfo) => {
|
|||
const log = await getLog();
|
||||
expect(log.entries.length).toBe(1);
|
||||
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.method).toBe('GET');
|
||||
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();
|
||||
|
||||
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[0].request.url).toBe(server.PREFIX + '/one-style.html');
|
||||
expect(entries[0].response.status).toBe(200);
|
||||
|
|
|
|||
Loading…
Reference in a new issue