feat(har): Remotely accessible HAR file (#8385)

This change ensure's the HAR file is saved at `recordHar.path` on the
client instead of the server.

NB: The goal was to make this change transparent to the user and NOT
introduce any new APIs. Namely, I want to leave the API open for
potential `context.har.start()` and `context.har.stop()`.

This does BREAK servers that expect the HAR to be at the `recordHar.path`
on the server, but I think that's OK since there haven't been reports
of missing HAR on client making me think not many users are getting
HAR with client and server on different hosts anyways.

Closes #8355
This commit is contained in:
Ross Wollman 2021-08-25 13:32:56 -07:00 committed by GitHub
parent de85d8bb83
commit cd110e6477
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 70 additions and 4 deletions

View File

@ -344,6 +344,10 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
try {
await this._wrapApiCall(async (channel: channels.BrowserContextChannel) => {
await this._browserType?._onWillCloseContext?.(this);
if (this._options.recordHar) {
const har = await this._channel.harExport();
await har.artifact.saveAs({ path: this._options.recordHar.path });
}
await channel.close();
await this._closedPromise;
});

View File

@ -217,4 +217,11 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
const artifact = await this._context.tracing.export();
return { artifact: new ArtifactDispatcher(this._scope, artifact) };
}
async harExport(params: channels.BrowserContextHarExportParams): Promise<channels.BrowserContextHarExportResult> {
const artifact = await this._context._harRecorder?.export();
if (!artifact)
throw new Error('No HAR artifact. Ensure record.harPath is set.');
return { artifact: new ArtifactDispatcher(this._scope, artifact) };
}
}

View File

@ -731,6 +731,7 @@ export interface BrowserContextChannel extends EventTargetChannel {
tracingStart(params: BrowserContextTracingStartParams, metadata?: Metadata): Promise<BrowserContextTracingStartResult>;
tracingStop(params?: BrowserContextTracingStopParams, metadata?: Metadata): Promise<BrowserContextTracingStopResult>;
tracingExport(params?: BrowserContextTracingExportParams, metadata?: Metadata): Promise<BrowserContextTracingExportResult>;
harExport(params?: BrowserContextHarExportParams, metadata?: Metadata): Promise<BrowserContextHarExportResult>;
}
export type BrowserContextBindingCallEvent = {
binding: BindingCallChannel,
@ -962,6 +963,11 @@ export type BrowserContextTracingExportOptions = {};
export type BrowserContextTracingExportResult = {
artifact: ArtifactChannel,
};
export type BrowserContextHarExportParams = {};
export type BrowserContextHarExportOptions = {};
export type BrowserContextHarExportResult = {
artifact: ArtifactChannel,
};
// ----------- Page -----------
export type PageInitializer = {

View File

@ -707,6 +707,10 @@ BrowserContext:
returns:
artifact: Artifact
harExport:
returns:
artifact: Artifact
events:
bindingCall:

View File

@ -448,6 +448,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
});
scheme.BrowserContextTracingStopParams = tOptional(tObject({}));
scheme.BrowserContextTracingExportParams = tOptional(tObject({}));
scheme.BrowserContextHarExportParams = tOptional(tObject({}));
scheme.PageSetDefaultNavigationTimeoutNoReplyParams = tObject({
timeout: tNumber,
});

View File

@ -61,7 +61,7 @@ export abstract class BrowserContext extends SdkObject {
readonly _browserContextId: string | undefined;
private _selectors?: Selectors;
private _origins = new Set<string>();
private _harRecorder: HarRecorder | undefined;
readonly _harRecorder: HarRecorder | undefined;
readonly tracing: Tracing;
constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) {
@ -74,7 +74,8 @@ export abstract class BrowserContext extends SdkObject {
this._closePromise = new Promise(fulfill => this._closePromiseFulfill = fulfill);
if (this._options.recordHar)
this._harRecorder = new HarRecorder(this, this._options.recordHar);
this._harRecorder = new HarRecorder(this, {...this._options.recordHar, path: path.join(this._browser.options.artifactsDir, `${createGuid()}.har`)});
this.tracing = new Tracing(this);
}

View File

@ -15,6 +15,7 @@
*/
import fs from 'fs';
import { Artifact } from '../../artifact';
import { BrowserContext } from '../../browserContext';
import * as har from './har';
import { HarTracer } from './harTracer';
@ -25,11 +26,14 @@ type HarOptions = {
};
export class HarRecorder {
private _artifact: Artifact;
private _isFlushed: boolean = false;
private _options: HarOptions;
private _tracer: HarTracer;
private _entries: har.Entry[] = [];
constructor(context: BrowserContext, options: HarOptions) {
this._artifact = new Artifact(context, options.path);
this._options = options;
this._tracer = new HarTracer(context, this, {
content: options.omitContent ? 'omit' : 'embedded',
@ -50,8 +54,17 @@ export class HarRecorder {
}
async flush() {
if (this._isFlushed)
return;
this._isFlushed = true;
const log = await this._tracer.stop();
log.entries = this._entries;
await fs.promises.writeFile(this._options.path, JSON.stringify({ log }, undefined, 2));
}
async export(): Promise<Artifact> {
await this.flush();
this._artifact.reportFinished();
return this._artifact;
}
}

View File

@ -23,8 +23,8 @@ import type { BrowserContext, BrowserContextOptions } from '../index';
import type { AddressInfo } from 'net';
import type { Log } from '../src/server/supplements/har/har';
async function pageWithHar(contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>, testInfo: any) {
const harPath = testInfo.outputPath('test.har');
async function pageWithHar(contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>, testInfo: any, outputPath: string = 'test.har') {
const harPath = testInfo.outputPath(outputPath);
const context = await contextFactory({ recordHar: { path: harPath }, ignoreHTTPSErrors: true });
const page = await context.newPage();
return {
@ -474,3 +474,33 @@ it('should contain http2 for http2 requests', async ({ contextFactory, browserNa
expect(log.entries[0].response.httpVersion).toBe('h2');
server.close();
});
it('should have different hars for concurrent contexts', async ({ contextFactory }, testInfo) => {
const session0 = await pageWithHar(contextFactory, testInfo, 'test-0.har');
await session0.page.goto('data:text/html,<title>Zero</title>');
await session0.page.waitForLoadState('domcontentloaded');
const session1 = await pageWithHar(contextFactory, testInfo, 'test-1.har');
await session1.page.goto('data:text/html,<title>One</title>');
await session1.page.waitForLoadState('domcontentloaded');
// Trigger flushing on the server and ensure they are not racing to same
// location. NB: Run this test with --repeat-each 10.
const [log0, log1] = await Promise.all([
session0.getLog(),
session1.getLog()
]);
{
expect(log0.pages.length).toBe(1);
const pageEntry = log0.pages[0];
expect(pageEntry.title).toBe('Zero');
}
{
expect(log1.pages.length).toBe(1);
const pageEntry = log1.pages[0];
expect(pageEntry.id).not.toBe(log0.pages[0].id);
expect(pageEntry.title).toBe('One');
}
});