From 76bbfcd12a2d1ff296add44bf1aab375d6aaf718 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 10 Apr 2026 21:50:45 +0100 Subject: [PATCH] feat(tracing): add tracing.startHar / tracing.stopHar Adds on-demand HAR recording to `Tracing`, available on both `BrowserContext.tracing` and `APIRequestContext.tracing`. Unlike `recordHar`, this can be scoped to an individual flow via explicit start/stop calls. --- docs/src/api/class-apirequestcontext.md | 4 + docs/src/api/class-tracing.md | 74 +++++++++++++++++++ packages/isomorphic/protocolMetainfo.ts | 4 +- packages/playwright-client/types/types.d.ts | 50 +++++++++++++ .../src/client/browserContext.ts | 41 +--------- packages/playwright-core/src/client/fetch.ts | 9 ++- packages/playwright-core/src/client/page.ts | 2 +- .../playwright-core/src/client/tracing.ts | 71 ++++++++++++++++++ .../playwright-core/src/protocol/validator.ts | 26 +++---- .../src/server/browserContext.ts | 17 ----- .../dispatchers/browserContextDispatcher.ts | 13 ---- .../server/dispatchers/tracingDispatcher.ts | 13 ++++ .../src/server/har/harRecorder.ts | 9 ++- .../src/server/trace/recorder/tracing.ts | 21 +++++- packages/playwright-core/types/types.d.ts | 50 +++++++++++++ packages/playwright/src/index.ts | 6 +- packages/protocol/src/channels.d.ts | 42 +++++------ packages/protocol/src/protocol.yml | 30 ++++---- tests/library/har.spec.ts | 31 ++++++++ tests/library/tracing.spec.ts | 16 ++-- 20 files changed, 390 insertions(+), 139 deletions(-) diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md index c96e01991ca2c..3525fe3ee7dd1 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -896,3 +896,7 @@ Returns storage state for this request context, contains current cookies and loc - `indexedDB` ? Set to `true` to include IndexedDB in the storage state snapshot. + +## property: APIRequestContext.tracing +* since: v1.60 +- type: <[Tracing]> diff --git a/docs/src/api/class-tracing.md b/docs/src/api/class-tracing.md index becde625b744f..50501c3bce6bf 100644 --- a/docs/src/api/class-tracing.md +++ b/docs/src/api/class-tracing.md @@ -303,6 +303,75 @@ given name prefix inside the [`option: BrowserType.launch.tracesDir`] directory To specify the final trace zip file name, you need to pass `path` option to [`method: Tracing.stopChunk`] instead. +## async method: Tracing.startHar +* since: v1.60 +- returns: <[Disposable]> + +Start recording a HAR (HTTP Archive) of network activity in this context. The HAR file is written to disk when [`method: Tracing.stopHar`] is called, or when the returned [Disposable] is disposed. + +Only one HAR recording can be active at a time per [BrowserContext]. + +**Usage** + +```js +await context.tracing.startHar('trace.har'); +const page = await context.newPage(); +await page.goto('https://playwright.dev'); +await context.tracing.stopHar(); +``` + +```java +context.tracing().startHar(Paths.get("trace.har")); +Page page = context.newPage(); +page.navigate("https://playwright.dev"); +context.tracing().stopHar(); +``` + +```python async +await context.tracing.start_har("trace.har") +page = await context.new_page() +await page.goto("https://playwright.dev") +await context.tracing.stop_har() +``` + +```python sync +context.tracing.start_har("trace.har") +page = context.new_page() +page.goto("https://playwright.dev") +context.tracing.stop_har() +``` + +```csharp +await context.Tracing.StartHarAsync("trace.har"); +var page = await context.NewPageAsync(); +await page.GotoAsync("https://playwright.dev"); +await context.Tracing.StopHarAsync(); +``` + +### param: Tracing.startHar.path +* since: v1.60 +- `path` <[path]> + +Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, the HAR is saved as a zip archive with response bodies attached as separate files. + +### option: Tracing.startHar.content +* since: v1.60 +- `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 persisted as separate files or entries in the ZIP archive. If `embed` is specified, content is stored inline the HAR file as per HAR specification. Defaults to `attach` for `.zip` output files and to `embed` for all other file extensions. + +### option: Tracing.startHar.mode +* since: v1.60 +- `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`. + +### option: Tracing.startHar.urlFilter +* since: v1.60 +- `urlFilter` <[string]|[RegExp]> + +A glob or regex pattern to filter requests that are stored in the HAR. Defaults to none. + ## async method: Tracing.group * since: v1.49 - returns: <[Disposable]> @@ -400,3 +469,8 @@ Stop the trace chunk. See [`method: Tracing.startChunk`] for more details about - `path` <[path]> Export trace collected since the last [`method: Tracing.startChunk`] call into the file with the given path. + +## async method: Tracing.stopHar +* since: v1.60 + +Stop HAR recording and save the HAR file to the path given to [`method: Tracing.startHar`]. diff --git a/packages/isomorphic/protocolMetainfo.ts b/packages/isomorphic/protocolMetainfo.ts index 9f2e384000d48..14a16108e8ef7 100644 --- a/packages/isomorphic/protocolMetainfo.ts +++ b/packages/isomorphic/protocolMetainfo.ts @@ -96,8 +96,6 @@ export const methodMetainfo = new Map([ ['BrowserContext.disableRecorder', { internal: true, }], ['BrowserContext.exposeConsoleApi', { internal: true, }], ['BrowserContext.newCDPSession', { title: 'Create CDP session', group: 'configuration', }], - ['BrowserContext.harStart', { internal: true, }], - ['BrowserContext.harExport', { internal: true, }], ['BrowserContext.createTempFiles', { internal: true, }], ['BrowserContext.updateSubscription', { internal: true, }], ['BrowserContext.clockFastForward', { title: 'Fast forward clock "{ticksNumber|ticksString}"', }], @@ -289,6 +287,8 @@ export const methodMetainfo = new Map([ ['Tracing.tracingGroupEnd', { title: 'Group end', }], ['Tracing.tracingStopChunk', { title: 'Stop tracing', group: 'configuration', }], ['Tracing.tracingStop', { title: 'Stop tracing', group: 'configuration', }], + ['Tracing.harStart', { internal: true, }], + ['Tracing.harExport', { internal: true, }], ['Artifact.pathAfterFinished', { internal: true, }], ['Artifact.saveAs', { internal: true, }], ['Artifact.saveAsStream', { internal: true, }], diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 2fbe9ef02a9c7..90d8f793c132d 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -19116,6 +19116,8 @@ export interface APIRequestContext { }>; }>; + tracing: Tracing; + [Symbol.asyncDispose](): Promise; } @@ -22148,6 +22150,48 @@ export interface Tracing { title?: string; }): Promise; + /** + * Start recording a HAR (HTTP Archive) of network activity in this context. The HAR file is written to disk when + * [tracing.stopHar()](https://playwright.dev/docs/api/class-tracing#tracing-stop-har) is called, or when the returned + * [Disposable](https://playwright.dev/docs/api/class-disposable) is disposed. + * + * Only one HAR recording can be active at a time per + * [BrowserContext](https://playwright.dev/docs/api/class-browsercontext). + * + * **Usage** + * + * ```js + * await context.tracing.startHar('trace.har'); + * const page = await context.newPage(); + * await page.goto('https://playwright.dev'); + * await context.tracing.stopHar(); + * ``` + * + * @param path Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, the HAR is saved as a zip + * archive with response bodies attached as separate files. + * @param options + */ + startHar(path: string, options?: { + /** + * Optional setting to control resource content management. If `omit` is specified, content is not persisted. If + * `attach` is specified, resources are persisted as separate files or entries in the ZIP archive. If `embed` is + * specified, content is stored inline the HAR file as per HAR specification. Defaults to `attach` for `.zip` output + * files and to `embed` for all other file extensions. + */ + content?: "omit"|"embed"|"attach"; + + /** + * 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. Defaults to none. + */ + urlFilter?: string|RegExp; + }): Promise; + /** * Stop tracing. * @param options @@ -22173,6 +22217,12 @@ export interface Tracing { */ path?: string; }): Promise; + + /** + * Stop HAR recording and save the HAR file to the path given to + * [tracing.startHar(path[, options])](https://playwright.dev/docs/api/class-tracing#tracing-start-har). + */ + stopHar(): Promise; } /** diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index dd2b371fc0870..ed74d8404d089 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -19,7 +19,6 @@ import { headersObjectToArray } from '@isomorphic/headers'; import { urlMatchesEqual } from '@isomorphic/urlMatch'; import { isRegExp, isString } from '@isomorphic/rtti'; import { rewriteErrorMessage } from '@isomorphic/stackTrace'; -import { Artifact } from './artifact'; import { Browser } from './browser'; import { CDPSession } from './cdpSession'; import { ChannelOwner } from './channelOwner'; @@ -76,7 +75,6 @@ export class BrowserContext extends ChannelOwner readonly clock: Clock; readonly _serviceWorkers = new Set(); - private _harRecorders = new Map(); private _closingStatus: 'none' | 'closing' | 'closed' = 'none'; private _closeReason: string | undefined; private _harRouters: HarRouter[] = []; @@ -179,7 +177,7 @@ export class BrowserContext extends ChannelOwner if (!recordHar) return; const defaultContent = recordHar.path.endsWith('.zip') ? 'attach' : 'embed'; - await this._recordIntoHAR(recordHar.path, null, { + await this.tracing._recordIntoHAR(recordHar.path, null, { url: recordHar.urlFilter, updateContent: recordHar.content ?? (recordHar.omitContent ? 'omit' : defaultContent), updateMode: recordHar.mode ?? 'full', @@ -384,27 +382,12 @@ export class BrowserContext extends ChannelOwner await this._updateWebSocketInterceptionPatterns({ title: 'Route WebSockets' }); } - async _recordIntoHAR(har: string, page: Page | null, options: { url?: string | RegExp, updateContent?: 'attach' | 'embed' | 'omit', updateMode?: 'minimal' | 'full'} = {}): Promise { - const { harId } = await this._channel.harStart({ - page: page?._channel, - options: { - zip: har.endsWith('.zip'), - content: options.updateContent ?? 'attach', - urlGlob: isString(options.url) ? options.url : undefined, - urlRegexSource: isRegExp(options.url) ? options.url.source : undefined, - urlRegexFlags: isRegExp(options.url) ? options.url.flags : undefined, - mode: options.updateMode ?? 'minimal', - }, - }); - this._harRecorders.set(harId, { path: har, content: options.updateContent ?? 'attach' }); - } - async routeFromHAR(har: string, options: { url?: string | RegExp, notFound?: 'abort' | 'fallback', update?: boolean, updateContent?: 'attach' | 'embed', updateMode?: 'minimal' | 'full' } = {}): Promise { const localUtils = this._connection.localUtils(); if (!localUtils) throw new Error('Route from har is not supported in thin clients'); if (options.update) { - await this._recordIntoHAR(har, null, options); + await this.tracing._recordIntoHAR(har, null, options); return; } const harRouter = await HarRouter.create(localUtils, har, options.notFound || 'abort', { urlMatch: options.url }); @@ -522,25 +505,7 @@ export class BrowserContext extends ChannelOwner this._closingStatus = 'closing'; await this.request.dispose(options); await this._instrumentation.runBeforeCloseBrowserContext(this); - await this._wrapApiCall(async () => { - for (const [harId, harParams] of this._harRecorders) { - const har = await this._channel.harExport({ harId }); - const artifact = Artifact.from(har.artifact); - // Server side will compress artifact if content is attach or if file is .zip. - const isCompressed = harParams.content === 'attach' || harParams.path.endsWith('.zip'); - const needCompressed = harParams.path.endsWith('.zip'); - if (isCompressed && !needCompressed) { - const localUtils = this._connection.localUtils(); - if (!localUtils) - throw new Error('Uncompressed har is not supported in thin clients'); - await artifact.saveAs(harParams.path + '.tmp'); - await localUtils.harUnzip({ zipFile: harParams.path + '.tmp', harFile: harParams.path }); - } else { - await artifact.saveAs(harParams.path); - } - await artifact.delete(); - } - }, { internal: true }); + await this.tracing._exportAllHars(); await this._channel.close(options); await this._closedPromise; } diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index 6909a75776bb0..95f2e94084a52 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -80,7 +80,7 @@ export class APIRequest implements api.APIRequest { this._contexts.add(context); context._request = this; context._timeoutSettings.setDefaultTimeout(options.timeout ?? this._playwright._defaultContextTimeout); - context._tracing._tracesDir = this._playwright._defaultLaunchOptions?.tracesDir; + context.tracing._tracesDir = this._playwright._defaultLaunchOptions?.tracesDir; await context._instrumentation.runAfterCreateRequestContext(context); return context; } @@ -88,7 +88,7 @@ export class APIRequest implements api.APIRequest { export class APIRequestContext extends ChannelOwner implements api.APIRequestContext { _request?: APIRequest; - readonly _tracing: Tracing; + readonly tracing: Tracing; private _closeReason: string | undefined; _timeoutSettings: TimeoutSettings; @@ -98,7 +98,7 @@ export class APIRequestContext extends ChannelOwner { this._closeReason = options.reason; await this._instrumentation.runBeforeCloseRequestContext(this); + await this.tracing._exportAllHars(); try { await this._channel.dispose(options); } catch (e) { @@ -116,7 +117,7 @@ export class APIRequestContext extends ChannelOwner implements api.Page if (!localUtils) throw new Error('Route from har is not supported in thin clients'); if (options.update) { - await this._browserContext._recordIntoHAR(har, this, options); + await this._browserContext.tracing._recordIntoHAR(har, this, options); return; } const harRouter = await HarRouter.create(localUtils, har, options.notFound || 'abort', { urlMatch: options.url }); diff --git a/packages/playwright-core/src/client/tracing.ts b/packages/playwright-core/src/client/tracing.ts index 5fed143e0a805..4d7d50f32efdd 100644 --- a/packages/playwright-core/src/client/tracing.ts +++ b/packages/playwright-core/src/client/tracing.ts @@ -14,10 +14,12 @@ * limitations under the License. */ +import { isRegExp, isString } from '@isomorphic/rtti'; import { Artifact } from './artifact'; import { ChannelOwner } from './channelOwner'; import { DisposableStub } from './disposable'; +import type { Page } from './page'; import type * as api from '../../types/types'; import type * as channels from '@protocol/channels'; @@ -28,6 +30,8 @@ export class Tracing extends ChannelOwner implements ap _tracesDir: string | undefined; private _stacksId: string | undefined; private _isTracing = false; + private _harId: string | undefined; + private _harRecorders = new Map(); static from(channel: channels.TracingChannel): Tracing { return (channel as any)._object; @@ -92,6 +96,73 @@ export class Tracing extends ChannelOwner implements ap }); } + async startHar(path: string, options: { content?: 'embed' | 'attach' | 'omit', mode?: 'full' | 'minimal', urlFilter?: string | RegExp } = {}) { + await this._wrapApiCall(async () => { + if (this._harId) + throw new Error('HAR recording has already been started'); + const defaultContent = path.endsWith('.zip') ? 'attach' : 'embed'; + this._harId = await this._recordIntoHAR(path, null, { + url: options.urlFilter, + updateContent: options.content ?? defaultContent, + updateMode: options.mode ?? 'full', + }); + }); + return new DisposableStub(() => this.stopHar()); + } + + async stopHar() { + await this._wrapApiCall(async () => { + const harId = this._harId; + if (!harId) + throw new Error('HAR recording has not been started'); + this._harId = undefined; + await this._exportHAR(harId); + }); + } + + async _recordIntoHAR(har: string, page: Page | null, options: { url?: string | RegExp, updateContent?: 'attach' | 'embed' | 'omit', updateMode?: 'minimal' | 'full' } = {}): Promise { + const { harId } = await this._channel.harStart({ + page: page?._channel, + options: { + zip: har.endsWith('.zip'), + content: options.updateContent ?? 'attach', + urlGlob: isString(options.url) ? options.url : undefined, + urlRegexSource: isRegExp(options.url) ? options.url.source : undefined, + urlRegexFlags: isRegExp(options.url) ? options.url.flags : undefined, + mode: options.updateMode ?? 'minimal', + }, + }); + this._harRecorders.set(harId, { path: har, content: options.updateContent ?? 'attach' }); + return harId; + } + + async _exportHAR(harId: string): Promise { + const harParams = this._harRecorders.get(harId); + if (!harParams) + return; + this._harRecorders.delete(harId); + const har = await this._channel.harExport({ harId }); + const artifact = Artifact.from(har.artifact); + const isCompressed = harParams.content === 'attach' || harParams.path.endsWith('.zip'); + const needCompressed = harParams.path.endsWith('.zip'); + if (isCompressed && !needCompressed) { + const localUtils = this._connection.localUtils(); + if (!localUtils) + throw new Error('Uncompressed har is not supported in thin clients'); + await artifact.saveAs(harParams.path + '.tmp'); + await localUtils.harUnzip({ zipFile: harParams.path + '.tmp', harFile: harParams.path }); + } else { + await artifact.saveAs(harParams.path); + } + await artifact.delete(); + } + + async _exportAllHars(): Promise { + await this._wrapApiCall(async () => { + await Promise.all([...this._harRecorders.keys()].map(harId => this._exportHAR(harId))); + }, { internal: true }); + } + private async _doStopChunk(filePath: string | undefined) { this._resetStackCounter(); diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index a0174127d8215..7b6336caead6a 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1144,19 +1144,6 @@ scheme.BrowserContextNewCDPSessionParams = tObject({ scheme.BrowserContextNewCDPSessionResult = tObject({ session: tChannel(['CDPSession']), }); -scheme.BrowserContextHarStartParams = tObject({ - page: tOptional(tChannel(['Page'])), - options: tType('RecordHarOptions'), -}); -scheme.BrowserContextHarStartResult = tObject({ - harId: tString, -}); -scheme.BrowserContextHarExportParams = tObject({ - harId: tOptional(tString), -}); -scheme.BrowserContextHarExportResult = tObject({ - artifact: tChannel(['Artifact']), -}); scheme.BrowserContextCreateTempFilesParams = tObject({ rootDirName: tOptional(tString), items: tArray(tObject({ @@ -2609,6 +2596,19 @@ scheme.TracingTracingStopChunkResult = tObject({ }); scheme.TracingTracingStopParams = tOptional(tObject({})); scheme.TracingTracingStopResult = tOptional(tObject({})); +scheme.TracingHarStartParams = tObject({ + page: tOptional(tChannel(['Page'])), + options: tType('RecordHarOptions'), +}); +scheme.TracingHarStartResult = tObject({ + harId: tString, +}); +scheme.TracingHarExportParams = tObject({ + harId: tOptional(tString), +}); +scheme.TracingHarExportResult = tObject({ + artifact: tChannel(['Artifact']), +}); scheme.ArtifactInitializer = tObject({ absolutePath: tString, }); diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 4b07029c2b4b4..41163bd7e760b 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -18,13 +18,11 @@ import fs from 'fs'; import { rewriteErrorMessage } from '@isomorphic/stackTrace'; -import { createGuid } from '@utils/crypto'; import { debugMode, isUnderTest } from '@utils/debug'; import { Clock } from './clock'; import { Debugger } from './debugger'; import { DialogManager } from './dialog'; import { BrowserContextAPIRequestContext } from './fetch'; -import { HarRecorder } from './har/harRecorder'; import { helper } from './helper'; import { EventMap, SdkObject } from './instrumentation'; import * as network from './network'; @@ -36,7 +34,6 @@ import { Tracing } from './trace/recorder/tracing'; import * as rawStorageSource from '../generated/storageScriptSource'; import { nullProgress } from './progress'; -import type { Artifact } from './artifact'; import type { Browser, BrowserOptions } from './browser'; import type { ConsoleMessage } from './console'; import type { Download } from './download'; @@ -101,7 +98,6 @@ export abstract class BrowserContext extends Sdk readonly _browserContextId: string | undefined; private _selectors: Selectors; private _origins = new Set(); - readonly _harRecorders = new Map(); readonly tracing: Tracing; readonly fetchRequest: BrowserContextAPIRequestContext; private _customCloseHandler?: () => Promise; @@ -544,8 +540,6 @@ export abstract class BrowserContext extends Sdk this.emit(BrowserContext.Events.BeforeClose); this._closedStatus = 'closing'; - for (const harRecorder of this._harRecorders.values()) - await progress.race(harRecorder.flush()); await progress.race(this.tracing.flush()); await progress.race(Promise.all(this.pages().map(page => page.screencast.handlePageOrContextClose()))); @@ -713,17 +707,6 @@ export abstract class BrowserContext extends Sdk await Promise.all(this.pages().map(page => page.safeNonStallingEvaluateInAllFrames(expression, world, options))); } - harStart(page: Page | null, options: channels.RecordHarOptions): string { - const harId = createGuid(); - this._harRecorders.set(harId, new HarRecorder(this, page, options)); - return harId; - } - - async harExport(progress: Progress, harId: string | undefined): Promise { - const recorder = this._harRecorders.get(harId || '')!; - return progress.race(recorder.export()); - } - addRouteInFlight(route: network.Route) { this._routesInFlight.add(route); } diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 734ba79035e65..83ea86ab1f287 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -20,7 +20,6 @@ import path from 'path'; import { deserializeURLMatch, urlMatches } from '@isomorphic/urlMatch'; import { createGuid } from '@utils/crypto'; import { BrowserContext } from '../browserContext'; -import { ArtifactDispatcher } from './artifactDispatcher'; import { CDPSessionDispatcher } from './cdpSessionDispatcher'; import { DebuggerDispatcher } from './debuggerDispatcher'; import { DialogDispatcher } from './dialogDispatcher'; @@ -367,18 +366,6 @@ export class BrowserContextDispatcher extends Dispatcher { - const harId = this._context.harStart(params.page ? (params.page as PageDispatcher)._object : null, params.options); - return { harId }; - } - - async harExport(params: channels.BrowserContextHarExportParams, progress: Progress): Promise { - const artifact = await this._context.harExport(progress, params.harId); - if (!artifact) - throw new Error('No HAR artifact. Ensure record.harPath is set.'); - return { artifact: ArtifactDispatcher.from(this, artifact) }; - } - async clockFastForward(params: channels.BrowserContextClockFastForwardParams, progress: Progress): Promise { await progress.race(this._context.clock.fastForward(params.ticksString ?? params.ticksNumber ?? 0)); } diff --git a/packages/playwright-core/src/server/dispatchers/tracingDispatcher.ts b/packages/playwright-core/src/server/dispatchers/tracingDispatcher.ts index e632509720a47..7569e11fb4cf0 100644 --- a/packages/playwright-core/src/server/dispatchers/tracingDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/tracingDispatcher.ts @@ -20,6 +20,7 @@ import { nullProgress } from '../progress'; import type { BrowserContextDispatcher } from './browserContextDispatcher'; import type { APIRequestContextDispatcher } from './networkDispatchers'; +import type { PageDispatcher } from './pageDispatcher'; import type { Tracing } from '../trace/recorder/tracing'; import type * as channels from '@protocol/channels'; import type { Progress } from '@protocol/progress'; @@ -64,6 +65,18 @@ export class TracingDispatcher extends Dispatcher { + const harId = this._object.harStart(params.page ? (params.page as PageDispatcher)._object : null, params.options); + return { harId }; + } + + async harExport(params: channels.TracingHarExportParams, progress: Progress): Promise { + const artifact = await this._object.harExport(progress, params.harId); + if (!artifact) + throw new Error('No HAR artifact. Ensure record.harPath is set.'); + return { artifact: ArtifactDispatcher.from(this, artifact) }; + } + override _onDispose() { // Avoid protocol calls for the closed context. if (this._started) diff --git a/packages/playwright-core/src/server/har/harRecorder.ts b/packages/playwright-core/src/server/har/harRecorder.ts index 210e03f41ffe8..57be2659ab72d 100644 --- a/packages/playwright-core/src/server/har/harRecorder.ts +++ b/packages/playwright-core/src/server/har/harRecorder.ts @@ -19,9 +19,10 @@ import path from 'path'; import * as yazl from 'yazl'; import { ManualPromise } from '@isomorphic/manualPromise'; -import { createGuid } from '@utils/crypto'; import { Artifact } from '../artifact'; import { HarTracer } from './harTracer'; + +import type { APIRequestContext } from '../fetch'; import type { BrowserContext } from '../browserContext'; import type { HarTracerDelegate } from './harTracer'; import type { ZipFile } from 'yazl'; @@ -38,8 +39,8 @@ export class HarRecorder implements HarTracerDelegate { private _zipFile: ZipFile | null = null; private _writtenZipEntries = new Set(); - constructor(context: BrowserContext, page: Page | null, options: channels.RecordHarOptions) { - this._artifact = new Artifact(context, path.join(context._browser.options.artifactsDir, `${createGuid()}.har`)); + constructor(context: BrowserContext | APIRequestContext, harFilePath: string, page: Page | null, options: channels.RecordHarOptions) { + this._artifact = new Artifact(context, harFilePath); const urlFilterRe = options.urlRegexSource !== undefined && options.urlRegexFlags !== undefined ? new RegExp(options.urlRegexSource, options.urlRegexFlags) : undefined; const expectsZip = !!options.zip; const content = options.content || (expectsZip ? 'attach' : 'embed'); @@ -80,6 +81,8 @@ export class HarRecorder implements HarTracerDelegate { const harFileContent = jsonStringify({ log }); + await fs.promises.mkdir(path.dirname(this._artifact.localPath()), { recursive: true }); + if (this._zipFile) { const result = new ManualPromise(); (this._zipFile as unknown as EventEmitter).on('error', error => result.reject(error)); diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index 452f31329461b..aa8b4b8b9d6d5 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -32,6 +32,7 @@ import { Artifact } from '../../artifact'; import { BrowserContext } from '../../browserContext'; import { Dispatcher } from '../../dispatchers/dispatcher'; import { serializeError } from '../../errors'; +import { HarRecorder } from '../../har/harRecorder'; import { HarTracer } from '../../har/harTracer'; import { SdkObject } from '../../instrumentation'; import { Page } from '../../page'; @@ -46,7 +47,7 @@ import type { Download } from '../../download'; import type { APIRequestContext } from '../../fetch'; import type { HarTracerDelegate } from '../../har/harTracer'; import type { CallMetadata, InstrumentationListener } from '../../instrumentation'; -import type { StackFrame, TracingTracingStopChunkParams } from '@protocol/channels'; +import type { RecordHarOptions, StackFrame, TracingTracingStopChunkParams } from '@protocol/channels'; import type * as har from '@trace/har'; import type { FrameSnapshot } from '@trace/snapshot'; import type * as trace from '@trace/trace'; @@ -95,6 +96,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps private _contextCreatedEvent: trace.ContextCreatedTraceEvent; private _pendingHarEntries = new Set(); private _started = false; + readonly harRecorders = new Map(); constructor(context: BrowserContext | APIRequestContext, tracesDir: string | undefined) { super(context, 'tracing'); @@ -348,9 +350,26 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps async flush() { this.abort(); + for (const harRecorder of this.harRecorders.values()) + await harRecorder.flush(); await this._fs.syncAndGetError(); } + harStart(page: Page | null, options: RecordHarOptions): string { + const harId = createGuid(); + const artifactsDir = this._context instanceof BrowserContext ? this._context._browser.options.artifactsDir : this._createTracesDirIfNeeded(); + const harFilePath = path.join(artifactsDir, `${harId}.har`); + this.harRecorders.set(harId, new HarRecorder(this._context, harFilePath, page, options)); + return harId; + } + + async harExport(progress: Progress, harId: string | undefined): Promise { + const recorder = this.harRecorders.get(harId || '')!; + const artifact = await progress.race(recorder.export()); + this.harRecorders.delete(harId || ''); + return artifact; + } + private _closeAllGroups() { while (this._currentGroupId()) this._groupEnd(); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 2fbe9ef02a9c7..90d8f793c132d 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -19116,6 +19116,8 @@ export interface APIRequestContext { }>; }>; + tracing: Tracing; + [Symbol.asyncDispose](): Promise; } @@ -22148,6 +22150,48 @@ export interface Tracing { title?: string; }): Promise; + /** + * Start recording a HAR (HTTP Archive) of network activity in this context. The HAR file is written to disk when + * [tracing.stopHar()](https://playwright.dev/docs/api/class-tracing#tracing-stop-har) is called, or when the returned + * [Disposable](https://playwright.dev/docs/api/class-disposable) is disposed. + * + * Only one HAR recording can be active at a time per + * [BrowserContext](https://playwright.dev/docs/api/class-browsercontext). + * + * **Usage** + * + * ```js + * await context.tracing.startHar('trace.har'); + * const page = await context.newPage(); + * await page.goto('https://playwright.dev'); + * await context.tracing.stopHar(); + * ``` + * + * @param path Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, the HAR is saved as a zip + * archive with response bodies attached as separate files. + * @param options + */ + startHar(path: string, options?: { + /** + * Optional setting to control resource content management. If `omit` is specified, content is not persisted. If + * `attach` is specified, resources are persisted as separate files or entries in the ZIP archive. If `embed` is + * specified, content is stored inline the HAR file as per HAR specification. Defaults to `attach` for `.zip` output + * files and to `embed` for all other file extensions. + */ + content?: "omit"|"embed"|"attach"; + + /** + * 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. Defaults to none. + */ + urlFilter?: string|RegExp; + }): Promise; + /** * Stop tracing. * @param options @@ -22173,6 +22217,12 @@ export interface Tracing { */ path?: string; }): Promise; + + /** + * Stop HAR recording and save the HAR file to the path given to + * [tracing.startHar(path[, options])](https://playwright.dev/docs/api/class-tracing#tracing-start-har). + */ + stopHar(): Promise; } /** diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 433241a8c147f..331a2c8ebe874 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -729,11 +729,11 @@ class ArtifactsRecorder { } async didCreateRequestContext(context: APIRequestContextImpl) { - await this._startTraceChunkOnContextCreation(context, context._tracing); + await this._startTraceChunkOnContextCreation(context, context.tracing); } async willCloseRequestContext(context: APIRequestContextImpl) { - await this._stopTracing(context, context._tracing); + await this._stopTracing(context, context.tracing); } async didFinishTestFunction() { @@ -750,7 +750,7 @@ class ArtifactsRecorder { await Promise.all(leftoverContexts.map(async context => { await this._stopTracing(context, context.tracing); }).concat(leftoverApiRequests.map(async context => { - await this._stopTracing(context, context._tracing); + await this._stopTracing(context, context.tracing); }))); await this._screenshotRecorder.persistTemporary(); diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 323143bb1af9d..0094e5cc02a14 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -1691,8 +1691,6 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT disableRecorder(params?: BrowserContextDisableRecorderParams, progress?: Progress): Promise; exposeConsoleApi(params?: BrowserContextExposeConsoleApiParams, progress?: Progress): Promise; newCDPSession(params: BrowserContextNewCDPSessionParams, progress?: Progress): Promise; - harStart(params: BrowserContextHarStartParams, progress?: Progress): Promise; - harExport(params: BrowserContextHarExportParams, progress?: Progress): Promise; createTempFiles(params: BrowserContextCreateTempFilesParams, progress?: Progress): Promise; updateSubscription(params: BrowserContextUpdateSubscriptionParams, progress?: Progress): Promise; clockFastForward(params: BrowserContextClockFastForwardParams, progress?: Progress): Promise; @@ -2000,25 +1998,6 @@ export type BrowserContextNewCDPSessionOptions = { export type BrowserContextNewCDPSessionResult = { session: CDPSessionChannel, }; -export type BrowserContextHarStartParams = { - page?: PageChannel, - options: RecordHarOptions, -}; -export type BrowserContextHarStartOptions = { - page?: PageChannel, -}; -export type BrowserContextHarStartResult = { - harId: string, -}; -export type BrowserContextHarExportParams = { - harId?: string, -}; -export type BrowserContextHarExportOptions = { - harId?: string, -}; -export type BrowserContextHarExportResult = { - artifact: ArtifactChannel, -}; export type BrowserContextCreateTempFilesParams = { rootDirName?: string, items: { @@ -4474,6 +4453,8 @@ export interface TracingChannel extends TracingEventTarget, Channel { tracingGroupEnd(params?: TracingTracingGroupEndParams, progress?: Progress): Promise; tracingStopChunk(params: TracingTracingStopChunkParams, progress?: Progress): Promise; tracingStop(params?: TracingTracingStopParams, progress?: Progress): Promise; + harStart(params: TracingHarStartParams, progress?: Progress): Promise; + harExport(params: TracingHarExportParams, progress?: Progress): Promise; } export type TracingTracingStartParams = { name?: string, @@ -4531,6 +4512,25 @@ export type TracingTracingStopChunkResult = { export type TracingTracingStopParams = {}; export type TracingTracingStopOptions = {}; export type TracingTracingStopResult = void; +export type TracingHarStartParams = { + page?: PageChannel, + options: RecordHarOptions, +}; +export type TracingHarStartOptions = { + page?: PageChannel, +}; +export type TracingHarStartResult = { + harId: string, +}; +export type TracingHarExportParams = { + harId?: string, +}; +export type TracingHarExportOptions = { + harId?: string, +}; +export type TracingHarExportResult = { + artifact: ArtifactChannel, +}; export interface TracingEvents { } diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index c11dad3b8f4e1..964ed19de1cd7 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1442,21 +1442,6 @@ BrowserContext: returns: session: CDPSession - harStart: - internal: true - parameters: - page: Page? - options: RecordHarOptions - returns: - harId: string - - harExport: - internal: true - parameters: - harId: string? - returns: - artifact: Artifact - createTempFiles: internal: true parameters: @@ -4023,6 +4008,21 @@ Tracing: title: Stop tracing group: configuration + harStart: + internal: true + parameters: + page: Page? + options: RecordHarOptions + returns: + harId: string + + harExport: + internal: true + parameters: + harId: string? + returns: + artifact: Artifact + Artifact: type: interface diff --git a/tests/library/har.spec.ts b/tests/library/har.spec.ts index 1992695dbd3d8..c7de2865d3504 100644 --- a/tests/library/har.spec.ts +++ b/tests/library/har.spec.ts @@ -914,3 +914,34 @@ it('should not hang on slow chunked response', async ({ browserName, browser, co expect(log.browser!.name).toBe(browserName); expect(log.browser!.version).toBe(browser.version()); }); + +it.describe('tracing.startHar', () => { + it('should record a HAR with options', async ({ contextFactory, server }, testInfo) => { + const context = await contextFactory(); + const harPath = testInfo.outputPath('tracing.har'); + await context.tracing.startHar(harPath, { mode: 'minimal', urlFilter: '**/one-style.css' }); + const page = await context.newPage(); + await page.goto(server.PREFIX + '/one-style.html'); + await context.tracing.stopHar(); + await context.close(); + + const log = JSON.parse(fs.readFileSync(harPath).toString()).log as Log; + const urls = log.entries.map(e => e.request.url); + expect(urls).toEqual([server.PREFIX + '/one-style.css']); + // Minimal mode drops body sizes. + expect(log.entries[0].request.bodySize).toBe(-1); + }); + + it('should record a zipped HAR for APIRequestContext', async ({ playwright, server }, testInfo) => { + const request = await playwright.request.newContext(); + const harPath = testInfo.outputPath('tracing.har.zip'); + await request.tracing.startHar(harPath, { content: 'attach' }); + await request.get(server.PREFIX + '/simple.json'); + await request.tracing.stopHar(); + await request.dispose(); + + const resources = await parseHar(harPath); + const log = JSON.parse(resources.get('har.har')!.toString()).log as Log; + expect(log.entries.some(e => e.request.url === server.PREFIX + '/simple.json')).toBe(true); + }); +}); diff --git a/tests/library/tracing.spec.ts b/tests/library/tracing.spec.ts index 3dd7a44bd60c8..b1232072f9961 100644 --- a/tests/library/tracing.spec.ts +++ b/tests/library/tracing.spec.ts @@ -633,11 +633,11 @@ test('should hide internal stack frames in expect', async ({ context, page }, te }); test('should record global request trace', async ({ request, context, server }, testInfo) => { - await (request as any)._tracing.start({ snapshots: true }); + await request.tracing.start({ snapshots: true }); const url = server.PREFIX + '/simple.json'; await request.get(url); const tracePath = testInfo.outputPath('trace.zip'); - await (request as any)._tracing.stop({ path: tracePath }); + await request.tracing.stop({ path: tracePath }); const trace = await parseTraceRaw(tracePath); const actions = trace.events.filter(e => e.type === 'resource-snapshot'); @@ -661,8 +661,8 @@ test('should record global request trace', async ({ request, context, server }, test('should store global request traces separately', async ({ request, server, playwright, browserName, mode }, testInfo) => { const request2 = await playwright.request.newContext(); await Promise.all([ - (request as any)._tracing.start({ snapshots: true }), - (request2 as any)._tracing.start({ snapshots: true }) + request.tracing.start({ snapshots: true }), + request2.tracing.start({ snapshots: true }) ]); const url = server.PREFIX + '/simple.json'; await Promise.all([ @@ -672,8 +672,8 @@ test('should store global request traces separately', async ({ request, server, const tracePath = testInfo.outputPath('trace.zip'); const trace2Path = testInfo.outputPath('trace2.zip'); await Promise.all([ - (request as any)._tracing.stop({ path: tracePath }), - (request2 as any)._tracing.stop({ path: trace2Path }) + request.tracing.stop({ path: tracePath }), + request2.tracing.stop({ path: trace2Path }) ]); { const trace = await parseTraceRaw(tracePath); @@ -697,13 +697,13 @@ test('should store global request traces separately', async ({ request, server, test('should store postData for global request', async ({ request, server }, testInfo) => { testInfo.annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/15031' }); - await (request as any)._tracing.start({ snapshots: true }); + await request.tracing.start({ snapshots: true }); const url = server.PREFIX + '/simple.json'; await request.post(url, { data: 'test' }); const tracePath = testInfo.outputPath('trace.zip'); - await (request as any)._tracing.stop({ path: tracePath }); + await request.tracing.stop({ path: tracePath }); const trace = await parseTraceRaw(tracePath); const actions = trace.events.filter(e => e.type === 'resource-snapshot');