diff --git a/src/server/browserContext.ts b/src/server/browserContext.ts index 2fb49fe6d8..8dc5f97d2d 100644 --- a/src/server/browserContext.ts +++ b/src/server/browserContext.ts @@ -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(); - 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. diff --git a/src/server/snapshot/inMemorySnapshotter.ts b/src/server/snapshot/inMemorySnapshotter.ts index a7be11213f..c64b2d1f69 100644 --- a/src/server/snapshot/inMemorySnapshotter.ts +++ b/src/server/snapshot/inMemorySnapshotter.ts @@ -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(); 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 { 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 { diff --git a/src/server/snapshot/snapshotTypes.ts b/src/server/snapshot/snapshotTypes.ts index 5dcc526ed1..040997917d 100644 --- a/src/server/snapshot/snapshotTypes.ts +++ b/src/server/snapshot/snapshotTypes.ts @@ -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. diff --git a/src/server/snapshot/snapshotter.ts b/src/server/snapshot/snapshotter.ts index 5d45c05d21..c31a545432 100644 --- a/src/server/snapshot/snapshotter.ts +++ b/src/server/snapshot/snapshotter.ts @@ -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(); 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(); diff --git a/src/server/supplements/har/harRecorder.ts b/src/server/supplements/har/harRecorder.ts new file mode 100644 index 0000000000..c903efc343 --- /dev/null +++ b/src/server/supplements/har/harRecorder.ts @@ -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)); + } +} diff --git a/src/server/supplements/har/harTracer.ts b/src/server/supplements/har/harTracer.ts index 5f1ddfc1e0..3f8524fc36 100644 --- a/src/server/supplements/har/harTracer.ts +++ b/src/server/supplements/har/harTracer.ts @@ -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(); - private _entries = new Map(); - private _lastPage = 0; + private _context: BrowserContext; private _barrierPromises = new Set>(); + private _delegate: HarTracerDelegate; + private _options: HarTracerOptions; + private _pageEntries = new Map(); + 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) { + if (!this._options.waitForContentOnStop) + return; const race = Promise.race([ new Promise(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'); +} diff --git a/src/server/trace/recorder/tracing.ts b/src/server/trace/recorder/tracing.ts index 00e53a32ef..86058bb2f0 100644 --- a/src/server/trace/recorder/tracing.ts +++ b/src/server/trace/recorder/tracing.ts @@ -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, actionSnapshot?: Promise, afterSnapshot?: Promise }>(); 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 }); } diff --git a/tests/har.spec.ts b/tests/har.spec.ts index 9ee3747097..42c6320e31 100644 --- a/tests/har.spec.ts +++ b/tests/har.spec.ts @@ -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, 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);