diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 5c143e9c7e..0d5a2ba234 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -563,6 +563,7 @@ Logger sink for Playwright logging. `false`. Deprecated, use `content` policy instead. - `content` ?<[HarContentPolicy]<"omit"|"embed"|"attach">> Optional setting to control resource content management. If `omit` is specified, content is not persisted. If `attach` is specified, resources are persistet as separate files and all of these files are archived along with the HAR file. Defaults to `embed`, which stores content inline the HAR file as per HAR specification. - `path` <[path]> Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `attach` mode is used by default. + - `mode` ?<[HarMode]<"full"|"minimal">> When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. - `urlFilter` ?<[string]|[RegExp]> A glob or regex pattern to filter requests that are stored in the HAR. When a [`option: baseURL`] via the context options was provided and the passed URL is a path, it gets merged via the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. Enables [HAR](http://www.softwareishard.com/blog/har-12-spec) recording for all pages into `recordHar.path` file. If not diff --git a/packages/playwright-core/src/cli/cli.ts b/packages/playwright-core/src/cli/cli.ts index d5c6278a9b..53fe63e627 100755 --- a/packages/playwright-core/src/cli/cli.ts +++ b/packages/playwright-core/src/cli/cli.ts @@ -468,7 +468,7 @@ async function launchContext(options: Options, headless: boolean, executablePath // HAR if (options.saveHar) { - contextOptions.recordHar = { path: path.resolve(process.cwd(), options.saveHar) }; + contextOptions.recordHar = { path: path.resolve(process.cwd(), options.saveHar), mode: 'minimal' }; if (options.saveHarGlob) contextOptions.recordHar.urlFilter = options.saveHarGlob; contextOptions.serviceWorkers = 'block'; diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index ff5f220875..5c45e564fd 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -393,6 +393,7 @@ function prepareRecordHarOptions(options: BrowserContextOptions['recordHar']): c urlGlob: isString(options.urlFilter) ? options.urlFilter : undefined, urlRegexSource: isRegExp(options.urlFilter) ? options.urlFilter.source : undefined, urlRegexFlags: isRegExp(options.urlFilter) ? options.urlFilter.flags : undefined, + mode: options.mode }; } diff --git a/packages/playwright-core/src/client/types.ts b/packages/playwright-core/src/client/types.ts index 6b7a8bf600..c8215f7a79 100644 --- a/packages/playwright-core/src/client/types.ts +++ b/packages/playwright-core/src/client/types.ts @@ -63,6 +63,7 @@ export type BrowserContextOptions = Omit Validator): Scheme { scheme.RecordHarOptions = tObject({ path: tString, content: tOptional(tEnum(['embed', 'attach', 'omit'])), + mode: tOptional(tEnum(['full', 'minimal'])), urlGlob: tOptional(tString), urlRegexSource: tOptional(tString), urlRegexFlags: tOptional(tString), diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index 3db59e896f..f0ff7c4c7d 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -176,14 +176,14 @@ class HarBackend { } } - private async _loadContent(content: { text?: string, encoding?: string, _sha1?: string }): Promise { - const sha1 = content._sha1; + private async _loadContent(content: { text?: string, encoding?: string, _file?: string }): Promise { + const file = content._file; let buffer: Buffer; - if (sha1) { + if (file) { if (this._zipFile) - buffer = await this._zipFile.read(sha1); + buffer = await this._zipFile.read(file); else - buffer = await fs.promises.readFile(path.resolve(this._baseDir!, sha1)); + buffer = await fs.promises.readFile(path.resolve(this._baseDir!, file)); } else { buffer = Buffer.from(content.text || '', content.encoding === 'base64' ? 'base64' : 'utf-8'); } diff --git a/packages/playwright-core/src/server/har/har.ts b/packages/playwright-core/src/server/har/har.ts index 1552dd6798..dcbc4ae2db 100644 --- a/packages/playwright-core/src/server/har/har.ts +++ b/packages/playwright-core/src/server/har/har.ts @@ -64,9 +64,8 @@ export type Entry = { timings: Timings; serverIPAddress?: string; connection?: string; - _requestref: string; - _frameref: string; - _monotonicTime: number; + _frameref?: string; + _monotonicTime?: number; _serverPort?: number; _securityDetails?: SecurityDetails; }; @@ -95,7 +94,7 @@ export type Response = { headersSize: number; bodySize: number; comment?: string; - _transferSize: number; + _transferSize?: number; _failureText?: string }; @@ -129,6 +128,7 @@ export type PostData = { text: string; comment?: string; _sha1?: string; + _file?: string; }; export type Param = { @@ -147,6 +147,7 @@ export type Content = { encoding?: string; comment?: string; _sha1?: string; + _file?: string; }; export type Cache = { diff --git a/packages/playwright-core/src/server/har/harRecorder.ts b/packages/playwright-core/src/server/har/harRecorder.ts index c3cfeea513..b4c4d1c19f 100644 --- a/packages/playwright-core/src/server/har/harRecorder.ts +++ b/packages/playwright-core/src/server/har/harRecorder.ts @@ -42,6 +42,8 @@ export class HarRecorder { const content = options.content || (expectsZip ? 'attach' : 'embed'); this._tracer = new HarTracer(context, this, { content, + slimMode: options.mode === 'minimal', + includeTraceInfo: false, waitForContentOnStop: true, skipScripts: false, urlFilter: urlFilterRe ?? options.urlGlob, @@ -73,7 +75,7 @@ export class HarRecorder { const log = this._tracer.stop(); log.entries = this._entries; - const harFileContent = JSON.stringify({ log }, undefined, 2); + const harFileContent = jsonStringify({ log }); if (this._zipFile) { const result = new ManualPromise(); @@ -95,3 +97,50 @@ export class HarRecorder { return this._artifact; } } + +function jsonStringify(object: any): string { + const tokens: string[] = []; + innerJsonStringify(object, tokens, '', false, undefined); + return tokens.join(''); +} + +function innerJsonStringify(object: any, tokens: string[], indent: string, flat: boolean, parentKey: string | undefined) { + if (typeof object !== 'object' || object === null) { + tokens.push(JSON.stringify(object)); + return; + } + + const isArray = Array.isArray(object); + if (!isArray && object.constructor.name !== 'Object') { + tokens.push(JSON.stringify(object)); + return; + } + + const entries = isArray ? object : Object.entries(object).filter(e => e[1] !== undefined); + if (!entries.length) { + tokens.push(isArray ? `[]` : `{}`); + return; + } + + const childIndent = `${indent} `; + let brackets: { open: string, close: string }; + if (isArray) + brackets = flat ? { open: '[', close: ']' } : { open: `[\n${childIndent}`, close: `\n${indent}]` }; + else + brackets = flat ? { open: '{ ', close: ' }' } : { open: `{\n${childIndent}`, close: `\n${indent}}` }; + + tokens.push(brackets.open); + + for (let i = 0; i < entries.length; ++i) { + const entry = entries[i]; + if (i) + tokens.push(flat ? `, ` : `,\n${childIndent}`); + if (!isArray) + tokens.push(`${JSON.stringify(entry[0])}: `); + const key = isArray ? undefined : entry[0]; + const flatten = flat || key === 'timings' || parentKey === 'headers'; + innerJsonStringify(isArray ? entry : entry[1], tokens, childIndent, flatten, key); + } + + tokens.push(brackets.close); +} diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index e6f8a7785b..0dcf28db5a 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -42,8 +42,16 @@ export interface HarTracerDelegate { type HarTracerOptions = { content: 'omit' | 'attach' | 'embed'; skipScripts: boolean; + includeTraceInfo: boolean; waitForContentOnStop: boolean; urlFilter?: string | RegExp; + slimMode?: boolean; + omitSecurityDetails?: boolean; + omitCookies?: boolean; + omitTiming?: boolean; + omitServerIP?: boolean; + omitPages?: boolean; + omitSizes?: boolean; }; export class HarTracer { @@ -61,6 +69,14 @@ export class HarTracer { this._context = context; this._delegate = delegate; this._options = options; + if (options.slimMode) { + options.omitSecurityDetails = true; + options.omitCookies = true; + options.omitTiming = true; + options.omitServerIP = true; + options.omitSizes = true; + options.omitPages = true; + } this._entrySymbol = Symbol('requestHarEntry'); this._baseURL = context instanceof APIRequestContext ? context._defaultOptions().baseURL : context._options.baseURL; } @@ -92,32 +108,34 @@ export class HarTracer { return (request as any)[this._entrySymbol]; } - private _ensurePageEntry(page: Page) { + private _ensurePageEntry(page: Page): har.Page | undefined { + if (this._options.omitPages) + return; let pageEntry = this._pageEntries.get(page); if (!pageEntry) { - page.mainFrame().on(Frame.Events.AddLifecycle, (event: LifecycleEvent) => { - if (event === 'load') - this._onLoad(page); - if (event === 'domcontentloaded') - this._onDOMContentLoaded(page); - }); - pageEntry = { startedDateTime: new Date(), id: page.guid, title: '', - pageTimings: { + pageTimings: this._options.omitTiming ? {} : { onContentLoad: -1, onLoad: -1, }, }; + + page.mainFrame().on(Frame.Events.AddLifecycle, (event: LifecycleEvent) => { + if (event === 'load') + this._onLoad(page, pageEntry!); + if (event === 'domcontentloaded') + this._onDOMContentLoaded(page, pageEntry!); + }); + this._pageEntries.set(page, pageEntry); } return pageEntry; } - private _onDOMContentLoaded(page: Page) { - const pageEntry = this._ensurePageEntry(page); + private _onDOMContentLoaded(page: Page, pageEntry: har.Page) { const promise = page.mainFrame().evaluateExpression(String(() => { return { title: document.title, @@ -125,13 +143,13 @@ export class HarTracer { }; }), true, undefined, 'utility').then(result => { pageEntry.title = result.title; - pageEntry.pageTimings.onContentLoad = result.domContentLoaded; + if (!this._options.omitTiming) + pageEntry.pageTimings.onContentLoad = result.domContentLoaded; }).catch(() => {}); this._addBarrier(page, promise); } - private _onLoad(page: Page) { - const pageEntry = this._ensurePageEntry(page); + private _onLoad(page: Page, pageEntry: har.Page) { const promise = page.mainFrame().evaluateExpression(String(() => { return { title: document.title, @@ -139,7 +157,8 @@ export class HarTracer { }; }), true, undefined, 'utility').then(result => { pageEntry.title = result.title; - pageEntry.pageTimings.onLoad = result.loaded; + if (!this._options.omitTiming) + pageEntry.pageTimings.onLoad = result.loaded; }).catch(() => {}); this._addBarrier(page, promise); } @@ -161,11 +180,13 @@ export class HarTracer { private _onAPIRequest(event: APIRequestEvent) { if (!this._shouldIncludeEntryWithUrl(event.url.toString())) return; - const harEntry = createHarEntry(event.method, event.url, '', ''); - harEntry.request.cookies = event.cookies; + const harEntry = createHarEntry(event.method, event.url, undefined, this._options); + if (!this._options.omitCookies) + harEntry.request.cookies = event.cookies; harEntry.request.headers = Object.entries(event.headers).map(([name, value]) => ({ name, value })); harEntry.request.postData = this._postDataForBuffer(event.postData || null, event.headers['content-type'], this._options.content); - harEntry.request.bodySize = event.postData?.length || 0; + if (!this._options.omitSizes) + harEntry.request.bodySize = event.postData?.length || 0; (event as any)[this._entrySymbol] = harEntry; if (this._started) this._delegate.onEntryStarted(harEntry); @@ -186,7 +207,7 @@ export class HarTracer { value: event.rawHeaders[i + 1] }); } - harEntry.response.cookies = event.cookies.map(c => { + harEntry.response.cookies = this._options.omitCookies ? [] : event.cookies.map(c => { return { ...c, expires: c.expires === -1 ? undefined : new Date(c.expires) @@ -212,10 +233,12 @@ export class HarTracer { return; const pageEntry = this._ensurePageEntry(page); - const harEntry = createHarEntry(request.method(), url, request.guid, request.frame().guid); - harEntry.pageref = pageEntry.id; + const harEntry = createHarEntry(request.method(), url, request.frame().guid, this._options); + if (pageEntry) + harEntry.pageref = pageEntry.id; harEntry.request.postData = this._postDataForRequest(request, this._options.content); - harEntry.request.bodySize = request.bodySize(); + if (!this._options.omitSizes) + harEntry.request.bodySize = request.bodySize(); if (request.redirectedFrom()) { const fromEntry = this._entryForRequest(request.redirectedFrom()!); if (fromEntry) @@ -238,7 +261,7 @@ export class HarTracer { harEntry.request.httpVersion = httpVersion; harEntry.response.httpVersion = httpVersion; - const compressionCalculationBarrier = { + const compressionCalculationBarrier = this._options.omitSizes ? undefined : { _encodedBodySize: -1, _decodedBodySize: -1, barrier: new ManualPromise(), @@ -257,32 +280,36 @@ export class HarTracer { this._check(); } }; - this._addBarrier(page, compressionCalculationBarrier.barrier); + if (compressionCalculationBarrier) + this._addBarrier(page, compressionCalculationBarrier.barrier); const promise = response.body().then(buffer => { if (this._options.skipScripts && request.resourceType() === 'script') { - compressionCalculationBarrier.setDecodedBodySize(0); + compressionCalculationBarrier?.setDecodedBodySize(0); return; } const content = harEntry.response.content; - compressionCalculationBarrier.setDecodedBodySize(buffer.length); + compressionCalculationBarrier?.setDecodedBodySize(buffer.length); this._storeResponseContent(buffer, content, request.resourceType()); }).catch(() => { - compressionCalculationBarrier.setDecodedBodySize(0); + compressionCalculationBarrier?.setDecodedBodySize(0); }).then(() => { if (this._started) this._delegate.onEntryFinished(harEntry); }); this._addBarrier(page, promise); - this._addBarrier(page, response.sizes().then(sizes => { - harEntry.response.bodySize = sizes.responseBodySize; - harEntry.response.headersSize = sizes.responseHeadersSize; - // Fallback for WebKit by calculating it manually - harEntry.response._transferSize = response.request().responseSize.transferSize || (sizes.responseHeadersSize + sizes.responseBodySize); - harEntry.request.headersSize = sizes.requestHeadersSize; - compressionCalculationBarrier.setEncodedBodySize(sizes.responseBodySize); - })); + + if (!this._options.omitSizes) { + this._addBarrier(page, response.sizes().then(sizes => { + harEntry.response.bodySize = sizes.responseBodySize; + harEntry.response.headersSize = sizes.responseHeadersSize; + // Fallback for WebKit by calculating it manually + harEntry.response._transferSize = response.request().responseSize.transferSize || (sizes.responseHeadersSize + sizes.responseBodySize); + harEntry.request.headersSize = sizes.requestHeadersSize; + compressionCalculationBarrier?.setEncodedBodySize(sizes.responseBodySize); + })); + } } private async _onRequestFailed(request: network.Request) { @@ -301,7 +328,10 @@ export class HarTracer { content.size = 0; return; } - content.size = buffer.length; + + if (!this._options.omitSizes) + content.size = buffer.length; + if (this._options.content === 'embed') { // Sometimes, we can receive a font/media file with textual mime type. Browser // still interprets them correctly, but the 'content-type' header is obviously wrong. @@ -312,9 +342,13 @@ export class HarTracer { content.encoding = 'base64'; } } else if (this._options.content === 'attach') { - content._sha1 = calculateSha1(buffer) + '.' + (mime.getExtension(content.mimeType) || 'dat'); + const sha1 = calculateSha1(buffer) + '.' + (mime.getExtension(content.mimeType) || 'dat'); + if (this._options.includeTraceInfo) + content._sha1 = sha1; + else + content._file = sha1; if (this._started) - this._delegate.onContentBlob(content._sha1, buffer); + this._delegate.onContentBlob(sha1, buffer); } } @@ -340,43 +374,56 @@ export class HarTracer { headersSize: -1, bodySize: -1, redirectURL: '', - _transferSize: -1 + _transferSize: this._options.omitSizes ? undefined : -1 }; - const timing = response.timing(); - if (pageEntry.startedDateTime.valueOf() > timing.startTime) - pageEntry.startedDateTime = new Date(timing.startTime); - const dns = timing.domainLookupEnd !== -1 ? helper.millisToRoundishMillis(timing.domainLookupEnd - timing.domainLookupStart) : -1; - const connect = timing.connectEnd !== -1 ? helper.millisToRoundishMillis(timing.connectEnd - timing.connectStart) : -1; - const ssl = timing.connectEnd !== -1 ? helper.millisToRoundishMillis(timing.connectEnd - timing.secureConnectionStart) : -1; - const wait = timing.responseStart !== -1 ? helper.millisToRoundishMillis(timing.responseStart - timing.requestStart) : -1; - const receive = response.request()._responseEndTiming !== -1 ? helper.millisToRoundishMillis(response.request()._responseEndTiming - timing.responseStart) : -1; - harEntry.timings = { - dns, - connect, - ssl, - send: 0, - wait, - 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; - if (server?.port) - harEntry._serverPort = server.port; - })); - this._addBarrier(page, response.securityDetails().then(details => { - if (details) - harEntry._securityDetails = details; - })); + + if (!this._options.omitTiming) { + const timing = response.timing(); + if (pageEntry && pageEntry.startedDateTime.valueOf() > timing.startTime) + pageEntry.startedDateTime = new Date(timing.startTime); + const dns = timing.domainLookupEnd !== -1 ? helper.millisToRoundishMillis(timing.domainLookupEnd - timing.domainLookupStart) : -1; + const connect = timing.connectEnd !== -1 ? helper.millisToRoundishMillis(timing.connectEnd - timing.connectStart) : -1; + const ssl = timing.connectEnd !== -1 ? helper.millisToRoundishMillis(timing.connectEnd - timing.secureConnectionStart) : -1; + const wait = timing.responseStart !== -1 ? helper.millisToRoundishMillis(timing.responseStart - timing.requestStart) : -1; + const receive = response.request()._responseEndTiming !== -1 ? helper.millisToRoundishMillis(response.request()._responseEndTiming - timing.responseStart) : -1; + + harEntry.timings = { + dns, + connect, + ssl, + send: 0, + wait, + receive, + }; + harEntry.time = [dns, connect, ssl, wait, receive].reduce((pre, cur) => cur > 0 ? cur + pre : pre, 0); + } + + if (!this._options.omitServerIP) { + this._addBarrier(page, response.serverAddr().then(server => { + if (server?.ipAddress) + harEntry.serverIPAddress = server.ipAddress; + if (server?.port) + harEntry._serverPort = server.port; + })); + } + if (!this._options.omitSecurityDetails) { + this._addBarrier(page, response.securityDetails().then(details => { + if (details) + harEntry._securityDetails = details; + })); + } this._addBarrier(page, request.rawRequestHeaders().then(headers => { - for (const header of headers.filter(header => header.name.toLowerCase() === 'cookie')) - harEntry.request.cookies.push(...header.value.split(';').map(parseCookie)); + if (!this._options.omitCookies) { + for (const header of headers.filter(header => header.name.toLowerCase() === 'cookie')) + harEntry.request.cookies.push(...header.value.split(';').map(parseCookie)); + } harEntry.request.headers = headers; })); this._addBarrier(page, response.rawResponseHeaders().then(headers => { - for (const header of headers.filter(header => header.name.toLowerCase() === 'set-cookie')) - harEntry.response.cookies.push(parseCookie(header.value)); + if (!this._options.omitCookies) { + for (const header of headers.filter(header => header.name.toLowerCase() === 'set-cookie')) + harEntry.response.cookies.push(parseCookie(header.value)); + } harEntry.response.headers = headers; const contentType = headers.find(header => header.name.toLowerCase() === 'content-type'); if (contentType) @@ -404,18 +451,20 @@ export class HarTracer { name: context?._browser.options.name || '', version: context?._browser.version() || '' }, - pages: Array.from(this._pageEntries.values()), + pages: this._pageEntries.size ? Array.from(this._pageEntries.values()) : undefined, entries: [], }; - for (const pageEntry of log.pages!) { - if (pageEntry.pageTimings.onContentLoad! >= 0) - pageEntry.pageTimings.onContentLoad! -= pageEntry.startedDateTime.valueOf(); - else - pageEntry.pageTimings.onContentLoad = -1; - if (pageEntry.pageTimings.onLoad! >= 0) - pageEntry.pageTimings.onLoad! -= pageEntry.startedDateTime.valueOf(); - else - pageEntry.pageTimings.onLoad = -1; + if (!this._options.omitTiming) { + for (const pageEntry of log.pages || []) { + if (typeof pageEntry.pageTimings.onContentLoad === 'number' && pageEntry.pageTimings.onContentLoad >= 0) + pageEntry.pageTimings.onContentLoad -= pageEntry.startedDateTime.valueOf(); + else + pageEntry.pageTimings.onContentLoad = -1; + if (typeof pageEntry.pageTimings.onLoad === 'number' && pageEntry.pageTimings.onLoad >= 0) + pageEntry.pageTimings.onLoad -= pageEntry.startedDateTime.valueOf(); + else + pageEntry.pageTimings.onLoad = -1; + } } this._pageEntries.clear(); return log; @@ -446,8 +495,12 @@ export class HarTracer { result.text = postData.toString(); if (content === 'attach') { - result._sha1 = calculateSha1(postData) + '.' + (mime.getExtension(contentType) || 'dat'); - this._delegate.onContentBlob(result._sha1, postData); + const sha1 = calculateSha1(postData) + '.' + (mime.getExtension(contentType) || 'dat'); + if (this._options.includeTraceInfo) + result._sha1 = sha1; + else + result._file = sha1; + this._delegate.onContentBlob(sha1, postData); } if (contentType === 'application/x-www-form-urlencoded') { @@ -461,11 +514,10 @@ export class HarTracer { } -function createHarEntry(method: string, url: URL, requestref: string, frameref: string): har.Entry { +function createHarEntry(method: string, url: URL, frameref: string | undefined, options: HarTracerOptions): har.Entry { const harEntry: har.Entry = { - _requestref: requestref, - _frameref: frameref, - _monotonicTime: monotonicTime(), + _frameref: options.includeTraceInfo ? frameref : undefined, + _monotonicTime: options.includeTraceInfo ? monotonicTime() : undefined, startedDateTime: new Date(), time: -1, request: { @@ -476,7 +528,7 @@ function createHarEntry(method: string, url: URL, requestref: string, frameref: headers: [], queryString: [...url.searchParams].map(e => ({ name: e[0], value: e[1] })), headersSize: -1, - bodySize: 0, + bodySize: -1, }, response: { status: -1, @@ -491,12 +543,9 @@ function createHarEntry(method: string, url: URL, requestref: string, frameref: headersSize: -1, bodySize: -1, redirectURL: '', - _transferSize: -1 - }, - cache: { - beforeRequest: null, - afterRequest: null, + _transferSize: options.omitSizes ? undefined : -1 }, + cache: {}, timings: { send: -1, wait: -1, diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index 46db9616fa..aa11d0e61d 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -90,6 +90,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps this._precreatedTracesDir = tracesDir; this._harTracer = new HarTracer(context, this, { content: 'attach', + includeTraceInfo: true, waitForContentOnStop: false, skipScripts: true, }); diff --git a/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts b/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts index c0f56bafe1..ed2c25d324 100644 --- a/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts +++ b/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts @@ -34,7 +34,7 @@ export class InMemorySnapshotter extends BaseSnapshotStorage implements Snapshot constructor(context: BrowserContext) { super(); this._snapshotter = new Snapshotter(context, this); - this._harTracer = new HarTracer(context, this, { content: 'attach', waitForContentOnStop: false, skipScripts: true }); + this._harTracer = new HarTracer(context, this, { content: 'attach', includeTraceInfo: true, waitForContentOnStop: false, skipScripts: true }); } async initialize(): Promise { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index a4add8fd04..3e84f3b9bf 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -10676,6 +10676,12 @@ export interface BrowserType { */ path: string; + /** + * When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, cookies, + * security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. + */ + mode?: "full"|"minimal"; + /** * A glob or regex pattern to filter requests that are stored in the HAR. When a `baseURL` via the context options was * provided and the passed URL is a path, it gets merged via the @@ -11863,6 +11869,12 @@ export interface AndroidDevice { */ path: string; + /** + * When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, cookies, + * security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. + */ + mode?: "full"|"minimal"; + /** * A glob or regex pattern to filter requests that are stored in the HAR. When a `baseURL` via the context options was * provided and the passed URL is a path, it gets merged via the @@ -13433,6 +13445,12 @@ export interface Browser extends EventEmitter { */ path: string; + /** + * When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, cookies, + * security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. + */ + mode?: "full"|"minimal"; + /** * A glob or regex pattern to filter requests that are stored in the HAR. When a `baseURL` via the context options was * provided and the passed URL is a path, it gets merged via the @@ -14219,6 +14237,12 @@ export interface Electron { */ path: string; + /** + * When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, cookies, + * security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. + */ + mode?: "full"|"minimal"; + /** * A glob or regex pattern to filter requests that are stored in the HAR. When a `baseURL` via the context options was * provided and the passed URL is a path, it gets merged via the @@ -16038,6 +16062,12 @@ export interface BrowserContextOptions { */ path: string; + /** + * When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, cookies, + * security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. + */ + mode?: "full"|"minimal"; + /** * A glob or regex pattern to filter requests that are stored in the HAR. When a `baseURL` via the context options was * provided and the passed URL is a path, it gets merged via the diff --git a/packages/trace-viewer/src/snapshotRenderer.ts b/packages/trace-viewer/src/snapshotRenderer.ts index 940e848590..f24cb56326 100644 --- a/packages/trace-viewer/src/snapshotRenderer.ts +++ b/packages/trace-viewer/src/snapshotRenderer.ts @@ -108,7 +108,7 @@ export class SnapshotRenderer { // First try locating exact resource belonging to this frame. for (const resource of this._resources) { - if (resource._monotonicTime >= snapshot.timestamp) + if (typeof resource._monotonicTime === 'number' && resource._monotonicTime >= snapshot.timestamp) break; if (resource._frameref !== snapshot.frameId) continue; @@ -121,7 +121,7 @@ export class SnapshotRenderer { if (!result) { // Then fall back to resource with this URL to account for memory cache. for (const resource of this._resources) { - if (resource._monotonicTime >= snapshot.timestamp) + if (typeof resource._monotonicTime === 'number' && resource._monotonicTime >= snapshot.timestamp) break; if (resource.request.url === url) return resource; diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index c156e833bf..dae16e3bd8 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -116,7 +116,7 @@ export function resourcesForAction(action: ActionTraceEvent): ResourceSnapshot[] const nextAction = next(action); result = context(action).resources.filter(resource => { - return resource._monotonicTime > action.metadata.startTime && (!nextAction || resource._monotonicTime < nextAction.metadata.startTime); + return typeof resource._monotonicTime === 'number' && resource._monotonicTime > action.metadata.startTime && (!nextAction || resource._monotonicTime < nextAction.metadata.startTime); }); (action as any)[resourcesSymbol] = result; return result; diff --git a/tests/assets/har-fulfill.har b/tests/assets/har-fulfill.har index fcc222d426..5b679098c8 100644 --- a/tests/assets/har-fulfill.har +++ b/tests/assets/har-fulfill.har @@ -22,7 +22,6 @@ ], "entries": [ { - "_requestref": "request@ee2a0dc164935fcd4d9432d37b245f3c", "_frameref": "frame@c7467fc0f1f86f09fc3b0d727a3862ea", "_monotonicTime": 270572145.898, "startedDateTime": "2022-06-10T04:27:32.146Z", @@ -92,7 +91,6 @@ "_securityDetails": {} }, { - "_requestref": "request@f2ff0fd79321ff90d0bc1b5d6fc13bad", "_frameref": "frame@c7467fc0f1f86f09fc3b0d727a3862ea", "_monotonicTime": 270572174.683, "startedDateTime": "2022-06-10T04:27:32.172Z", @@ -162,7 +160,6 @@ "_securityDetails": {} }, { - "_requestref": "request@f2ff0fd79321ff90d0bc1b5d6fc13bac", "_frameref": "frame@c7467fc0f1f86f09fc3b0d727a3862ea", "_monotonicTime": 270572174.683, "startedDateTime": "2022-06-10T04:27:32.174Z", @@ -232,7 +229,6 @@ "_securityDetails": {} }, { - "_requestref": "request@9626f59acb1f4a95f25112d32e9f7f60", "_frameref": "frame@c7467fc0f1f86f09fc3b0d727a3862ea", "_monotonicTime": 270572175.042, "startedDateTime": "2022-06-10T04:27:32.175Z", @@ -297,7 +293,6 @@ "_securityDetails": {} }, { - "_requestref": "request@d7ee53396148a663b819c348c53b03fb", "_frameref": "frame@c7467fc0f1f86f09fc3b0d727a3862ea", "_monotonicTime": 270572181.822, "startedDateTime": "2022-06-10T04:27:32.182Z", diff --git a/tests/assets/har-redirect.har b/tests/assets/har-redirect.har index c8e865f7d9..5b50e7bffd 100644 --- a/tests/assets/har-redirect.har +++ b/tests/assets/har-redirect.har @@ -22,7 +22,6 @@ ], "entries": [ { - "_requestref": "request@7d6e0ddb1e1e25f6e5c4a7c943c0bae1", "_frameref": "frame@3767e074ecde4cb8372abba2f6f9bb4f", "_monotonicTime": 110928357.437, "startedDateTime": "2022-06-16T21:41:23.951Z", @@ -201,7 +200,6 @@ } }, { - "_requestref": "request@5c7a316ee46a095bda80c23ddc8c740d", "_frameref": "frame@3767e074ecde4cb8372abba2f6f9bb4f", "_monotonicTime": 110928427.603, "startedDateTime": "2022-06-16T21:41:24.022Z", @@ -358,7 +356,6 @@ "_securityDetails": {} }, { - "_requestref": "request@17664a6093c12c97d41efbff3a502adb", "_frameref": "frame@3767e074ecde4cb8372abba2f6f9bb4f", "_monotonicTime": 110928455.901, "startedDateTime": "2022-06-16T21:41:24.050Z", diff --git a/tests/assets/har-sha1.har b/tests/assets/har-sha1.har index 2b849154b8..d918acd028 100644 --- a/tests/assets/har-sha1.har +++ b/tests/assets/har-sha1.har @@ -22,7 +22,6 @@ ], "entries": [ { - "_requestref": "request@ee2a0dc164935fcd4d9432d37b245f3c", "_frameref": "frame@c7467fc0f1f86f09fc3b0d727a3862ea", "_monotonicTime": 270572145.898, "startedDateTime": "2022-06-10T04:27:32.146Z", @@ -69,7 +68,7 @@ "size": 12, "mimeType": "text/html", "compression": 0, - "_sha1": "har-sha1-main-response.txt" + "_file": "har-sha1-main-response.txt" }, "headersSize": 64, "bodySize": 71, diff --git a/tests/library/browsercontext-har.spec.ts b/tests/library/browsercontext-har.spec.ts index 032f39481d..c94ce4ec56 100644 --- a/tests/library/browsercontext-har.spec.ts +++ b/tests/library/browsercontext-har.spec.ts @@ -255,7 +255,7 @@ it('should round-trip har.zip', async ({ contextFactory, isAndroid, server }, te it.fixme(isAndroid); const harPath = testInfo.outputPath('har.zip'); - const context1 = await contextFactory({ recordHar: { path: harPath } }); + const context1 = await contextFactory({ recordHar: { mode: 'minimal', path: harPath } }); const page1 = await context1.newPage(); await page1.goto(server.PREFIX + '/one-style.html'); await context1.close(); @@ -272,7 +272,7 @@ it('should round-trip extracted har.zip', async ({ contextFactory, isAndroid, se it.fixme(isAndroid); const harPath = testInfo.outputPath('har.zip'); - const context1 = await contextFactory({ recordHar: { path: harPath } }); + const context1 = await contextFactory({ recordHar: { mode: 'minimal', path: harPath } }); const page1 = await context1.newPage(); await page1.goto(server.PREFIX + '/one-style.html'); await context1.close(); @@ -296,7 +296,7 @@ it('should round-trip har with postData', async ({ contextFactory, isAndroid, se }); const harPath = testInfo.outputPath('har.zip'); - const context1 = await contextFactory({ recordHar: { path: harPath } }); + const context1 = await contextFactory({ recordHar: { mode: 'minimal', path: harPath } }); const page1 = await context1.newPage(); await page1.goto(server.EMPTY_PAGE); const fetchFunction = async (body: string) => { @@ -327,7 +327,7 @@ it('should disambiguate by header', async ({ contextFactory, isAndroid, server } }); const harPath = testInfo.outputPath('har.zip'); - const context1 = await contextFactory({ recordHar: { path: harPath } }); + const context1 = await contextFactory({ recordHar: { mode: 'minimal', path: harPath } }); const page1 = await context1.newPage(); await page1.goto(server.EMPTY_PAGE); diff --git a/tests/library/har.spec.ts b/tests/library/har.spec.ts index 1fdb6f45af..21f1990d12 100644 --- a/tests/library/har.spec.ts +++ b/tests/library/har.spec.ts @@ -291,7 +291,7 @@ it('should omit content', async ({ contextFactory, server }, testInfo) => { await page.evaluate(() => fetch('/pptr.png').then(r => r.arrayBuffer())); const log = await getLog(); expect(log.entries[0].response.content.text).toBe(undefined); - expect(log.entries[0].response.content._sha1).toBe(undefined); + expect(log.entries[0].response.content._file).toBe(undefined); }); it('should omit content legacy', async ({ contextFactory, server }, testInfo) => { @@ -300,7 +300,7 @@ it('should omit content legacy', async ({ contextFactory, server }, testInfo) => await page.evaluate(() => fetch('/pptr.png').then(r => r.arrayBuffer())); const log = await getLog(); expect(log.entries[0].response.content.text).toBe(undefined); - expect(log.entries[0].response.content._sha1).toBe(undefined); + expect(log.entries[0].response.content._file).toBe(undefined); }); it('should attach content', async ({ contextFactory, server }, testInfo) => { @@ -312,19 +312,19 @@ it('should attach content', async ({ contextFactory, server }, testInfo) => { expect(log.entries[0].response.content.encoding).toBe(undefined); expect(log.entries[0].response.content.mimeType).toBe('text/html; charset=utf-8'); - expect(log.entries[0].response.content._sha1).toContain('75841480e2606c03389077304342fac2c58ccb1b'); + expect(log.entries[0].response.content._file).toContain('75841480e2606c03389077304342fac2c58ccb1b'); expect(log.entries[0].response.content.size).toBeGreaterThanOrEqual(96); expect(log.entries[0].response.content.compression).toBe(0); expect(log.entries[1].response.content.encoding).toBe(undefined); expect(log.entries[1].response.content.mimeType).toBe('text/css; charset=utf-8'); - expect(log.entries[1].response.content._sha1).toContain('79f739d7bc88e80f55b9891a22bf13a2b4e18adb'); + expect(log.entries[1].response.content._file).toContain('79f739d7bc88e80f55b9891a22bf13a2b4e18adb'); expect(log.entries[1].response.content.size).toBeGreaterThanOrEqual(37); expect(log.entries[1].response.content.compression).toBe(0); expect(log.entries[2].response.content.encoding).toBe(undefined); expect(log.entries[2].response.content.mimeType).toBe('image/png'); - expect(log.entries[2].response.content._sha1).toContain('a4c3a18f0bb83f5d9fe7ce561e065c36205762fa'); + expect(log.entries[2].response.content._file).toContain('a4c3a18f0bb83f5d9fe7ce561e065c36205762fa'); expect(log.entries[2].response.content.size).toBeGreaterThanOrEqual(6000); expect(log.entries[2].response.content.compression).toBe(0); @@ -689,45 +689,6 @@ it('should have different hars for concurrent contexts', async ({ contextFactory } }); -it('should include _requestref', async ({ contextFactory, server }, testInfo) => { - const { page, getLog } = await pageWithHar(contextFactory, testInfo); - const resp = await page.goto(server.EMPTY_PAGE); - const log = await getLog(); - expect(log.entries.length).toBe(1); - const entry = log.entries[0]; - expect(entry._requestref).toMatch(/^request@[a-f0-9]{32}$/); - expect(entry._requestref).toBe((resp.request() as any)._guid); -}); - -it('should include _requestref for redirects', async ({ contextFactory, server }, testInfo) => { - server.setRedirect('/start', '/one-more'); - server.setRedirect('/one-more', server.EMPTY_PAGE); - - const { page, getLog, context } = await pageWithHar(contextFactory, testInfo); - - const requests = new Map(); - context.on('request', request => { - requests.set(request.url(), (request as any)._guid); - }); - - await page.goto(server.PREFIX + '/start'); - - const log = await getLog(); - expect(log.entries.length).toBe(3); - - const entryStart = log.entries[0]; - expect(entryStart.request.url).toBe(server.PREFIX + '/start'); - expect(entryStart._requestref).toBe(requests.get(entryStart.request.url)); - - const entryOneMore = log.entries[1]; - expect(entryOneMore.request.url).toBe(server.PREFIX + '/one-more'); - expect(entryOneMore._requestref).toBe(requests.get(entryOneMore.request.url)); - - const entryEmptyPage = log.entries[2]; - expect(entryEmptyPage.request.url).toBe(server.EMPTY_PAGE); - expect(entryEmptyPage._requestref).toBe(requests.get(entryEmptyPage.request.url)); -}); - it('should include API request', async ({ contextFactory, server }, testInfo) => { const { page, getLog } = await pageWithHar(contextFactory, testInfo); const url = server.PREFIX + '/simple.json'; diff --git a/tests/library/tracing.spec.ts b/tests/library/tracing.spec.ts index 17dd3ba9df..51f24a95d1 100644 --- a/tests/library/tracing.spec.ts +++ b/tests/library/tracing.spec.ts @@ -528,7 +528,6 @@ test('should store postData for global request', async ({ request, server }, tes const actions = trace.events.filter(e => e.type === 'resource-snapshot'); expect(actions).toHaveLength(1); const req = actions[0].snapshot.request; - console.log(JSON.stringify(req, null, 2)); expect(req.postData?._sha1).toBeTruthy(); expect(req).toEqual(expect.objectContaining({ method: 'POST',