Skip to content

Commit d3d436d

Browse files
authored
feat(routeFromHar): add interceptAPIRequests option (#41294)
Adds opt-in `interceptAPIRequests` option to `BrowserContext.routeFromHAR` so `page.request.*` / `context.request.*` calls are also served from the HAR file. API-request lookup is restricted to entries marked `_apiRequest: true` (already written by the HAR recorder), so browser-side recordings are never served to API requests for the same URL. Fixes <#22869>.
1 parent 3387c15 commit d3d436d

18 files changed

Lines changed: 738 additions & 18 deletions

File tree

docs/src/api/class-browsercontext.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,6 +1307,12 @@ When set to `minimal`, only record information necessary for routing from HAR. T
13071307

13081308
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.
13091309

1310+
### option: BrowserContext.routeFromHAR.interceptAPIRequests
1311+
* since: v1.62
1312+
- `interceptAPIRequests` <[boolean]>
1313+
1314+
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.
1315+
13101316

13111317
## async method: BrowserContext.routeWebSocket
13121318
* since: v1.48

packages/isomorphic/protocolMetainfo.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ export const methodMetainfo = new Map<string, MethodMetainfo>([
8989
['BrowserContext.setGeolocation', { title: 'Set geolocation', group: 'configuration', }],
9090
['BrowserContext.setHTTPCredentials', { title: 'Set HTTP credentials', group: 'configuration', }],
9191
['BrowserContext.setNetworkInterceptionPatterns', { title: 'Route requests', group: 'route', }],
92+
['BrowserContext.routeAPIRequestsFromHar', { internal: true, }],
93+
['BrowserContext.unrouteAPIRequestsFromHar', { internal: true, }],
9294
['BrowserContext.setWebSocketInterceptionPatterns', { title: 'Route WebSockets', group: 'route', }],
9395
['BrowserContext.setOffline', { title: 'Set offline mode', }],
9496
['BrowserContext.storageState', { title: 'Get storage state', group: 'configuration', }],

packages/playwright-client/types/types.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10240,6 +10240,15 @@ export interface BrowserContext {
1024010240
* @param options
1024110241
*/
1024210242
routeFromHAR(har: string, options?: {
10243+
/**
10244+
* If set to `true`, requests made via [APIRequestContext](https://playwright.dev/docs/api/class-apirequestcontext)
10245+
* (such as [browserContext.request](https://playwright.dev/docs/api/class-browsercontext#browser-context-request) or
10246+
* [page.request](https://playwright.dev/docs/api/class-page#page-request)) are also served from the HAR file. By
10247+
* default these requests are sent to the network, matching the behavior prior to v1.62. Defaults to `false` for
10248+
* backward compatibility.
10249+
*/
10250+
interceptAPIRequests?: boolean;
10251+
1024310252
/**
1024410253
* - If set to 'abort' any request not found in the HAR file will be aborted.
1024510254
* - If set to 'fallback' falls through to the next route handler in the handler chain.

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
387387
await this._updateWebSocketInterceptionPatterns({ title: 'Route WebSockets' });
388388
}
389389

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

403405
private _disposeHarRouters() {

packages/playwright-core/src/client/channels.d.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,6 +1282,8 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, Channe
12821282
setGeolocation(params: BrowserContextSetGeolocationParams, signal: AbortSignal | undefined): Promise<BrowserContextSetGeolocationResult>;
12831283
setHTTPCredentials(params: BrowserContextSetHTTPCredentialsParams, signal: AbortSignal | undefined): Promise<BrowserContextSetHTTPCredentialsResult>;
12841284
setNetworkInterceptionPatterns(params: BrowserContextSetNetworkInterceptionPatternsParams, signal: AbortSignal | undefined): Promise<BrowserContextSetNetworkInterceptionPatternsResult>;
1285+
routeAPIRequestsFromHar(params: BrowserContextRouteAPIRequestsFromHarParams, signal: AbortSignal | undefined): Promise<BrowserContextRouteAPIRequestsFromHarResult>;
1286+
unrouteAPIRequestsFromHar(params: BrowserContextUnrouteAPIRequestsFromHarParams, signal: AbortSignal | undefined): Promise<BrowserContextUnrouteAPIRequestsFromHarResult>;
12851287
setWebSocketInterceptionPatterns(params: BrowserContextSetWebSocketInterceptionPatternsParams, signal: AbortSignal | undefined): Promise<BrowserContextSetWebSocketInterceptionPatternsResult>;
12861288
setOffline(params: BrowserContextSetOfflineParams, signal: AbortSignal | undefined): Promise<BrowserContextSetOfflineResult>;
12871289
storageState(params: BrowserContextStorageStateParams, signal: AbortSignal | undefined): Promise<BrowserContextStorageStateResult>;
@@ -1515,6 +1517,28 @@ export type BrowserContextSetNetworkInterceptionPatternsOptions = {
15151517

15161518
};
15171519
export type BrowserContextSetNetworkInterceptionPatternsResult = void;
1520+
export type BrowserContextRouteAPIRequestsFromHarParams = {
1521+
harId: string,
1522+
urlGlob?: string,
1523+
urlRegexSource?: string,
1524+
urlRegexFlags?: string,
1525+
notFound: 'abort' | 'fallback',
1526+
};
1527+
export type BrowserContextRouteAPIRequestsFromHarOptions = {
1528+
urlGlob?: string,
1529+
urlRegexSource?: string,
1530+
urlRegexFlags?: string,
1531+
};
1532+
export type BrowserContextRouteAPIRequestsFromHarResult = {
1533+
registrationId: string,
1534+
};
1535+
export type BrowserContextUnrouteAPIRequestsFromHarParams = {
1536+
registrationId: string,
1537+
};
1538+
export type BrowserContextUnrouteAPIRequestsFromHarOptions = {
1539+
1540+
};
1541+
export type BrowserContextUnrouteAPIRequestsFromHarResult = void;
15181542
export type BrowserContextSetWebSocketInterceptionPatternsParams = {
15191543
patterns: {
15201544
glob?: string,

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616

1717
import { debugLogger } from '@utils/debugLogger';
18+
import { isRegExp, isString } from '@isomorphic/rtti';
1819

1920
import type { BrowserContext } from './browserContext';
2021
import type { LocalUtils } from './localUtils';
@@ -29,6 +30,7 @@ export class HarRouter {
2930
private _harId: string;
3031
private _notFoundAction: HarNotFoundAction;
3132
private _options: { urlMatch?: URLMatch; baseURL?: string; };
33+
private _apiRequestRegistrations: { context: BrowserContext, registrationId: string }[] = [];
3234

3335
static async create(localUtils: LocalUtils, file: string, notFoundAction: HarNotFoundAction, options: { urlMatch?: URLMatch }): Promise<HarRouter> {
3436
const { harId, error } = await localUtils.harOpen({ file });
@@ -117,11 +119,26 @@ export class HarRouter {
117119
await page.route(this._options.urlMatch || '**/*', route => this._handle(route));
118120
}
119121

122+
async addAPIRequestRoute(context: BrowserContext) {
123+
const urlMatch = this._options.urlMatch;
124+
const { registrationId } = await context._channel.routeAPIRequestsFromHar({
125+
harId: this._harId,
126+
urlGlob: isString(urlMatch) ? urlMatch : undefined,
127+
urlRegexSource: isRegExp(urlMatch) ? urlMatch.source : undefined,
128+
urlRegexFlags: isRegExp(urlMatch) ? urlMatch.flags : undefined,
129+
notFound: this._notFoundAction,
130+
}, undefined);
131+
this._apiRequestRegistrations.push({ context, registrationId });
132+
}
133+
120134
async [Symbol.asyncDispose]() {
121135
await this.dispose();
122136
}
123137

124138
dispose() {
139+
for (const { context, registrationId } of this._apiRequestRegistrations)
140+
context._channel.unrouteAPIRequestsFromHar({ registrationId }, undefined).catch(() => {});
141+
this._apiRequestRegistrations = [];
125142
this._localUtils.harClose({ harId: this._harId }).catch(() => {});
126143
}
127144
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,20 @@ scheme.BrowserContextSetNetworkInterceptionPatternsParams = tObject({
832832
})),
833833
});
834834
scheme.BrowserContextSetNetworkInterceptionPatternsResult = tOptional(tObject({}));
835+
scheme.BrowserContextRouteAPIRequestsFromHarParams = tObject({
836+
harId: tString,
837+
urlGlob: tOptional(tString),
838+
urlRegexSource: tOptional(tString),
839+
urlRegexFlags: tOptional(tString),
840+
notFound: tEnum(['abort', 'fallback']),
841+
});
842+
scheme.BrowserContextRouteAPIRequestsFromHarResult = tObject({
843+
registrationId: tString,
844+
});
845+
scheme.BrowserContextUnrouteAPIRequestsFromHarParams = tObject({
846+
registrationId: tString,
847+
});
848+
scheme.BrowserContextUnrouteAPIRequestsFromHarResult = tOptional(tObject({}));
835849
scheme.BrowserContextSetWebSocketInterceptionPatternsParams = tObject({
836850
patterns: tArray(tObject({
837851
glob: tOptional(tString),

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,13 @@ import type { Browser, BrowserOptions } from './browser';
3939
import type { ConsoleMessage } from './console';
4040
import type { Download } from './download';
4141
import type * as frames from './frames';
42+
import type { HarBackend } from './harBackend';
4243
import type { PageError } from './page';
4344
import type { Progress } from './progress';
4445
import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor';
4546
import type { SerializedStorage } from '@injected/storageScript';
4647
import type * as types from './types';
48+
import type { URLMatch } from '@isomorphic/urlMatch';
4749
import type * as channels from './channels';
4850

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

124127
constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) {
125128
super(browser, 'browser-context');
@@ -749,8 +752,37 @@ export abstract class BrowserContext<EM extends EventMap = EventMap> extends Sdk
749752
async notifyRoutesInFlightAboutRemovedHandler(handler: network.RouteHandler): Promise<void> {
750753
await Promise.all([...this._routesInFlight].map(route => route.removeHandler(handler)));
751754
}
755+
756+
routeAPIRequestsFromHar(options: { harBackend: HarBackend, urlMatch: URLMatch | undefined, notFound: 'abort' | 'fallback', baseURL: string | undefined }): { dispose: () => void } {
757+
const registration: HarForAPIRequestsRegistration = {
758+
harBackend: options.harBackend,
759+
urlMatch: options.urlMatch,
760+
notFound: options.notFound,
761+
baseURL: options.baseURL,
762+
};
763+
// Give priority to the newest registration, mirroring BrowserContext.route/Page.route.
764+
this._harForAPIRequests.unshift(registration);
765+
return {
766+
dispose: () => {
767+
const index = this._harForAPIRequests.indexOf(registration);
768+
if (index !== -1)
769+
this._harForAPIRequests.splice(index, 1);
770+
},
771+
};
772+
}
773+
774+
harForAPIRequests(): readonly HarForAPIRequestsRegistration[] {
775+
return this._harForAPIRequests;
776+
}
752777
}
753778

779+
export type HarForAPIRequestsRegistration = {
780+
harBackend: HarBackend;
781+
urlMatch: URLMatch | undefined;
782+
notFound: 'abort' | 'fallback';
783+
baseURL: string | undefined;
784+
};
785+
754786
export function validateBrowserContextOptions(options: types.BrowserContextOptions, browserOptions: BrowserOptions) {
755787
if (options.noDefaultViewport && options.deviceScaleFactor !== undefined)
756788
throw new Error(`"deviceScaleFactor" option is not supported with null "viewport"`);

packages/playwright-core/src/server/channels.d.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,6 +1285,8 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, Channe
12851285
setGeolocation(params: BrowserContextSetGeolocationParams, progress: Progress): Promise<BrowserContextSetGeolocationResult>;
12861286
setHTTPCredentials(params: BrowserContextSetHTTPCredentialsParams, progress: Progress): Promise<BrowserContextSetHTTPCredentialsResult>;
12871287
setNetworkInterceptionPatterns(params: BrowserContextSetNetworkInterceptionPatternsParams, progress: Progress): Promise<BrowserContextSetNetworkInterceptionPatternsResult>;
1288+
routeAPIRequestsFromHar(params: BrowserContextRouteAPIRequestsFromHarParams, progress: Progress): Promise<BrowserContextRouteAPIRequestsFromHarResult>;
1289+
unrouteAPIRequestsFromHar(params: BrowserContextUnrouteAPIRequestsFromHarParams, progress: Progress): Promise<BrowserContextUnrouteAPIRequestsFromHarResult>;
12881290
setWebSocketInterceptionPatterns(params: BrowserContextSetWebSocketInterceptionPatternsParams, progress: Progress): Promise<BrowserContextSetWebSocketInterceptionPatternsResult>;
12891291
setOffline(params: BrowserContextSetOfflineParams, progress: Progress): Promise<BrowserContextSetOfflineResult>;
12901292
storageState(params: BrowserContextStorageStateParams, progress: Progress): Promise<BrowserContextStorageStateResult>;
@@ -1518,6 +1520,28 @@ export type BrowserContextSetNetworkInterceptionPatternsOptions = {
15181520

15191521
};
15201522
export type BrowserContextSetNetworkInterceptionPatternsResult = void;
1523+
export type BrowserContextRouteAPIRequestsFromHarParams = {
1524+
harId: string,
1525+
urlGlob?: string,
1526+
urlRegexSource?: string,
1527+
urlRegexFlags?: string,
1528+
notFound: 'abort' | 'fallback',
1529+
};
1530+
export type BrowserContextRouteAPIRequestsFromHarOptions = {
1531+
urlGlob?: string,
1532+
urlRegexSource?: string,
1533+
urlRegexFlags?: string,
1534+
};
1535+
export type BrowserContextRouteAPIRequestsFromHarResult = {
1536+
registrationId: string,
1537+
};
1538+
export type BrowserContextUnrouteAPIRequestsFromHarParams = {
1539+
registrationId: string,
1540+
};
1541+
export type BrowserContextUnrouteAPIRequestsFromHarOptions = {
1542+
1543+
};
1544+
export type BrowserContextUnrouteAPIRequestsFromHarResult = void;
15211545
export type BrowserContextSetWebSocketInterceptionPatternsParams = {
15221546
patterns: {
15231547
glob?: string,

packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,13 @@ import type { Request, Response, RouteHandler } from '../network';
4646
import type { InitScript, Page, PageError } from '../page';
4747
import type { Disposable } from '../disposable';
4848
import type { DispatcherScope } from './dispatcher';
49+
import type { LocalUtilsDispatcher } from './localUtilsDispatcher';
4950
import type * as channels from '../channels';
5051
import type { Progress } from '../progress';
5152
import type { URLMatch } from '@isomorphic/urlMatch';
5253

54+
type HarForAPIRequestsDisposable = Disposable & { registrationId: string };
55+
5356
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel, DispatcherScope> implements channels.BrowserContextChannel {
5457
_type_BrowserContext = true;
5558
private _context: BrowserContext;
@@ -335,6 +338,38 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
335338
this._routeWebSocketInitScript = await WebSocketRouteDispatcher.install(progress, this.connection, this._context);
336339
}
337340

341+
async routeAPIRequestsFromHar(params: channels.BrowserContextRouteAPIRequestsFromHarParams, progress: Progress): Promise<channels.BrowserContextRouteAPIRequestsFromHarResult> {
342+
// Reuse the HarBackend that was already opened via localUtils.harOpen for the page-side
343+
// route, rather than opening a second backend for the same HAR file. The backend is owned
344+
// by LocalUtils and closed via harClose, so this registration must not dispose it.
345+
const harBackend = this.connection.getDispatcher<LocalUtilsDispatcher>('LocalUtils')?.harBackendForId(params.harId);
346+
if (!harBackend)
347+
throw new Error('Internal error: har was not opened');
348+
const urlMatch: URLMatch | undefined =
349+
params.urlRegexSource !== undefined && params.urlRegexFlags !== undefined ? new RegExp(params.urlRegexSource, params.urlRegexFlags) :
350+
params.urlGlob !== undefined ? params.urlGlob : undefined;
351+
const registrationId = createGuid();
352+
const registration = this._context.routeAPIRequestsFromHar({
353+
harBackend,
354+
urlMatch,
355+
notFound: params.notFound,
356+
baseURL: this._context._options.baseURL,
357+
});
358+
this._disposables.push({
359+
registrationId,
360+
dispose: async () => registration.dispose(),
361+
} as HarForAPIRequestsDisposable);
362+
return { registrationId };
363+
}
364+
365+
async unrouteAPIRequestsFromHar(params: channels.BrowserContextUnrouteAPIRequestsFromHarParams, progress: Progress): Promise<void> {
366+
const index = this._disposables.findIndex(d => (d as HarForAPIRequestsDisposable).registrationId === params.registrationId);
367+
if (index === -1)
368+
return;
369+
const [disposable] = this._disposables.splice(index, 1);
370+
await progress.race(disposable.dispose());
371+
}
372+
338373
async storageState(params: channels.BrowserContextStorageStateParams, progress: Progress): Promise<channels.BrowserContextStorageStateResult> {
339374
return await this._context.storageState(progress, params.indexedDB, params.credentials);
340375
}

0 commit comments

Comments
 (0)