Skip to content

Commit 245c33a

Browse files
authored
feat(har): allow storing content as separate files (#14934)
1 parent 765ac5f commit 245c33a

File tree

15 files changed

+207
-46
lines changed

15 files changed

+207
-46
lines changed

docs/src/api/params.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -591,8 +591,9 @@ Logger sink for Playwright logging.
591591
* langs: js
592592
- `recordHar` <[Object]>
593593
- `omitContent` ?<[boolean]> Optional setting to control whether to omit request content from the HAR. Defaults to
594-
`false`.
595-
- `path` <[path]> Path on the filesystem to write the HAR file to.
594+
`false`. Deprecated, use `content` policy instead.
595+
- `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.
596+
- `path` <[path]> Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, the har file is archived. Content `attach` will also enforce `zip` compression.
596597
- `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.
597598

598599
Enables [HAR](http://www.softwareishard.com/blog/har-12-spec) recording for all pages into `recordHar.path` file. If not

packages/playwright-core/src/client/browserContext.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ function prepareRecordHarOptions(options: BrowserContextOptions['recordHar']): c
383383
return;
384384
return {
385385
path: options.path,
386-
omitContent: options.omitContent,
386+
content: options.content || (options.omitContent ? 'omit' : 'embed'),
387387
urlGlob: isString(options.urlFilter) ? options.urlFilter : undefined,
388388
urlRegexSource: isRegExp(options.urlFilter) ? options.urlFilter.source : undefined,
389389
urlRegexFlags: isRegExp(options.urlFilter) ? options.urlFilter.flags : undefined,

packages/playwright-core/src/client/electron.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ import type * as structs from '../../types/structs';
2020
import type * as api from '../../types/types';
2121
import type * as channels from '../protocol/channels';
2222
import { TimeoutSettings } from '../common/timeoutSettings';
23-
import { headersObjectToArray } from '../utils';
24-
import { BrowserContext } from './browserContext';
23+
import { BrowserContext, prepareBrowserContextParams } from './browserContext';
2524
import { ChannelOwner } from './channelOwner';
2625
import { envObjectToArray } from './clientHelper';
2726
import { Events } from './events';
@@ -31,10 +30,11 @@ import type { Env, WaitForEventOptions, Headers, BrowserContextOptions } from '.
3130
import { Waiter } from './waiter';
3231
import { HarRouter } from './harRouter';
3332

34-
type ElectronOptions = Omit<channels.ElectronLaunchOptions, 'env'|'extraHTTPHeaders'> & {
33+
type ElectronOptions = Omit<channels.ElectronLaunchOptions, 'env'|'extraHTTPHeaders'|'recordHar'> & {
3534
env?: Env,
3635
extraHTTPHeaders?: Headers,
37-
har?: BrowserContextOptions['har']
36+
har?: BrowserContextOptions['har'],
37+
recordHar?: BrowserContextOptions['recordHar'],
3838
};
3939

4040
type ElectronAppType = typeof import('electron');
@@ -50,8 +50,7 @@ export class Electron extends ChannelOwner<channels.ElectronChannel> implements
5050

5151
async launch(options: ElectronOptions = {}): Promise<ElectronApplication> {
5252
const params: channels.ElectronLaunchParams = {
53-
...options,
54-
extraHTTPHeaders: options.extraHTTPHeaders && headersObjectToArray(options.extraHTTPHeaders),
53+
...await prepareBrowserContextParams(options),
5554
env: envObjectToArray(options.env ? options.env : process.env),
5655
};
5756
const harRouter = options.har ? await HarRouter.create(options.har) : null;

packages/playwright-core/src/client/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export type BrowserContextOptions = Omit<channels.BrowserNewContextOptions, 'vie
6262
recordHar?: {
6363
path: string,
6464
omitContent?: boolean,
65+
content?: 'omit' | 'embed' | 'attach',
6566
urlFilter?: string | RegExp,
6667
},
6768
};

packages/playwright-core/src/protocol/channels.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,8 +264,8 @@ export type SerializedError = {
264264
};
265265

266266
export type RecordHarOptions = {
267-
omitContent?: boolean,
268267
path: string,
268+
content: 'embed' | 'attach' | 'omit',
269269
urlGlob?: string,
270270
urlRegexSource?: string,
271271
urlRegexFlags?: string,

packages/playwright-core/src/protocol/protocol.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,13 @@ SerializedError:
224224
RecordHarOptions:
225225
type: object
226226
properties:
227-
omitContent: boolean?
228227
path: string
228+
content:
229+
type: enum
230+
literals:
231+
- embed
232+
- attach
233+
- omit
229234
urlGlob: string?
230235
urlRegexSource: string?
231236
urlRegexFlags: string?

packages/playwright-core/src/protocol/validator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,8 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
154154
value: tOptional(tType('SerializedValue')),
155155
});
156156
scheme.RecordHarOptions = tObject({
157-
omitContent: tOptional(tBoolean),
158157
path: tString,
158+
content: tEnum(['embed', 'attach', 'omit']),
159159
urlGlob: tOptional(tString),
160160
urlRegexSource: tOptional(tString),
161161
urlRegexFlags: tOptional(tString),

packages/playwright-core/src/server/browserContext.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import * as os from 'os';
1919
import { TimeoutSettings } from '../common/timeoutSettings';
20-
import { debugMode, createGuid } from '../utils';
20+
import { debugMode } from '../utils';
2121
import { mkdirIfNeeded } from '../utils/fileUtils';
2222
import type { Browser, BrowserOptions } from './browser';
2323
import type { Download } from './download';
@@ -87,7 +87,7 @@ export abstract class BrowserContext extends SdkObject {
8787
this.fetchRequest = new BrowserContextAPIRequestContext(this);
8888

8989
if (this._options.recordHar)
90-
this._harRecorder = new HarRecorder(this, { ...this._options.recordHar, path: path.join(this._browser.options.artifactsDir, `${createGuid()}.har`) });
90+
this._harRecorder = new HarRecorder(this, this._options.recordHar);
9191

9292
this.tracing = new Tracing(this, browser.options.tracesDir);
9393
}

packages/playwright-core/src/server/har/harRecorder.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,30 +15,35 @@
1515
*/
1616

1717
import fs from 'fs';
18-
import type { APIRequestContext } from '../fetch';
18+
import path from 'path';
1919
import { Artifact } from '../artifact';
2020
import type { BrowserContext } from '../browserContext';
2121
import type * as har from './har';
2222
import { HarTracer } from './harTracer';
2323
import type * as channels from '../../protocol/channels';
24+
import { yazl } from '../../zipBundle';
25+
import type { ZipFile } from '../../zipBundle';
26+
import { ManualPromise } from '../../utils/manualPromise';
27+
import type EventEmitter from 'events';
28+
import { createGuid } from '../../utils';
2429

2530
export class HarRecorder {
2631
private _artifact: Artifact;
2732
private _isFlushed: boolean = false;
28-
private _options: channels.RecordHarOptions;
2933
private _tracer: HarTracer;
3034
private _entries: har.Entry[] = [];
35+
private _zipFile: ZipFile | null = null;
3136

32-
constructor(context: BrowserContext | APIRequestContext, options: channels.RecordHarOptions) {
33-
this._artifact = new Artifact(context, options.path);
34-
this._options = options;
37+
constructor(context: BrowserContext, options: channels.RecordHarOptions) {
38+
this._artifact = new Artifact(context, path.join(context._browser.options.artifactsDir, `${createGuid()}.har`));
3539
const urlFilterRe = options.urlRegexSource !== undefined && options.urlRegexFlags !== undefined ? new RegExp(options.urlRegexSource, options.urlRegexFlags) : undefined;
3640
this._tracer = new HarTracer(context, this, {
37-
content: options.omitContent ? 'omit' : 'embedded',
41+
content: options.content || 'embed',
3842
waitForContentOnStop: true,
3943
skipScripts: false,
4044
urlFilter: urlFilterRe ?? options.urlGlob,
4145
});
46+
this._zipFile = options.content === 'attach' || options.path.endsWith('.zip') ? new yazl.ZipFile() : null;
4247
this._tracer.start();
4348
}
4449

@@ -50,16 +55,33 @@ export class HarRecorder {
5055
}
5156

5257
onContentBlob(sha1: string, buffer: Buffer) {
58+
if (this._zipFile)
59+
this._zipFile!.addBuffer(buffer, sha1);
5360
}
5461

5562
async flush() {
5663
if (this._isFlushed)
5764
return;
5865
this._isFlushed = true;
5966
await this._tracer.flush();
67+
6068
const log = this._tracer.stop();
6169
log.entries = this._entries;
62-
await fs.promises.writeFile(this._options.path, JSON.stringify({ log }, undefined, 2));
70+
71+
const harFileContent = JSON.stringify({ log }, undefined, 2);
72+
73+
if (this._zipFile) {
74+
const result = new ManualPromise<void>();
75+
(this._zipFile as unknown as EventEmitter).on('error', error => result.reject(error));
76+
this._zipFile.addBuffer(Buffer.from(harFileContent, 'utf-8'), 'har.har');
77+
this._zipFile.end();
78+
this._zipFile.outputStream.pipe(fs.createWriteStream(this._artifact.localPath())).on('close', () => {
79+
result.resolve();
80+
});
81+
await result;
82+
} else {
83+
await fs.promises.writeFile(this._artifact.localPath(), harFileContent);
84+
}
6385
}
6486

6587
async export(): Promise<Artifact> {

packages/playwright-core/src/server/har/harTracer.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export interface HarTracerDelegate {
4040
}
4141

4242
type HarTracerOptions = {
43-
content: 'omit' | 'sha1' | 'embedded';
43+
content: 'omit' | 'attach' | 'embed';
4444
skipScripts: boolean;
4545
waitForContentOnStop: boolean;
4646
urlFilter?: string | RegExp;
@@ -272,7 +272,7 @@ export class HarTracer {
272272
compressionCalculationBarrier.setDecodedBodySize(0);
273273
}).then(() => {
274274
const postData = response.request().postDataBuffer();
275-
if (postData && harEntry.request.postData && this._options.content === 'sha1') {
275+
if (postData && harEntry.request.postData && this._options.content === 'attach') {
276276
harEntry.request.postData._sha1 = calculateSha1(postData) + '.' + (mime.getExtension(harEntry.request.postData.mimeType) || 'dat');
277277
if (this._started)
278278
this._delegate.onContentBlob(harEntry.request.postData._sha1, postData);
@@ -308,7 +308,7 @@ export class HarTracer {
308308
return;
309309
}
310310
content.size = buffer.length;
311-
if (this._options.content === 'embedded') {
311+
if (this._options.content === 'embed') {
312312
// Sometimes, we can receive a font/media file with textual mime type. Browser
313313
// still interprets them correctly, but the 'content-type' header is obviously wrong.
314314
if (isTextualMimeType(content.mimeType) && resourceType !== 'font') {
@@ -317,7 +317,7 @@ export class HarTracer {
317317
content.text = buffer.toString('base64');
318318
content.encoding = 'base64';
319319
}
320-
} else if (this._options.content === 'sha1') {
320+
} else if (this._options.content === 'attach') {
321321
content._sha1 = calculateSha1(buffer) + '.' + (mime.getExtension(content.mimeType) || 'dat');
322322
if (this._started)
323323
this._delegate.onContentBlob(content._sha1, buffer);
@@ -475,7 +475,7 @@ function createHarEntry(method: string, url: URL, requestref: string, frameref:
475475
return harEntry;
476476
}
477477

478-
function postDataForRequest(request: network.Request, content: 'omit' | 'sha1' | 'embedded'): har.PostData | undefined {
478+
function postDataForRequest(request: network.Request, content: 'omit' | 'attach' | 'embed'): har.PostData | undefined {
479479
const postData = request.postDataBuffer();
480480
if (!postData)
481481
return;
@@ -484,7 +484,7 @@ function postDataForRequest(request: network.Request, content: 'omit' | 'sha1' |
484484
return postDataForBuffer(postData, contentType, content);
485485
}
486486

487-
function postDataForBuffer(postData: Buffer | null, contentType: string | undefined, content: 'omit' | 'sha1' | 'embedded'): har.PostData | undefined {
487+
function postDataForBuffer(postData: Buffer | null, contentType: string | undefined, content: 'omit' | 'attach' | 'embed'): har.PostData | undefined {
488488
if (!postData)
489489
return;
490490

@@ -496,7 +496,7 @@ function postDataForBuffer(postData: Buffer | null, contentType: string | undefi
496496
params: []
497497
};
498498

499-
if (content === 'embedded' && contentType !== 'application/octet-stream')
499+
if (content === 'embed' && contentType !== 'application/octet-stream')
500500
result.text = postData.toString();
501501

502502
if (contentType === 'application/x-www-form-urlencoded') {

0 commit comments

Comments
 (0)