Skip to content
6 changes: 6 additions & 0 deletions docs/src/api/class-browsercontext.md
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,12 @@ When set to `minimal`, only record information necessary for routing from HAR. T

Optional setting to control resource content management. 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.

### option: BrowserContext.routeFromHAR.interceptAPIRequests
* since: v1.62
- `interceptAPIRequests` <[boolean]>

If set to `true`, requests made via [APIRequestContext] (such as [`property: BrowserContext.request`] or [`property: Page.request`]) are also served from the HAR file. By default these requests are sent to the network, matching the behavior prior to v1.62. Defaults to `false` for backward compatibility.


## async method: BrowserContext.routeWebSocket
* since: v1.48
Expand Down
2 changes: 2 additions & 0 deletions packages/isomorphic/protocolMetainfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export const methodMetainfo = new Map<string, MethodMetainfo>([
['BrowserContext.setGeolocation', { title: 'Set geolocation', group: 'configuration', }],
['BrowserContext.setHTTPCredentials', { title: 'Set HTTP credentials', group: 'configuration', }],
['BrowserContext.setNetworkInterceptionPatterns', { title: 'Route requests', group: 'route', }],
['BrowserContext.harForAPIRequestsStart', { internal: true, }],
['BrowserContext.harForAPIRequestsStop', { internal: true, }],
['BrowserContext.setWebSocketInterceptionPatterns', { title: 'Route WebSockets', group: 'route', }],
['BrowserContext.setOffline', { title: 'Set offline mode', }],
['BrowserContext.storageState', { title: 'Get storage state', group: 'configuration', }],
Expand Down
9 changes: 9 additions & 0 deletions packages/playwright-client/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9466,6 +9466,15 @@ export interface BrowserContext {
* @param options
*/
routeFromHAR(har: string, options?: {
/**
* If set to `true`, requests made via [APIRequestContext](https://playwright.dev/docs/api/class-apirequestcontext)
* (such as [browserContext.request](https://playwright.dev/docs/api/class-browsercontext#browser-context-request) or
* [page.request](https://playwright.dev/docs/api/class-page#page-request)) are also served from the HAR file. By
* default these requests are sent to the network, matching the behavior prior to v1.62. Defaults to `false` for
* backward compatibility.
*/
interceptAPIRequests?: boolean;

/**
* - If set to 'abort' any request not found in the HAR file will be aborted.
* - If set to 'fallback' falls through to the next route handler in the handler chain.
Expand Down
4 changes: 3 additions & 1 deletion packages/playwright-core/src/client/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
await this._updateWebSocketInterceptionPatterns({ title: 'Route WebSockets' });
}

async routeFromHAR(har: string, options: { url?: string | RegExp, notFound?: 'abort' | 'fallback', update?: boolean, updateContent?: 'attach' | 'embed', updateMode?: 'minimal' | 'full' } = {}): Promise<void> {
async routeFromHAR(har: string, options: { url?: string | RegExp, notFound?: 'abort' | 'fallback', update?: boolean, updateContent?: 'attach' | 'embed', updateMode?: 'minimal' | 'full', interceptAPIRequests?: boolean } = {}): Promise<void> {
const localUtils = this._connection.localUtils();
if (!localUtils)
throw new Error('Route from har is not supported in thin clients');
Expand All @@ -396,6 +396,8 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
const harRouter = await HarRouter.create(localUtils, har, options.notFound || 'abort', { urlMatch: options.url });
this._harRouters.push(harRouter);
await harRouter.addContextRoute(this);
if (options.interceptAPIRequests)
await harRouter.addAPIRequestRoute(this, har);
}

private _disposeHarRouters() {
Expand Down
24 changes: 24 additions & 0 deletions packages/playwright-core/src/client/channels.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1341,6 +1341,8 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, Channe
setHTTPCredentials(params: BrowserContextSetHTTPCredentialsParams): Promise<BrowserContextSetHTTPCredentialsResult>;
setNetworkInterceptionPatterns(params: BrowserContextSetNetworkInterceptionPatternsParams): Promise<BrowserContextSetNetworkInterceptionPatternsResult>;
setWebSocketInterceptionPatterns(params: BrowserContextSetWebSocketInterceptionPatternsParams): Promise<BrowserContextSetWebSocketInterceptionPatternsResult>;
harForAPIRequestsStart(params: BrowserContextHarForAPIRequestsStartParams, progress?: Progress): Promise<BrowserContextHarForAPIRequestsStartResult>;
harForAPIRequestsStop(params: BrowserContextHarForAPIRequestsStopParams, progress?: Progress): Promise<BrowserContextHarForAPIRequestsStopResult>;
setOffline(params: BrowserContextSetOfflineParams): Promise<BrowserContextSetOfflineResult>;
storageState(params: BrowserContextStorageStateParams): Promise<BrowserContextStorageStateResult>;
setStorageState(params: BrowserContextSetStorageStateParams): Promise<BrowserContextSetStorageStateResult>;
Expand Down Expand Up @@ -1573,6 +1575,28 @@ export type BrowserContextSetNetworkInterceptionPatternsOptions = {

};
export type BrowserContextSetNetworkInterceptionPatternsResult = void;
export type BrowserContextHarForAPIRequestsStartParams = {
har: string,
urlGlob?: string,
urlRegexSource?: string,
urlRegexFlags?: string,
notFound: 'abort' | 'fallback',
};
export type BrowserContextHarForAPIRequestsStartOptions = {
urlGlob?: string,
urlRegexSource?: string,
urlRegexFlags?: string,
};
export type BrowserContextHarForAPIRequestsStartResult = {
registrationId: string,
};
export type BrowserContextHarForAPIRequestsStopParams = {
registrationId: string,
};
export type BrowserContextHarForAPIRequestsStopOptions = {

};
export type BrowserContextHarForAPIRequestsStopResult = void;
export type BrowserContextSetWebSocketInterceptionPatternsParams = {
patterns: {
glob?: string,
Expand Down
18 changes: 18 additions & 0 deletions packages/playwright-core/src/client/harRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* limitations under the License.
*/

import { isRegExp, isString } from '@isomorphic/rtti';

import type { BrowserContext } from './browserContext';
import type { LocalUtils } from './localUtils';
import type { Route } from './network';
Expand All @@ -27,6 +29,7 @@ export class HarRouter {
private _harId: string;
private _notFoundAction: HarNotFoundAction;
private _options: { urlMatch?: URLMatch; baseURL?: string; };
private _apiRequestRegistrations: { context: BrowserContext, registrationId: string }[] = [];

static async create(localUtils: LocalUtils, file: string, notFoundAction: HarNotFoundAction, options: { urlMatch?: URLMatch }): Promise<HarRouter> {
const { harId, error } = await localUtils.harOpen({ file });
Expand Down Expand Up @@ -115,11 +118,26 @@ export class HarRouter {
await page.route(this._options.urlMatch || '**/*', route => this._handle(route));
}

async addAPIRequestRoute(context: BrowserContext, har: string) {
const urlMatch = this._options.urlMatch;
const { registrationId } = await context._channel.harForAPIRequestsStart({
har,
urlGlob: isString(urlMatch) ? urlMatch : undefined,
urlRegexSource: isRegExp(urlMatch) ? urlMatch.source : undefined,
urlRegexFlags: isRegExp(urlMatch) ? urlMatch.flags : undefined,
notFound: this._notFoundAction,
});
this._apiRequestRegistrations.push({ context, registrationId });
Comment thread
stkevintan marked this conversation as resolved.
}

async [Symbol.asyncDispose]() {
await this.dispose();
}

dispose() {
this._localUtils.harClose({ harId: this._harId }).catch(() => {});
Comment thread
stkevintan marked this conversation as resolved.
Outdated
for (const { context, registrationId } of this._apiRequestRegistrations)
context._channel.harForAPIRequestsStop({ registrationId }).catch(() => {});
this._apiRequestRegistrations = [];
}
}
14 changes: 14 additions & 0 deletions packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,20 @@ scheme.BrowserContextSetNetworkInterceptionPatternsParams = tObject({
})),
});
scheme.BrowserContextSetNetworkInterceptionPatternsResult = tOptional(tObject({}));
scheme.BrowserContextHarForAPIRequestsStartParams = tObject({
har: tString,
urlGlob: tOptional(tString),
urlRegexSource: tOptional(tString),
urlRegexFlags: tOptional(tString),
notFound: tEnum(['abort', 'fallback']),
});
scheme.BrowserContextHarForAPIRequestsStartResult = tObject({
registrationId: tString,
});
scheme.BrowserContextHarForAPIRequestsStopParams = tObject({
registrationId: tString,
});
scheme.BrowserContextHarForAPIRequestsStopResult = tOptional(tObject({}));
scheme.BrowserContextSetWebSocketInterceptionPatternsParams = tObject({
patterns: tArray(tObject({
glob: tOptional(tString),
Expand Down
31 changes: 31 additions & 0 deletions packages/playwright-core/src/server/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@ import type { Browser, BrowserOptions } from './browser';
import type { ConsoleMessage } from './console';
import type { Download } from './download';
import type * as frames from './frames';
import type { HarBackend } from './harBackend';
import type { PageError } from './page';
import type { Progress } from './progress';
import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor';
import type { SerializedStorage } from '@injected/storageScript';
import type * as types from './types';
import type { URLMatch } from '@isomorphic/urlMatch';
import type * as channels from './channels';

const BrowserContextEvent = {
Expand Down Expand Up @@ -120,6 +122,7 @@ export abstract class BrowserContext<EM extends EventMap = EventMap> extends Sdk
private _playwrightBindingExposed?: Promise<void>;
readonly dialogManager: DialogManager;
private _consoleApiExposed = false;
private _harForAPIRequests: HarForAPIRequestsRegistration[] = [];

constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) {
super(browser, 'browser-context');
Expand Down Expand Up @@ -738,8 +741,36 @@ export abstract class BrowserContext<EM extends EventMap = EventMap> extends Sdk
async notifyRoutesInFlightAboutRemovedHandler(handler: network.RouteHandler): Promise<void> {
await Promise.all([...this._routesInFlight].map(route => route.removeHandler(handler)));
}

addHarForAPIRequests(options: { harBackend: HarBackend, urlMatch: URLMatch | undefined, notFound: 'abort' | 'fallback', baseURL: string | undefined }): { dispose: () => void } {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: i think this needs a more clear name like routeAPIRequestsFromHar to match similar naming elsewhere

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to routeAPIRequestsFromHar.

const registration: HarForAPIRequestsRegistration = {
harBackend: options.harBackend,
urlMatch: options.urlMatch,
notFound: options.notFound,
baseURL: options.baseURL,
};
this._harForAPIRequests.push(registration);
Comment thread
stkevintan marked this conversation as resolved.
Outdated
return {
dispose: () => {
const index = this._harForAPIRequests.indexOf(registration);
if (index !== -1)
this._harForAPIRequests.splice(index, 1);
},
};
}

harForAPIRequests(): readonly HarForAPIRequestsRegistration[] {
return this._harForAPIRequests;
}
}

export type HarForAPIRequestsRegistration = {
harBackend: HarBackend;
urlMatch: URLMatch | undefined;
notFound: 'abort' | 'fallback';
baseURL: string | undefined;
};

export function validateBrowserContextOptions(options: types.BrowserContextOptions, browserOptions: BrowserOptions) {
if (options.noDefaultViewport && options.deviceScaleFactor !== undefined)
throw new Error(`"deviceScaleFactor" option is not supported with null "viewport"`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { RecorderApp } from '../recorder/recorderApp';
import { ElementHandleDispatcher } from './elementHandlerDispatcher';
import { JSHandleDispatcher } from './jsHandleDispatcher';
import { disposeAll } from '../disposable';
import { openHarBackend } from '../localUtils';

import type { ConsoleMessage } from '../console';
import type { Dialog } from '../dialog';
Expand All @@ -61,6 +62,7 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
private _requestInterceptor: RouteHandler;
private _interceptionUrlMatchers: URLMatch[] = [];
private _routeWebSocketInitScript: InitScript | undefined;
private _harForAPIRequestsRegistrations = new Map<string, { dispose: () => void }>();

static from(parentScope: DispatcherScope, context: BrowserContext): BrowserContextDispatcher {
const result = parentScope.connection.existingDispatcher<BrowserContextDispatcher>(context);
Expand Down Expand Up @@ -335,6 +337,37 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
this._routeWebSocketInitScript = await WebSocketRouteDispatcher.install(progress, this.connection, this._context);
}

async harForAPIRequestsStart(params: channels.BrowserContextHarForAPIRequestsStartParams, progress: Progress): Promise<channels.BrowserContextHarForAPIRequestsStartResult> {
Comment thread
stkevintan marked this conversation as resolved.
Outdated
const result = await openHarBackend(progress, params.har);
if ('error' in result)
throw new Error(result.error);
const urlMatch: URLMatch | undefined =
params.urlRegexSource !== undefined && params.urlRegexFlags !== undefined ? new RegExp(params.urlRegexSource, params.urlRegexFlags) :
params.urlGlob !== undefined ? params.urlGlob : undefined;
const registrationId = createGuid();
const registration = this._context.addHarForAPIRequests({
harBackend: result.harBackend,
urlMatch,
notFound: params.notFound,
baseURL: this._context._options.baseURL,
});
this._harForAPIRequestsRegistrations.set(registrationId, {
dispose: () => {
registration.dispose();
result.harBackend.dispose();
},
});
Comment thread
stkevintan marked this conversation as resolved.
Outdated
return { registrationId };
}

async harForAPIRequestsStop(params: channels.BrowserContextHarForAPIRequestsStopParams, progress: Progress): Promise<void> {
Comment thread
stkevintan marked this conversation as resolved.
Outdated
const entry = this._harForAPIRequestsRegistrations.get(params.registrationId);
if (!entry)
return;
this._harForAPIRequestsRegistrations.delete(params.registrationId);
entry.dispose();
}

async storageState(params: channels.BrowserContextStorageStateParams, progress: Progress): Promise<channels.BrowserContextStorageStateResult> {
return await this._context.storageState(progress, params.indexedDB);
}
Expand Down Expand Up @@ -446,6 +479,13 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
this._context.dialogManager.removeDialogHandler(this._dialogHandler);
this._interceptionUrlMatchers = [];
this._context.removeRequestInterceptor(this._requestInterceptor).catch(() => {});
for (const entry of this._harForAPIRequestsRegistrations.values()) {
try {
entry.dispose();
} catch {
}
}
this._harForAPIRequestsRegistrations.clear();
disposeAll(this._disposables).catch(() => {});
if (this._routeWebSocketInitScript)
WebSocketRouteDispatcher.uninstall(this.connection, this._context, this._routeWebSocketInitScript).catch(() => {});
Expand Down
56 changes: 54 additions & 2 deletions packages/playwright-core/src/server/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import * as zlib from 'zlib';
import { createGuid } from '@utils/crypto';
import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent, timingForSocket } from '@utils/happyEyeballs';
import { assert } from '@isomorphic/assert';
import { constructURLBasedOnBaseURL } from '@isomorphic/urlMatch';
import { constructURLBasedOnBaseURL, urlMatches } from '@isomorphic/urlMatch';
import { eventsHelper } from '@utils/eventsHelper';
import { monotonicTime } from '@isomorphic/time';
import { createProxyAgent } from '@utils/network';
Expand Down Expand Up @@ -150,6 +150,10 @@ export abstract class APIRequestContext extends SdkObject {
abstract addCookies(cookies: channels.NetworkCookie[]): Promise<void>;
abstract cookies(progress: Progress, url: URL): Promise<channels.NetworkCookie[]>;

protected async _lookupInHar(progress: Progress, url: URL, method: string, headers: HeadersObject, postData: Buffer | undefined): Promise<SendRequestResult | undefined> {
return undefined;
}

protected _disposeImpl() {
this._disposed = true;
APIRequestContext.allInstances.delete(this);
Expand Down Expand Up @@ -225,7 +229,14 @@ export abstract class APIRequestContext extends SdkObject {
const postData = serializePostData(params, headers);
if (postData)
setHeader(headers, 'content-length', String(postData.byteLength));
const { body, log, response } = await this._sendRequestWithRetries(progress, requestUrl, options, postData, params.maxRetries);
const harResponse = await this._lookupInHar(progress, requestUrl, method, headers, postData);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this will prevent the dispatch of APIRequestContext.Events.Request and APIRequestContext.Events.RequestFinished meaning that if a new recording is captured while replaying from the HAR then it wont include any previously captured API requests

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The short-circuit bypassed _sendRequest, which is the only emitter of Request/RequestFinished. The HAR path now emits both events (mirroring _sendRequest), so capturing a new recording while replaying still includes the API requests. Added a test (should re-record intercepted APIRequestContext requests into a new HAR).

let body: Buffer;
let log: string[];
let response: Omit<channels.APIResponse, 'fetchUid'>;
if (harResponse)
({ body, log, response } = harResponse);
else
({ body, log, response } = await this._sendRequestWithRetries(progress, requestUrl, options, postData, params.maxRetries));
Comment thread
stkevintan marked this conversation as resolved.
Outdated
const failOnStatusCode = params.failOnStatusCode !== undefined ? params.failOnStatusCode : !!defaults.failOnStatusCode;
if (failOnStatusCode && (response.status < 200 || response.status >= 400)) {
let responseText = '';
Expand Down Expand Up @@ -682,6 +693,47 @@ export class BrowserContextAPIRequestContext extends APIRequestContext {
override async storageState(progress: Progress, indexedDB?: boolean): Promise<channels.APIRequestContextStorageStateResult> {
return this._context.storageState(progress, indexedDB);
}

protected override async _lookupInHar(progress: Progress, url: URL, method: string, headers: HeadersObject, postData: Buffer | undefined): Promise<SendRequestResult | undefined> {
const registrations = this._context.harForAPIRequests();
if (!registrations.length)
return undefined;
const urlString = url.toString();
const log: string[] = [];
log.push(`→ ${method} ${urlString}`);
Comment thread
stkevintan marked this conversation as resolved.
Outdated
const headersArray: HeadersArray = Object.entries(headers).map(([name, value]) => ({ name, value }));
for (const registration of registrations) {
Comment thread
stkevintan marked this conversation as resolved.
Outdated
if (!urlMatches(registration.baseURL, urlString, registration.urlMatch))
continue;
const lookupResult = await progress.race(registration.harBackend.lookup(urlString, method, headersArray, postData, false, { apiRequestOnly: true }));
Comment thread
stkevintan marked this conversation as resolved.
Outdated
if (lookupResult.action === 'error') {
log.push(`HAR: ${lookupResult.message ?? 'lookup failed'}`);
continue;
}
if (lookupResult.action === 'noentry') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: 'missing' would be a better name here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept 'noentry' here — the action is part of the LocalUtilsHarLookupResult wire protocol (localUtils.yml), so renaming it would be a breaking change for cross-version client/server compatibility. Happy to rename if you'd still prefer it and are OK treating it as a protocol change.

if (registration.notFound === 'abort')
throw new Error(`Request "${method} ${urlString}" was not found in the HAR file`);
Comment thread
stkevintan marked this conversation as resolved.
continue;
}
if (lookupResult.action === 'redirect') {
// Not expected for non-navigation API requests, but treat as fulfill miss.
log.push(`HAR: ignoring redirect entry for ${urlString}`);
continue;
}
log.push(`← ${lookupResult.status ?? 0} (from HAR)`);
return {
body: lookupResult.body ?? Buffer.from(''),
log,
response: {
Comment thread
stkevintan marked this conversation as resolved.
url: urlString,
Comment thread
stkevintan marked this conversation as resolved.
Outdated
status: lookupResult.status ?? 0,
statusText: '',
Comment thread
stkevintan marked this conversation as resolved.
Outdated
headers: lookupResult.headers ?? [],
},
};
Comment thread
stkevintan marked this conversation as resolved.
}
return undefined;
}
}


Expand Down
Loading
Loading