diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index c5e504b484c49..450662936d4e4 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -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 diff --git a/packages/isomorphic/protocolMetainfo.ts b/packages/isomorphic/protocolMetainfo.ts index 95683a55ac9e8..5fbbd689cdf2f 100644 --- a/packages/isomorphic/protocolMetainfo.ts +++ b/packages/isomorphic/protocolMetainfo.ts @@ -89,6 +89,8 @@ export const methodMetainfo = new Map([ ['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', }], diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 0bff9d367c0b3..c91d3d9ec92ec 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -9402,6 +9402,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. diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 31accbf0cb8cf..5a9843fd3a947 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -385,7 +385,7 @@ export class BrowserContext extends ChannelOwner 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 { + async routeFromHAR(har: string, options: { url?: string | RegExp, notFound?: 'abort' | 'fallback', update?: boolean, updateContent?: 'attach' | 'embed', updateMode?: 'minimal' | 'full', interceptAPIRequests?: boolean } = {}): Promise { const localUtils = this._connection.localUtils(); if (!localUtils) throw new Error('Route from har is not supported in thin clients'); @@ -396,6 +396,8 @@ export class BrowserContext extends ChannelOwner 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() { diff --git a/packages/playwright-core/src/client/harRouter.ts b/packages/playwright-core/src/client/harRouter.ts index b6a25bd3bee30..decf92ba8a2a7 100644 --- a/packages/playwright-core/src/client/harRouter.ts +++ b/packages/playwright-core/src/client/harRouter.ts @@ -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'; @@ -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 { const { harId, error } = await localUtils.harOpen({ file }); @@ -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 }); + } + async [Symbol.asyncDispose]() { await this.dispose(); } dispose() { this._localUtils.harClose({ harId: this._harId }).catch(() => {}); + for (const { context, registrationId } of this._apiRequestRegistrations) + context._channel.harForAPIRequestsStop({ registrationId }).catch(() => {}); + this._apiRequestRegistrations = []; } } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 4a9b428e56fc3..17bd700eda11b 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -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), diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 788179e985121..495723b30c595 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -39,12 +39,14 @@ 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 * as channels from '@protocol/channels'; +import type { URLMatch } from '@isomorphic/urlMatch'; const BrowserContextEvent = { Console: 'console', @@ -120,6 +122,7 @@ export abstract class BrowserContext extends Sdk private _playwrightBindingExposed?: Promise; readonly dialogManager: DialogManager; private _consoleApiExposed = false; + private _harForAPIRequests: HarForAPIRequestsRegistration[] = []; constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) { super(browser, 'browser-context'); @@ -738,8 +741,36 @@ export abstract class BrowserContext extends Sdk async notifyRoutesInFlightAboutRemovedHandler(handler: network.RouteHandler): Promise { 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 } { + const registration: HarForAPIRequestsRegistration = { + harBackend: options.harBackend, + urlMatch: options.urlMatch, + notFound: options.notFound, + baseURL: options.baseURL, + }; + this._harForAPIRequests.push(registration); + 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"`); diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 4db2f8adfd955..b66d1b648f031 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -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'; @@ -61,6 +62,7 @@ export class BrowserContextDispatcher extends Dispatcher void }>(); static from(parentScope: DispatcherScope, context: BrowserContext): BrowserContextDispatcher { const result = parentScope.connection.existingDispatcher(context); @@ -335,6 +337,37 @@ export class BrowserContextDispatcher extends Dispatcher { + 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(); + }, + }); + return { registrationId }; + } + + async harForAPIRequestsStop(params: channels.BrowserContextHarForAPIRequestsStopParams, progress: Progress): Promise { + 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 { return await this._context.storageState(progress, params.indexedDB); } @@ -446,6 +479,13 @@ export class BrowserContextDispatcher extends Dispatcher {}); + 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(() => {}); diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 78bfa6b8640c6..e6d33416cf881 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -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'; @@ -150,6 +150,10 @@ export abstract class APIRequestContext extends SdkObject { abstract addCookies(cookies: channels.NetworkCookie[]): Promise; abstract cookies(progress: Progress, url: URL): Promise; + protected async _lookupInHar(progress: Progress, url: URL, method: string, headers: HeadersObject, postData: Buffer | undefined): Promise { + return undefined; + } + protected _disposeImpl() { this._disposed = true; APIRequestContext.allInstances.delete(this); @@ -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); + let body: Buffer; + let log: string[]; + let response: Omit; + if (harResponse) + ({ body, log, response } = harResponse); + else + ({ body, log, response } = await this._sendRequestWithRetries(progress, requestUrl, options, postData, params.maxRetries)); const failOnStatusCode = params.failOnStatusCode !== undefined ? params.failOnStatusCode : !!defaults.failOnStatusCode; if (failOnStatusCode && (response.status < 200 || response.status >= 400)) { let responseText = ''; @@ -682,6 +693,47 @@ export class BrowserContextAPIRequestContext extends APIRequestContext { override async storageState(progress: Progress, indexedDB?: boolean): Promise { return this._context.storageState(progress, indexedDB); } + + protected override async _lookupInHar(progress: Progress, url: URL, method: string, headers: HeadersObject, postData: Buffer | undefined): Promise { + const registrations = this._context.harForAPIRequests(); + if (!registrations.length) + return undefined; + const urlString = url.toString(); + const log: string[] = []; + log.push(`→ ${method} ${urlString}`); + const headersArray: HeadersArray = Object.entries(headers).map(([name, value]) => ({ name, value })); + for (const registration of registrations) { + if (!urlMatches(registration.baseURL, urlString, registration.urlMatch)) + continue; + const lookupResult = await progress.race(registration.harBackend.lookup(urlString, method, headersArray, postData, false, { apiRequestOnly: true })); + if (lookupResult.action === 'error') { + log.push(`HAR: ${lookupResult.message ?? 'lookup failed'}`); + continue; + } + if (lookupResult.action === 'noentry') { + if (registration.notFound === 'abort') + throw new Error(`Request "${method} ${urlString}" was not found in the HAR file`); + 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: { + url: urlString, + status: lookupResult.status ?? 0, + statusText: '', + headers: lookupResult.headers ?? [], + }, + }; + } + return undefined; + } } diff --git a/packages/playwright-core/src/server/harBackend.ts b/packages/playwright-core/src/server/harBackend.ts index bff11879e112b..c1e50cfb4a9cf 100644 --- a/packages/playwright-core/src/server/harBackend.ts +++ b/packages/playwright-core/src/server/harBackend.ts @@ -39,7 +39,7 @@ export class HarBackend { this._zipFile = zipFile; } - async lookup(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined, isNavigationRequest: boolean): Promise<{ + async lookup(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined, isNavigationRequest: boolean, options: { apiRequestOnly?: boolean } = {}): Promise<{ action: 'error' | 'redirect' | 'fulfill' | 'noentry', message?: string, redirectURL?: string, @@ -49,7 +49,7 @@ export class HarBackend { }> { let entry; try { - entry = await this._harFindResponse(url, method, headers, postData); + entry = await this._harFindResponse(url, method, headers, postData, options); } catch (e) { return { action: 'error', message: 'HAR error: ' + e.message }; } @@ -93,7 +93,7 @@ export class HarBackend { return buffer; } - private async _harFindResponse(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined): Promise { + private async _harFindResponse(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined, options: { apiRequestOnly?: boolean } = {}): Promise { const harLog = this._harFile.log; const visited = new Set(); while (true) { @@ -101,6 +101,8 @@ export class HarBackend { for (const candidate of harLog.entries) { if (candidate.request.url !== url || candidate.request.method !== method) continue; + if (options.apiRequestOnly && !candidate._apiRequest) + continue; if (method === 'POST' && postData && candidate.request.postData) { const buffer = await this._loadContent(candidate.request.postData); if (!buffer.equals(postData)) { diff --git a/packages/playwright-core/src/server/localUtils.ts b/packages/playwright-core/src/server/localUtils.ts index 8da82e56b2bbf..05bd79898db11 100644 --- a/packages/playwright-core/src/server/localUtils.ts +++ b/packages/playwright-core/src/server/localUtils.ts @@ -148,9 +148,16 @@ async function deleteStackSession(progress: Progress, stackSessions: Map, params: channels.LocalUtilsHarOpenParams): Promise { - let harBackend: HarBackend; - if (params.file.endsWith('.zip')) { - const zipFile = new ZipFile(params.file); + const result = await openHarBackend(progress, params.file); + if ('error' in result) + return { error: result.error }; + harBackends.set(result.harBackend.id, result.harBackend); + return { harId: result.harBackend.id }; +} + +export async function openHarBackend(progress: Progress, file: string): Promise<{ harBackend: HarBackend } | { error: string }> { + if (file.endsWith('.zip')) { + const zipFile = new ZipFile(file); try { const entryNames = await progress.race(zipFile.entries()); const harEntryName = entryNames.find(e => e.endsWith('.har')); @@ -158,17 +165,14 @@ export async function harOpen(progress: Progress, harBackends: Map, params: channels.LocalUtilsHarLookupParams): Promise { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 0bff9d367c0b3..c91d3d9ec92ec 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9402,6 +9402,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. diff --git a/packages/protocol/spec/browserContext.yml b/packages/protocol/spec/browserContext.yml index b930f2a6bc3ba..ed0de66a19871 100644 --- a/packages/protocol/spec/browserContext.yml +++ b/packages/protocol/spec/browserContext.yml @@ -156,6 +156,26 @@ BrowserContext: regexFlags: string? urlPattern: URLPattern? + harForAPIRequestsStart: + internal: true + parameters: + har: string + urlGlob: string? + urlRegexSource: string? + urlRegexFlags: string? + notFound: + type: enum + literals: + - abort + - fallback + returns: + registrationId: string + + harForAPIRequestsStop: + internal: true + parameters: + registrationId: string + setWebSocketInterceptionPatterns: title: Route WebSockets group: route diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index ddec4af4c34a1..c30e5e3602985 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -1344,6 +1344,8 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, Channe setGeolocation(params: BrowserContextSetGeolocationParams, progress?: Progress): Promise; setHTTPCredentials(params: BrowserContextSetHTTPCredentialsParams, progress?: Progress): Promise; setNetworkInterceptionPatterns(params: BrowserContextSetNetworkInterceptionPatternsParams, progress?: Progress): Promise; + harForAPIRequestsStart(params: BrowserContextHarForAPIRequestsStartParams, progress?: Progress): Promise; + harForAPIRequestsStop(params: BrowserContextHarForAPIRequestsStopParams, progress?: Progress): Promise; setWebSocketInterceptionPatterns(params: BrowserContextSetWebSocketInterceptionPatternsParams, progress?: Progress): Promise; setOffline(params: BrowserContextSetOfflineParams, progress?: Progress): Promise; storageState(params: BrowserContextStorageStateParams, progress?: Progress): Promise; @@ -1577,6 +1579,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, diff --git a/tests/library/browsercontext-har.spec.ts b/tests/library/browsercontext-har.spec.ts index 98c3e1fe04feb..1c6c8f0df476f 100644 --- a/tests/library/browsercontext-har.spec.ts +++ b/tests/library/browsercontext-har.spec.ts @@ -610,3 +610,225 @@ it('should ignore aborted requests', async ({ contextFactory, server }) => { expect(result).toBe('timeout'); } }); + +it.describe('interceptAPIRequests', () => { + it('should fulfill APIRequestContext requests from HAR', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/22869' } + }, async ({ contextFactory, server }, testInfo) => { + server.setRoute('/api/data', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ hello: 'live' })); + }); + + const harPath = testInfo.outputPath('api.har'); + const context1 = await contextFactory(); + await context1.routeFromHAR(harPath, { update: true }); + const page1 = await context1.newPage(); + await page1.goto(server.EMPTY_PAGE); + const recorded = await page1.request.get(server.PREFIX + '/api/data'); + expect(await recorded.json()).toEqual({ hello: 'live' }); + await context1.close(); + + // Now stop serving on the network side - the request must come from the HAR. + server.setRoute('/api/data', (req, res) => res.end('NOT_FROM_HAR')); + const context2 = await contextFactory(); + await context2.routeFromHAR(harPath, { interceptAPIRequests: true }); + const page2 = await context2.newPage(); + const replayed = await page2.request.get(server.PREFIX + '/api/data'); + expect(await replayed.json()).toEqual({ hello: 'live' }); + await context2.close(); + }); + + it('should not intercept APIRequestContext requests by default (backward compat)', async ({ contextFactory, server }, testInfo) => { + server.setRoute('/api/data', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ hello: 'live' })); + }); + + const harPath = testInfo.outputPath('api.har'); + const context1 = await contextFactory(); + await context1.routeFromHAR(harPath, { update: true }); + const page1 = await context1.newPage(); + await page1.goto(server.EMPTY_PAGE); + await page1.request.get(server.PREFIX + '/api/data'); + await context1.close(); + + // Without the option, the live network is hit. + server.setRoute('/api/data', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ hello: 'fresh' })); + }); + const context2 = await contextFactory(); + await context2.routeFromHAR(harPath, { notFound: 'fallback' }); + const page2 = await context2.newPage(); + const replayed = await page2.request.get(server.PREFIX + '/api/data'); + expect(await replayed.json()).toEqual({ hello: 'fresh' }); + }); + + it('should fall back to the network when interceptAPIRequests + notFound:fallback', async ({ contextFactory, server }, testInfo) => { + const harPath = testInfo.outputPath('api.har'); + const context1 = await contextFactory(); + await context1.routeFromHAR(harPath, { update: true }); + const page1 = await context1.newPage(); + await page1.goto(server.EMPTY_PAGE); + await context1.close(); + + server.setRoute('/api/missing', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ source: 'network' })); + }); + + const context2 = await contextFactory(); + await context2.routeFromHAR(harPath, { interceptAPIRequests: true, notFound: 'fallback' }); + const page2 = await context2.newPage(); + const response = await page2.request.get(server.PREFIX + '/api/missing'); + expect(await response.json()).toEqual({ source: 'network' }); + }); + + it('should abort unmatched APIRequestContext requests when interceptAPIRequests + notFound:abort', async ({ contextFactory, server }, testInfo) => { + const harPath = testInfo.outputPath('api.har'); + const context1 = await contextFactory(); + await context1.routeFromHAR(harPath, { update: true }); + const page1 = await context1.newPage(); + await page1.goto(server.EMPTY_PAGE); + await context1.close(); + + const context2 = await contextFactory(); + await context2.routeFromHAR(harPath, { interceptAPIRequests: true /* default notFound: abort */ }); + const page2 = await context2.newPage(); + const error = await page2.request.get(server.PREFIX + '/api/missing').catch(e => e); + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain('was not found in the HAR file'); + }); + + it('should respect url filter for APIRequestContext requests', async ({ contextFactory, server }, testInfo) => { + server.setRoute('/api/data', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ source: 'hario' })); + }); + server.setRoute('/other', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ source: 'live' })); + }); + + const harPath = testInfo.outputPath('api.har'); + const context1 = await contextFactory(); + await context1.routeFromHAR(harPath, { update: true }); + const page1 = await context1.newPage(); + await page1.goto(server.EMPTY_PAGE); + await page1.request.get(server.PREFIX + '/api/data'); + await page1.request.get(server.PREFIX + '/other'); + await context1.close(); + + // Re-route /api/data so that only the HAR can produce 'hario'. + server.setRoute('/api/data', (req, res) => res.end('NOT_FROM_HAR')); + + const context2 = await contextFactory(); + await context2.routeFromHAR(harPath, { interceptAPIRequests: true, url: '**/api/**', notFound: 'fallback' }); + const page2 = await context2.newPage(); + const fromHar = await page2.request.get(server.PREFIX + '/api/data'); + expect(await fromHar.json()).toEqual({ source: 'hario' }); + // /other does not match the url filter, so it hits the network. + const fromNetwork = await page2.request.get(server.PREFIX + '/other'); + expect(await fromNetwork.json()).toEqual({ source: 'live' }); + }); + + it('should match APIRequestContext POST requests by body', async ({ contextFactory, server }, testInfo) => { + server.setRoute('/echo', (req, res) => { + const chunks: Buffer[] = []; + req.on('data', c => chunks.push(c)); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ echoed: Buffer.concat(chunks).toString() })); + }); + }); + + const harPath = testInfo.outputPath('api.har'); + const context1 = await contextFactory(); + await context1.routeFromHAR(harPath, { update: true }); + const page1 = await context1.newPage(); + await page1.goto(server.EMPTY_PAGE); + await page1.request.post(server.PREFIX + '/echo', { data: 'one' }); + await page1.request.post(server.PREFIX + '/echo', { data: 'two' }); + await context1.close(); + + server.setRoute('/echo', (req, res) => res.end('NOT_FROM_HAR')); + const context2 = await contextFactory(); + await context2.routeFromHAR(harPath, { interceptAPIRequests: true }); + const page2 = await context2.newPage(); + const r1 = await page2.request.post(server.PREFIX + '/echo', { data: 'one' }); + const r2 = await page2.request.post(server.PREFIX + '/echo', { data: 'two' }); + expect(await r1.json()).toEqual({ echoed: 'one' }); + expect(await r2.json()).toEqual({ echoed: 'two' }); + }); + + it('should stop intercepting APIRequestContext requests after unrouteAll', async ({ contextFactory, server }, testInfo) => { + server.setRoute('/api/data', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ source: 'hario' })); + }); + + const harPath = testInfo.outputPath('api.har'); + const context1 = await contextFactory(); + await context1.routeFromHAR(harPath, { update: true }); + const page1 = await context1.newPage(); + await page1.goto(server.EMPTY_PAGE); + await page1.request.get(server.PREFIX + '/api/data'); + await context1.close(); + + const context2 = await contextFactory(); + await context2.routeFromHAR(harPath, { interceptAPIRequests: true }); + const page2 = await context2.newPage(); + // First call: served from HAR. + const first = await page2.request.get(server.PREFIX + '/api/data'); + expect(await first.json()).toEqual({ source: 'hario' }); + + await context2.unrouteAll(); + server.setRoute('/api/data', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ source: 'live' })); + }); + + // After unrouteAll: the registration is gone, the live network is hit. + const second = await page2.request.get(server.PREFIX + '/api/data'); + expect(await second.json()).toEqual({ source: 'live' }); + }); + + it('should only match _apiRequest entries when intercepting APIRequestContext', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/22869' } + }, async ({ contextFactory, server }, testInfo) => { + // The HAR will contain TWO entries for the same URL: one from a browser fetch and one from + // page.request. interceptAPIRequests must serve only the API-request entry. + server.setRoute('/data', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + const fromApi = req.headers['x-from'] === 'api'; + res.end(JSON.stringify({ source: fromApi ? 'recorded-api' : 'recorded-browser' })); + }); + + const harPath = testInfo.outputPath('api.har'); + const context1 = await contextFactory(); + await context1.routeFromHAR(harPath, { update: true }); + const page1 = await context1.newPage(); + await page1.goto(server.EMPTY_PAGE); + // Browser-side fetch — recorded WITHOUT _apiRequest. + await page1.evaluate(url => fetch(url, { headers: { 'x-from': 'browser' } }).then(r => r.json()), server.PREFIX + '/data'); + // API-request — recorded WITH _apiRequest:true. + await page1.request.get(server.PREFIX + '/data', { headers: { 'x-from': 'api' } }); + await context1.close(); + + // Sanity: the HAR must contain at least one _apiRequest entry. + const harText = fs.readFileSync(harPath, 'utf-8'); + expect(harText).toContain('"_apiRequest":true'); + + // Make the live network unreachable for this URL — if interception works correctly + // we never hit the network anyway. + server.setRoute('/data', (req, res) => res.end('NOT_FROM_HAR')); + + const context2 = await contextFactory(); + await context2.routeFromHAR(harPath, { interceptAPIRequests: true }); + const page2 = await context2.newPage(); + const apiResponse = await page2.request.get(server.PREFIX + '/data', { headers: { 'x-from': 'api' } }); + // Must be the recorded API entry, not the recorded browser entry. + expect(await apiResponse.json()).toEqual({ source: 'recorded-api' }); + }); +});