diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index f961e5b1e4..00d060e82c 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -1044,8 +1044,13 @@ Path to a [HAR](http://www.softwareishard.com/blog/har-12-spec) file with prerec Defaults to abort. +### option: BrowserContext.routeFromHAR.update +- `update` ? + +If specified, updates the given HAR with the actual network information instead of serving from file. + ### option: BrowserContext.routeFromHAR.url -- `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]> +- `url` <[string]|[RegExp]> A glob pattern, regular expression or predicate to match the request URL. Only requests with URL matching the pattern will be served from the HAR file. If not specified, all requests are served from the HAR file. diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index bdbf9ad905..54a3e35930 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -2751,8 +2751,13 @@ Path to a [HAR](http://www.softwareishard.com/blog/har-12-spec) file with prerec Defaults to abort. +### option: Page.routeFromHAR.update +- `update` ? + +If specified, updates the given HAR with the actual network information instead of serving from file. + ### option: Page.routeFromHAR.url -- `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]> +- `url` <[string]|[RegExp]> A glob pattern, regular expression or predicate to match the request URL. Only requests with URL matching the pattern will be served from the HAR file. If not specified, all requests are served from the HAR file. diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 3ae8ec4bd6..46b1a35a76 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -561,8 +561,8 @@ Logger sink for Playwright logging. - `recordHar` <[Object]> - `omitContent` ?<[boolean]> Optional setting to control whether to omit request content from the HAR. Defaults to `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. + - `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 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. + - `path` <[path]> Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `content: 'attach'` 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. diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index be6de760a9..b21b1a2618 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -58,6 +58,7 @@ export class BrowserContext extends ChannelOwner readonly _backgroundPages = new Set(); readonly _serviceWorkers = new Set(); readonly _isChromium: boolean; + private _harRecorders = new Map(); static from(context: channels.BrowserContextChannel): BrowserContext { return (context as any)._object; @@ -100,6 +101,8 @@ export class BrowserContext extends ChannelOwner _setBrowserType(browserType: BrowserType) { this._browserType = browserType; browserType._contexts.add(this); + if (this._options.recordHar) + this._harRecorders.set('', { path: this._options.recordHar.path, content: this._options.recordHar.content }); } private _onPage(page: Page): void { @@ -270,7 +273,24 @@ export class BrowserContext extends ChannelOwner await this._channel.setNetworkInterceptionEnabled({ enabled: true }); } - async routeFromHAR(har: string, options: { url?: URLMatch, notFound?: 'abort' | 'fallback' } = {}): Promise { + async _recordIntoHAR(har: string, page: Page | null, options: { url?: string | RegExp, notFound?: 'abort' | 'fallback', update?: boolean } = {}): Promise { + const { harId } = await this._channel.harStart({ + page: page?._channel, + options: prepareRecordHarOptions({ + path: har, + content: 'attach', + mode: 'minimal', + urlFilter: options.url + })! + }); + this._harRecorders.set(harId, { path: har, content: 'attach' }); + } + + async routeFromHAR(har: string, options: { url?: string | RegExp, notFound?: 'abort' | 'fallback', update?: boolean } = {}): Promise { + if (options.update) { + await this._recordIntoHAR(har, null, options); + return; + } const harRouter = await HarRouter.create(this._connection.localUtils(), har, options.notFound || 'abort', { urlMatch: options.url }); harRouter.addContextRoute(this); } @@ -340,10 +360,18 @@ export class BrowserContext extends ChannelOwner try { await this._wrapApiCall(async () => { await this._browserType?._onWillCloseContext?.(this); - if (this._options.recordHar) { - const har = await this._channel.harExport(); + for (const [harId, harParams] of this._harRecorders) { + const har = await this._channel.harExport({ harId }); const artifact = Artifact.from(har.artifact); - await artifact.saveAs(this._options.recordHar.path); + // 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) { + await artifact.saveAs(harParams.path + '.tmp'); + await this._connection.localUtils()._channel.harUnzip({ zipFile: harParams.path + '.tmp', harFile: harParams.path }); + } else { + await artifact.saveAs(harParams.path); + } await artifact.delete(); } }, true); diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index fd4283b18d..17c9252f01 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -468,7 +468,11 @@ export class Page extends ChannelOwner implements api.Page await this._channel.setNetworkInterceptionEnabled({ enabled: true }); } - async routeFromHAR(har: string, options: { url?: URLMatch, notFound?: 'abort' | 'fallback' } = {}): Promise { + async routeFromHAR(har: string, options: { url?: string | RegExp, notFound?: 'abort' | 'fallback', update?: boolean } = {}): Promise { + if (options.update) { + await this._browserContext._recordIntoHAR(har, this, options); + return; + } const harRouter = await HarRouter.create(this._connection.localUtils(), har, options.notFound || 'abort', { urlMatch: options.url }); harRouter.addPageRoute(this); } diff --git a/packages/playwright-core/src/protocol/channels.ts b/packages/playwright-core/src/protocol/channels.ts index bd70c0196a..14ac2e710e 100644 --- a/packages/playwright-core/src/protocol/channels.ts +++ b/packages/playwright-core/src/protocol/channels.ts @@ -382,6 +382,7 @@ export interface LocalUtilsChannel extends LocalUtilsEventTarget, Channel { harOpen(params: LocalUtilsHarOpenParams, metadata?: Metadata): Promise; harLookup(params: LocalUtilsHarLookupParams, metadata?: Metadata): Promise; harClose(params: LocalUtilsHarCloseParams, metadata?: Metadata): Promise; + harUnzip(params: LocalUtilsHarUnzipParams, metadata?: Metadata): Promise; } export type LocalUtilsZipParams = { zipFile: string, @@ -427,6 +428,14 @@ export type LocalUtilsHarCloseOptions = { }; export type LocalUtilsHarCloseResult = void; +export type LocalUtilsHarUnzipParams = { + zipFile: string, + harFile: string, +}; +export type LocalUtilsHarUnzipOptions = { + +}; +export type LocalUtilsHarUnzipResult = void; export interface LocalUtilsEvents { } @@ -1119,7 +1128,8 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT pause(params?: BrowserContextPauseParams, metadata?: Metadata): Promise; recorderSupplementEnable(params: BrowserContextRecorderSupplementEnableParams, metadata?: Metadata): Promise; newCDPSession(params: BrowserContextNewCDPSessionParams, metadata?: Metadata): Promise; - harExport(params?: BrowserContextHarExportParams, metadata?: Metadata): Promise; + harStart(params: BrowserContextHarStartParams, metadata?: Metadata): Promise; + harExport(params: BrowserContextHarExportParams, metadata?: Metadata): Promise; createTempFile(params: BrowserContextCreateTempFileParams, metadata?: Metadata): Promise; } export type BrowserContextBindingCallEvent = { @@ -1325,8 +1335,22 @@ export type BrowserContextNewCDPSessionOptions = { export type BrowserContextNewCDPSessionResult = { session: CDPSessionChannel, }; -export type BrowserContextHarExportParams = {}; -export type BrowserContextHarExportOptions = {}; +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, }; diff --git a/packages/playwright-core/src/protocol/protocol.yml b/packages/playwright-core/src/protocol/protocol.yml index bc9cc6abba..310b58a062 100644 --- a/packages/playwright-core/src/protocol/protocol.yml +++ b/packages/playwright-core/src/protocol/protocol.yml @@ -520,6 +520,11 @@ LocalUtils: parameters: harId: string + harUnzip: + parameters: + zipFile: string + harFile: string + Root: type: interface @@ -926,7 +931,16 @@ BrowserContext: returns: session: CDPSession + harStart: + parameters: + page: Page? + options: RecordHarOptions + returns: + harId: string + harExport: + parameters: + harId: string? returns: artifact: Artifact diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 90dafa6121..c817833fe1 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -220,6 +220,10 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { scheme.LocalUtilsHarCloseParams = tObject({ harId: tString, }); + scheme.LocalUtilsHarUnzipParams = tObject({ + zipFile: tString, + harFile: tString, + }); scheme.RootInitializeParams = tObject({ sdkLanguage: tString, }); @@ -527,7 +531,13 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { page: tOptional(tChannel('Page')), frame: tOptional(tChannel('Frame')), }); - scheme.BrowserContextHarExportParams = tOptional(tObject({})); + scheme.BrowserContextHarStartParams = tObject({ + page: tOptional(tChannel('Page')), + options: tType('RecordHarOptions'), + }); + scheme.BrowserContextHarExportParams = tObject({ + harId: tOptional(tString), + }); scheme.BrowserContextCreateTempFileParams = tObject({ name: tString, }); diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index d6d9461599..d75e045e27 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -17,7 +17,7 @@ import * as os from 'os'; import { TimeoutSettings } from '../common/timeoutSettings'; -import { debugMode } from '../utils'; +import { createGuid, debugMode } from '../utils'; import { mkdirIfNeeded } from '../utils/fileUtils'; import type { Browser, BrowserOptions } from './browser'; import type { Download } from './download'; @@ -40,6 +40,7 @@ import { HarRecorder } from './har/harRecorder'; import { Recorder } from './recorder'; import * as consoleApiSource from '../generated/consoleApiSource'; import { BrowserContextAPIRequestContext } from './fetch'; +import type { Artifact } from './artifact'; export abstract class BrowserContext extends SdkObject { static Events = { @@ -67,7 +68,7 @@ export abstract class BrowserContext extends SdkObject { readonly _browserContextId: string | undefined; private _selectors?: Selectors; private _origins = new Set(); - readonly _harRecorder: HarRecorder | undefined; + readonly _harRecorders = new Map(); readonly tracing: Tracing; readonly fetchRequest: BrowserContextAPIRequestContext; private _customCloseHandler?: () => Promise; @@ -87,7 +88,7 @@ export abstract class BrowserContext extends SdkObject { this.fetchRequest = new BrowserContextAPIRequestContext(this); if (this._options.recordHar) - this._harRecorder = new HarRecorder(this, this._options.recordHar); + this._harRecorders.set('', new HarRecorder(this, null, this._options.recordHar)); this.tracing = new Tracing(this, browser.options.tracesDir); } @@ -316,7 +317,8 @@ export abstract class BrowserContext extends SdkObject { this.emit(BrowserContext.Events.BeforeClose); this._closedStatus = 'closing'; - await this._harRecorder?.flush(); + for (const harRecorder of this._harRecorders.values()) + await harRecorder.flush(); await this.tracing.flush(); // Cleanup. @@ -442,6 +444,17 @@ export abstract class BrowserContext extends SdkObject { this.on(BrowserContext.Events.Page, installInPage); return Promise.all(this.pages().map(installInPage)); } + + async _harStart(page: Page | null, options: channels.RecordHarOptions): Promise { + const harId = createGuid(); + this._harRecorders.set(harId, new HarRecorder(this, page, options)); + return harId; + } + + async _harExport(harId: string | undefined): Promise { + const recorder = this._harRecorders.get(harId || '')!; + return recorder.export(); + } } export function assertBrowserContextIsNotOwned(context: BrowserContext) { diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index ecfc42ea95..6a50fb67ac 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -213,8 +213,13 @@ export class BrowserContextDispatcher extends Dispatcher { + const harId = await this._context._harStart(params.page ? (params.page as PageDispatcher)._object : null, params.options); + return { harId }; + } + async harExport(params: channels.BrowserContextHarExportParams): Promise { - const artifact = await this._context._harRecorder?.export(); + const artifact = await this._context._harExport(params.harId); if (!artifact) throw new Error('No HAR artifact. Ensure record.harPath is set.'); return { artifact: new ArtifactDispatcher(this._scope, artifact) }; diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index f0ff7c4c7d..64d29e477f 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -124,6 +124,20 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels. harBackend.dispose(); } } + + async harUnzip(params: channels.LocalUtilsHarUnzipParams, metadata?: channels.Metadata): Promise { + const dir = path.dirname(params.zipFile); + const zipFile = new ZipFile(params.zipFile); + for (const entry of await zipFile.entries()) { + const buffer = await zipFile.read(entry); + if (entry === 'har.har') + await fs.promises.writeFile(params.harFile, buffer); + else + await fs.promises.writeFile(path.join(dir, entry), buffer); + } + zipFile.close(); + await fs.promises.unlink(params.zipFile); + } } const redirectStatus = [301, 302, 303, 307, 308]; diff --git a/packages/playwright-core/src/server/har/harRecorder.ts b/packages/playwright-core/src/server/har/harRecorder.ts index b4c4d1c19f..d4af1310fd 100644 --- a/packages/playwright-core/src/server/har/harRecorder.ts +++ b/packages/playwright-core/src/server/har/harRecorder.ts @@ -26,6 +26,7 @@ import type { ZipFile } from '../../zipBundle'; import { ManualPromise } from '../../utils/manualPromise'; import type EventEmitter from 'events'; import { createGuid } from '../../utils'; +import type { Page } from '../page'; export class HarRecorder { private _artifact: Artifact; @@ -35,12 +36,12 @@ export class HarRecorder { private _zipFile: ZipFile | null = null; private _writtenZipEntries = new Set(); - constructor(context: BrowserContext, options: channels.RecordHarOptions) { + constructor(context: BrowserContext, page: Page | null, options: channels.RecordHarOptions) { this._artifact = new Artifact(context, path.join(context._browser.options.artifactsDir, `${createGuid()}.har`)); const urlFilterRe = options.urlRegexSource !== undefined && options.urlRegexFlags !== undefined ? new RegExp(options.urlRegexSource, options.urlRegexFlags) : undefined; const expectsZip = options.path.endsWith('.zip'); const content = options.content || (expectsZip ? 'attach' : 'embed'); - this._tracer = new HarTracer(context, this, { + this._tracer = new HarTracer(context, page, this, { content, slimMode: options.mode === 'minimal', includeTraceInfo: false, diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index 0dcf28db5a..c9eb736d52 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -64,9 +64,11 @@ export class HarTracer { private _started = false; private _entrySymbol: symbol; private _baseURL: string | undefined; + private _page: Page | null; - constructor(context: BrowserContext | APIRequestContext, delegate: HarTracerDelegate, options: HarTracerOptions) { + constructor(context: BrowserContext | APIRequestContext, page: Page | null, delegate: HarTracerDelegate, options: HarTracerOptions) { this._context = context; + this._page = page; this._delegate = delegate; this._options = options; if (options.slimMode) { @@ -92,7 +94,7 @@ export class HarTracer { ]; if (this._context instanceof BrowserContext) { this._eventListeners.push( - eventsHelper.addEventListener(this._context, BrowserContext.Events.Page, (page: Page) => this._ensurePageEntry(page)), + eventsHelper.addEventListener(this._context, BrowserContext.Events.Page, (page: Page) => this._createPageEntryIfNeeded(page)), eventsHelper.addEventListener(this._context, BrowserContext.Events.Request, (request: network.Request) => this._onRequest(request)), eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestFinished, ({ request, response }) => this._onRequestFinished(request, response).catch(() => {})), eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestFailed, request => this._onRequestFailed(request)), @@ -108,9 +110,11 @@ export class HarTracer { return (request as any)[this._entrySymbol]; } - private _ensurePageEntry(page: Page): har.Page | undefined { + private _createPageEntryIfNeeded(page: Page): har.Page | undefined { if (this._options.omitPages) return; + if (this._page && page !== this._page) + return; let pageEntry = this._pageEntries.get(page); if (!pageEntry) { pageEntry = { @@ -228,11 +232,13 @@ export class HarTracer { if (!this._shouldIncludeEntryWithUrl(request.url())) return; const page = request.frame()._page; + if (this._page && page !== this._page) + return; const url = network.parsedURL(request.url()); if (!url) return; - const pageEntry = this._ensurePageEntry(page); + const pageEntry = this._createPageEntryIfNeeded(page); const harEntry = createHarEntry(request.method(), url, request.frame().guid, this._options); if (pageEntry) harEntry.pageref = pageEntry.id; @@ -252,10 +258,10 @@ export class HarTracer { private async _onRequestFinished(request: network.Request, response: network.Response | null) { if (!response) return; - const page = request.frame()._page; const harEntry = this._entryForRequest(request); if (!harEntry) return; + const page = request.frame()._page; const httpVersion = response.httpVersion(); harEntry.request.httpVersion = httpVersion; @@ -353,11 +359,11 @@ export class HarTracer { } private _onResponse(response: network.Response) { - const page = response.frame()._page; - const pageEntry = this._ensurePageEntry(page); const harEntry = this._entryForRequest(response.request()); if (!harEntry) return; + const page = response.frame()._page; + const pageEntry = this._createPageEntryIfNeeded(page); const request = response.request(); harEntry.response = { diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index aa11d0e61d..e39c726a6c 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -88,7 +88,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps super(context, 'Tracing'); this._context = context; this._precreatedTracesDir = tracesDir; - this._harTracer = new HarTracer(context, this, { + this._harTracer = new HarTracer(context, null, this, { content: 'attach', includeTraceInfo: true, waitForContentOnStop: false, diff --git a/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts b/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts index ed2c25d324..6f272a62a0 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', includeTraceInfo: true, waitForContentOnStop: false, skipScripts: true }); + this._harTracer = new HarTracer(context, null, 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 5ac7dbdda8..15e223b442 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -3185,11 +3185,16 @@ export interface Page { */ notFound?: "abort"|"fallback"; + /** + * If specified, updates the given HAR with the actual network information instead of serving from file. + */ + update?: boolean; + /** * A glob pattern, regular expression or predicate to match the request URL. Only requests with URL matching the pattern * will be served from the HAR file. If not specified, all requests are served from the HAR file. */ - url?: string|RegExp|((url: URL) => boolean); + url?: string|RegExp; }): Promise; /** @@ -7137,11 +7142,16 @@ export interface BrowserContext { */ notFound?: "abort"|"fallback"; + /** + * If specified, updates the given HAR with the actual network information instead of serving from file. + */ + update?: boolean; + /** * A glob pattern, regular expression or predicate to match the request URL. Only requests with URL matching the pattern * will be served from the HAR file. If not specified, all requests are served from the HAR file. */ - url?: string|RegExp|((url: URL) => boolean); + url?: string|RegExp; }): Promise; /** @@ -10666,13 +10676,15 @@ export interface BrowserType { /** * 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. + * is specified, resources are persistet 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"; /** - * Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `attach` mode is used by default. + * Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `content: 'attach'` is used by + * default. */ path: string; @@ -11859,13 +11871,15 @@ export interface AndroidDevice { /** * 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. + * is specified, resources are persistet 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"; /** - * Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `attach` mode is used by default. + * Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `content: 'attach'` is used by + * default. */ path: string; @@ -13435,13 +13449,15 @@ export interface Browser extends EventEmitter { /** * 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. + * is specified, resources are persistet 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"; /** - * Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `attach` mode is used by default. + * Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `content: 'attach'` is used by + * default. */ path: string; @@ -14227,13 +14243,15 @@ export interface Electron { /** * 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. + * is specified, resources are persistet 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"; /** - * Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `attach` mode is used by default. + * Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `content: 'attach'` is used by + * default. */ path: string; @@ -16054,13 +16072,15 @@ export interface BrowserContextOptions { /** * 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. + * is specified, resources are persistet 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"; /** - * Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `attach` mode is used by default. + * Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `content: 'attach'` is used by + * default. */ path: string; diff --git a/tests/library/browsercontext-har.spec.ts b/tests/library/browsercontext-har.spec.ts index 219ed56ff6..6f2317d3fb 100644 --- a/tests/library/browsercontext-har.spec.ts +++ b/tests/library/browsercontext-har.spec.ts @@ -269,6 +269,27 @@ it('should round-trip har.zip', async ({ contextFactory, isAndroid, server }, te await expect(page2.locator('body')).toHaveCSS('background-color', 'rgb(255, 192, 203)'); }); +it('should produce extracted zip', async ({ contextFactory, isAndroid, server }, testInfo) => { + it.fixme(isAndroid); + + const harPath = testInfo.outputPath('har.har'); + const context1 = await contextFactory({ recordHar: { mode: 'minimal', path: harPath, content: 'attach' } }); + const page1 = await context1.newPage(); + await page1.goto(server.PREFIX + '/one-style.html'); + await context1.close(); + + expect(fs.existsSync(harPath)).toBeTruthy(); + const har = fs.readFileSync(harPath, 'utf-8'); + expect(har).not.toContain('background-color'); + + const context2 = await contextFactory(); + await context2.routeFromHAR(harPath, { notFound: 'abort' }); + const page2 = await context2.newPage(); + await page2.goto(server.PREFIX + '/one-style.html'); + expect(await page2.content()).toContain('hello, world!'); + await expect(page2.locator('body')).toHaveCSS('background-color', 'rgb(255, 192, 203)'); +}); + it('should round-trip extracted har.zip', async ({ contextFactory, isAndroid, server }, testInfo) => { it.fixme(isAndroid); @@ -359,3 +380,57 @@ it('should disambiguate by header', async ({ contextFactory, isAndroid, server } expect(await page2.evaluate(fetchFunction, 'baz3')).toBe('baz3'); expect(await page2.evaluate(fetchFunction, 'baz4')).toBe('baz1'); }); + +it('should update har.zip for context', async ({ contextFactory, isAndroid, server }, testInfo) => { + it.fixme(isAndroid); + + const harPath = testInfo.outputPath('har.zip'); + const context1 = await contextFactory(); + await context1.routeFromHAR(harPath, { update: true }); + const page1 = await context1.newPage(); + await page1.goto(server.PREFIX + '/one-style.html'); + await context1.close(); + + const context2 = await contextFactory(); + await context2.routeFromHAR(harPath, { notFound: 'abort' }); + const page2 = await context2.newPage(); + await page2.goto(server.PREFIX + '/one-style.html'); + expect(await page2.content()).toContain('hello, world!'); + await expect(page2.locator('body')).toHaveCSS('background-color', 'rgb(255, 192, 203)'); +}); + +it('should update har.zip for page', async ({ contextFactory, isAndroid, server }, testInfo) => { + it.fixme(isAndroid); + + const harPath = testInfo.outputPath('har.zip'); + const context1 = await contextFactory(); + const page1 = await context1.newPage(); + await page1.routeFromHAR(harPath, { update: true }); + await page1.goto(server.PREFIX + '/one-style.html'); + await context1.close(); + + const context2 = await contextFactory(); + const page2 = await context2.newPage(); + await page2.routeFromHAR(harPath, { notFound: 'abort' }); + await page2.goto(server.PREFIX + '/one-style.html'); + expect(await page2.content()).toContain('hello, world!'); + await expect(page2.locator('body')).toHaveCSS('background-color', 'rgb(255, 192, 203)'); +}); + +it('should update extracted har.zip for page', async ({ contextFactory, isAndroid, server }, testInfo) => { + it.fixme(isAndroid); + + const harPath = testInfo.outputPath('har.har'); + const context1 = await contextFactory(); + const page1 = await context1.newPage(); + await page1.routeFromHAR(harPath, { update: true }); + await page1.goto(server.PREFIX + '/one-style.html'); + await context1.close(); + + const context2 = await contextFactory(); + const page2 = await context2.newPage(); + await page2.routeFromHAR(harPath, { notFound: 'abort' }); + await page2.goto(server.PREFIX + '/one-style.html'); + expect(await page2.content()).toContain('hello, world!'); + await expect(page2.locator('body')).toHaveCSS('background-color', 'rgb(255, 192, 203)'); +});