chore: chain routes from multiple connections (#36105)

This commit is contained in:
Dmitry Gozman 2025-05-30 14:59:24 +00:00 committed by GitHub
parent 7cbc04e5ac
commit 70d64a2978
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 224 additions and 92 deletions

View File

@ -70,7 +70,7 @@ export abstract class BrowserContext extends SdkObject {
readonly _pageBindings = new Map<string, PageBinding>();
readonly _activeProgressControllers = new Set<ProgressController>();
readonly _options: types.BrowserContextOptions;
_requestInterceptor?: network.RouteHandler;
readonly requestInterceptors: network.RouteHandler[] = [];
private _isPersistentContext: boolean;
private _closedStatus: 'open' | 'closing' | 'closed' = 'open';
readonly _closePromise: Promise<Error>;
@ -200,8 +200,6 @@ export abstract class BrowserContext extends SdkObject {
this.selectors().setTestIdAttributeName(params.testIdAttributeName);
}
await this._cancelAllRoutesInFlight();
// Close extra pages early.
let page: Page | undefined = this.pages()[0];
const [, ...otherPages] = this.pages();
@ -439,8 +437,17 @@ export abstract class BrowserContext extends SdkObject {
await this.doRemoveInitScripts(initScripts);
}
async setRequestInterceptor(handler: network.RouteHandler | undefined): Promise<void> {
this._requestInterceptor = handler;
async addRequestInterceptor(handler: network.RouteHandler): Promise<void> {
this.requestInterceptors.push(handler);
await this.doUpdateRequestInterception();
}
async removeRequestInterceptor(handler: network.RouteHandler): Promise<void> {
const index = this.requestInterceptors.indexOf(handler);
if (index === -1)
return;
this.requestInterceptors.splice(index, 1);
await this.notifyRoutesInFlightAboutRemovedHandler(handler);
await this.doUpdateRequestInterception();
}
@ -547,10 +554,9 @@ export abstract class BrowserContext extends SdkObject {
if (originsToSave.size) {
const internalMetadata = serverSideCallMetadata();
const page = await this.newPage(internalMetadata);
await page.setServerRequestInterceptor(handler => {
handler.fulfill({ body: '<html></html>' }).catch(() => {});
return true;
});
page.addRequestInterceptor(route => {
route.fulfill({ body: '<html></html>' }).catch(() => {});
}, 'prepend');
for (const origin of originsToSave) {
const frame = page.mainFrame();
await frame.goto(internalMetadata, origin, { timeout: 0 });
@ -577,10 +583,10 @@ export abstract class BrowserContext extends SdkObject {
// as a user-visible page.
isServerSide: false,
});
await page.setServerRequestInterceptor(handler => {
handler.fulfill({ body: '<html></html>' }).catch(() => {});
return true;
});
const interceptor = (route: network.Route) => {
route.fulfill({ body: '<html></html>' }).catch(() => {});
};
await page.addRequestInterceptor(interceptor, 'prepend');
for (const origin of new Set([...oldOrigins, ...newOrigins.keys()])) {
const frame = page.mainFrame();
@ -588,7 +594,7 @@ export abstract class BrowserContext extends SdkObject {
await frame.resetStorageForCurrentOriginBestEffort(newOrigins.get(origin));
}
await page.setServerRequestInterceptor(undefined);
await page.removeRequestInterceptor(interceptor);
this._origins = new Set([...newOrigins.keys()]);
// It is safe to not restore the URL to about:blank since we are doing it in Page::resetForReuse.
@ -612,10 +618,9 @@ export abstract class BrowserContext extends SdkObject {
if (state.origins && state.origins.length) {
const internalMetadata = serverSideCallMetadata();
const page = await this.newPage(internalMetadata);
await page.setServerRequestInterceptor(handler => {
handler.fulfill({ body: '<html></html>' }).catch(() => {});
return true;
});
await page.addRequestInterceptor(route => {
route.fulfill({ body: '<html></html>' }).catch(() => {});
}, 'prepend');
for (const originState of state.origins) {
const frame = page.mainFrame();
await frame.goto(metadata, originState.origin, { timeout: 0 });
@ -667,9 +672,8 @@ export abstract class BrowserContext extends SdkObject {
this._routesInFlight.delete(route);
}
async _cancelAllRoutesInFlight() {
await Promise.all([...this._routesInFlight].map(r => r.abort())).catch(() => {});
this._routesInFlight.clear();
async notifyRoutesInFlightAboutRemovedHandler(handler: network.RouteHandler): Promise<void> {
await Promise.all([...this._routesInFlight].map(route => route.removeHandler(handler)));
}
}

View File

@ -84,7 +84,7 @@ export class CRServiceWorker extends Worker {
}
needsRequestInterception(): boolean {
return this._isNetworkInspectionEnabled() && !!this.browserContext._requestInterceptor;
return this._isNetworkInspectionEnabled() && this.browserContext.requestInterceptors.length > 0;
}
reportRequestFinished(request: network.Request, response: network.Response | null) {
@ -101,12 +101,8 @@ export class CRServiceWorker extends Worker {
requestStarted(request: network.Request, route?: network.RouteDelegate) {
this.browserContext.emit(BrowserContext.Events.Request, request);
if (route) {
const r = new network.Route(request, route);
if (this.browserContext._requestInterceptor?.(r, request))
return;
r.continue({ isFallback: true }).catch(() => {});
}
if (route)
new network.Route(request, route).handle(this.browserContext.requestInterceptors);
}
private _isNetworkInspectionEnabled(): boolean {

View File

@ -39,7 +39,7 @@ import type { Artifact } from '../artifact';
import type { ConsoleMessage } from '../console';
import type { Dialog } from '../dialog';
import type { CallMetadata } from '../instrumentation';
import type { Request, Response } from '../network';
import type { Request, Response, RouteHandler } from '../network';
import type { InitScript, Page, PageBinding } from '../page';
import type { DispatcherScope } from './dispatcher';
import type { FrameDispatcher } from './frameDispatcher';
@ -55,6 +55,8 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
private _initScritps: InitScript[] = [];
private _dialogHandler: (dialog: Dialog) => boolean;
private _clockPaused = false;
private _requestInterceptor: RouteHandler;
private _interceptionUrlMatchers: (string | RegExp)[] = [];
static from(parentScope: DispatcherScope, context: BrowserContext): BrowserContextDispatcher {
const result = parentScope.connection.existingDispatcher<BrowserContextDispatcher>(context);
@ -76,9 +78,21 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
this.adopt(requestContext);
this.adopt(tracing);
this._requestInterceptor = (route, request) => {
const matchesSome = this._interceptionUrlMatchers.some(urlMatch => urlMatches(this._context._options.baseURL, request.url(), urlMatch));
// If there is already a dispatcher, that means we've already routed this request through page.
// Client expects a single `route` event, either on the page or on the context, so we can just fallback here.
const routeDispatcher = this.connection.existingDispatcher<RouteDispatcher>(route);
if (!matchesSome || routeDispatcher) {
route.continue({ isFallback: true }).catch(() => {});
return;
}
this._dispatchEvent('route', { route: new RouteDispatcher(RequestDispatcher.from(this, request), route) });
};
this._context = context;
// Note: when launching persistent context, dispatcher is created very late,
// so we can already have pages, videos and everything else.
// Note: when launching persistent context, or connecting to an existing browser,
// dispatcher is created very late, so we can already have pages, videos and everything else.
const onVideo = (artifact: Artifact) => {
// Note: Video must outlive Page and BrowserContext, so that client can saveAs it
@ -276,18 +290,18 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
}
async setNetworkInterceptionPatterns(params: channels.BrowserContextSetNetworkInterceptionPatternsParams): Promise<void> {
const hadMatchers = this._interceptionUrlMatchers.length > 0;
if (!params.patterns.length) {
await this._context.setRequestInterceptor(undefined);
return;
// Note: it is important to remove the interceptor when there are no patterns,
// because that disables the slow-path interception in the browser itself.
if (hadMatchers)
await this._context.removeRequestInterceptor(this._requestInterceptor);
this._interceptionUrlMatchers = [];
} else {
this._interceptionUrlMatchers = params.patterns.map(pattern => pattern.regexSource ? new RegExp(pattern.regexSource, pattern.regexFlags!) : pattern.glob!);
if (!hadMatchers)
await this._context.addRequestInterceptor(this._requestInterceptor);
}
const urlMatchers = params.patterns.map(pattern => pattern.regexSource ? new RegExp(pattern.regexSource, pattern.regexFlags!) : pattern.glob!);
await this._context.setRequestInterceptor((route, request) => {
const matchesSome = urlMatchers.some(urlMatch => urlMatches(this._context._options.baseURL, request.url(), urlMatch));
if (!matchesSome)
return false;
this._dispatchEvent('route', { route: RouteDispatcher.from(RequestDispatcher.from(this, request), route) });
return true;
});
}
async setWebSocketInterceptionPatterns(params: channels.PageSetWebSocketInterceptionPatternsParams, metadata: CallMetadata): Promise<void> {
@ -383,8 +397,11 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
// Avoid protocol calls for the closed context.
if (this._context.isClosingOrClosed())
return;
// Cleanup properly and leave the page in a good state. Other clients may still connect and use it.
this._context.dialogManager.removeDialogHandler(this._dialogHandler);
this._context.setRequestInterceptor(undefined).catch(() => {});
this._interceptionUrlMatchers = [];
this._context.removeRequestInterceptor(this._requestInterceptor).catch(() => {});
this._context.removeExposedBindings(this._bindings).catch(() => {});
this._bindings = [];
this._context.removeInitScripts(this._initScritps).catch(() => {});

View File

@ -123,19 +123,23 @@ export class ResponseDispatcher extends Dispatcher<Response, channels.ResponseCh
export class RouteDispatcher extends Dispatcher<Route, channels.RouteChannel, RequestDispatcher> implements channels.RouteChannel {
_type_Route = true;
static from(scope: RequestDispatcher, route: Route): RouteDispatcher {
const result = scope.connection.existingDispatcher<RouteDispatcher>(route);
return result || new RouteDispatcher(scope, route);
}
private _handled = false;
private constructor(scope: RequestDispatcher, route: Route) {
constructor(scope: RequestDispatcher, route: Route) {
super(scope, route, 'Route', {
// Context route can point to a non-reported request, so we send the request in the initializer.
request: scope
});
}
private _checkNotHandled() {
if (this._handled)
throw new Error('Route is already handled!');
this._handled = true;
}
async continue(params: channels.RouteContinueParams, metadata: CallMetadata): Promise<channels.RouteContinueResult> {
this._checkNotHandled();
await this._object.continue({
url: params.url,
method: params.method,
@ -146,14 +150,17 @@ export class RouteDispatcher extends Dispatcher<Route, channels.RouteChannel, Re
}
async fulfill(params: channels.RouteFulfillParams, metadata: CallMetadata): Promise<void> {
this._checkNotHandled();
await this._object.fulfill(params);
}
async abort(params: channels.RouteAbortParams, metadata: CallMetadata): Promise<void> {
this._checkNotHandled();
await this._object.abort(params.errorCode || 'failed');
}
async redirectNavigationRequest(params: channels.RouteRedirectNavigationRequestParams): Promise<void> {
this._checkNotHandled();
await this._object.redirectNavigationRequest(params.url);
}
}

View File

@ -37,6 +37,7 @@ import type { CallMetadata } from '../instrumentation';
import type { JSHandle } from '../javascript';
import type { BrowserContextDispatcher } from './browserContextDispatcher';
import type { Frame } from '../frames';
import type { RouteHandler } from '../network';
import type { InitScript, PageBinding } from '../page';
import type * as channels from '@protocol/channels';
@ -48,6 +49,8 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
_webSocketInterceptionPatterns: channels.PageSetWebSocketInterceptionPatternsParams['patterns'] = [];
private _bindings: PageBinding[] = [];
private _initScripts: InitScript[] = [];
private _requestInterceptor: RouteHandler;
private _interceptionUrlMatchers: (string | RegExp)[] = [];
private _locatorHandlers = new Set<number>();
private _jsCoverageActive = false;
private _cssCoverageActive = false;
@ -80,6 +83,15 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
this.adopt(mainFrame);
this._page = page;
this._requestInterceptor = (route, request) => {
const matchesSome = this._interceptionUrlMatchers.some(urlMatch => urlMatches(this._page.browserContext._options.baseURL, request.url(), urlMatch));
if (!matchesSome) {
route.continue({ isFallback: true }).catch(() => {});
return;
}
this._dispatchEvent('route', { route: new RouteDispatcher(RequestDispatcher.from(this.parentScope(), request), route) });
};
this.addObjectListener(Page.Events.Close, () => {
this._dispatchEvent('close');
this._dispose();
@ -179,18 +191,18 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
}
async setNetworkInterceptionPatterns(params: channels.PageSetNetworkInterceptionPatternsParams, metadata: CallMetadata): Promise<void> {
const hadMatchers = this._interceptionUrlMatchers.length > 0;
if (!params.patterns.length) {
await this._page.setClientRequestInterceptor(undefined);
return;
// Note: it is important to remove the interceptor when there are no patterns,
// because that disables the slow-path interception in the browser itself.
if (hadMatchers)
await this._page.removeRequestInterceptor(this._requestInterceptor);
this._interceptionUrlMatchers = [];
} else {
this._interceptionUrlMatchers = params.patterns.map(pattern => pattern.regexSource ? new RegExp(pattern.regexSource, pattern.regexFlags!) : pattern.glob!);
if (!hadMatchers)
await this._page.addRequestInterceptor(this._requestInterceptor);
}
const urlMatchers = params.patterns.map(pattern => pattern.regexSource ? new RegExp(pattern.regexSource, pattern.regexFlags!) : pattern.glob!);
await this._page.setClientRequestInterceptor((route, request) => {
const matchesSome = urlMatchers.some(urlMatch => urlMatches(this._page.browserContext._options.baseURL, request.url(), urlMatch));
if (!matchesSome)
return false;
this._dispatchEvent('route', { route: RouteDispatcher.from(RequestDispatcher.from(this.parentScope(), request), route) });
return true;
});
}
async setWebSocketInterceptionPatterns(params: channels.PageSetWebSocketInterceptionPatternsParams, metadata: CallMetadata): Promise<void> {
@ -343,7 +355,10 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
// Avoid protocol calls for the closed page.
if (this._page.isClosedOrClosingOrCrashed())
return;
this._page.setClientRequestInterceptor(undefined).catch(() => {});
// Cleanup properly and leave the page in a good state. Other clients may still connect and use it.
this._interceptionUrlMatchers = [];
this._page.removeRequestInterceptor(this._requestInterceptor).catch(() => {});
this._page.removeExposedBindings(this._bindings).catch(() => {});
this._bindings = [];
this._page.removeInitScripts(this._initScripts).catch(() => {});

View File

@ -387,8 +387,8 @@ export class FFBrowserContext extends BrowserContext {
async doUpdateRequestInterception(): Promise<void> {
await Promise.all([
this._browser.session.send('Browser.setRequestInterception', { browserContextId: this._browserContextId, enabled: !!this._requestInterceptor }),
this._browser.session.send('Browser.setCacheDisabled', { browserContextId: this._browserContextId, cacheDisabled: !!this._requestInterceptor }),
this._browser.session.send('Browser.setRequestInterception', { browserContextId: this._browserContextId, enabled: this.requestInterceptors.length > 0 }),
this._browser.session.send('Browser.setCacheDisabled', { browserContextId: this._browserContextId, cacheDisabled: this.requestInterceptors.length > 0 }),
]);
}

View File

@ -301,16 +301,8 @@ export class FrameManager {
return;
}
this._page.emitOnContext(BrowserContext.Events.Request, request);
if (route) {
const r = new network.Route(request, route);
if (this._page.serverRequestInterceptor?.(r, request))
return;
if (this._page.clientRequestInterceptor?.(r, request))
return;
if (this._page.browserContext._requestInterceptor?.(r, request))
return;
r.continue({ isFallback: true }).catch(() => {});
}
if (route)
new network.Route(request, route).handle([...this._page.requestInterceptors, ...this._page.browserContext.requestInterceptors]);
}
requestReceivedResponse(response: network.Response) {

View File

@ -292,7 +292,7 @@ export class HarTracer {
}
private _recordRequestOverrides(harEntry: har.Entry, request: network.Request) {
if (!request._hasOverrides() || !this._options.recordRequestOverrides)
if (!request.overrides() || !this._options.recordRequestOverrides)
return;
harEntry.request.method = request.method();
harEntry.request.url = request.url();

View File

@ -137,9 +137,10 @@ export class Request extends SdkObject {
this._waitForResponsePromise.resolve(null);
}
_setOverrides(overrides: types.NormalizedContinueOverrides) {
this._overrides = overrides;
_applyOverrides(overrides: types.NormalizedContinueOverrides) {
this._overrides = { ...this._overrides, ...overrides };
this._updateHeadersMap();
return this._overrides;
}
private _updateHeadersMap() {
@ -147,8 +148,8 @@ export class Request extends SdkObject {
this._headersMap.set(name.toLowerCase(), value);
}
_hasOverrides() {
return !!this._overrides;
overrides() {
return this._overrides;
}
url(): string {
@ -251,6 +252,8 @@ export class Route extends SdkObject {
private readonly _request: Request;
private readonly _delegate: RouteDelegate;
private _handled = false;
private _currentHandler: RouteHandler | undefined;
private _futureHandlers: RouteHandler[] = [];
constructor(request: Request, delegate: RouteDelegate) {
super(request._frame || request._context, 'route');
@ -259,6 +262,19 @@ export class Route extends SdkObject {
this._request._context.addRouteInFlight(this);
}
handle(handlers: RouteHandler[]) {
this._futureHandlers = [...handlers];
this.continue({ isFallback: true }).catch(() => {});
}
async removeHandler(handler: RouteHandler) {
this._futureHandlers = this._futureHandlers.filter(h => h !== handler);
if (handler === this._currentHandler) {
await this.continue({ isFallback: true }).catch(() => {});
return;
}
}
request(): Request {
return this._request;
}
@ -274,6 +290,7 @@ export class Route extends SdkObject {
this._startHandling();
assert(this._request.isNavigationRequest());
this._request.frame()!.redirectNavigation(url, this._request._documentId!, this._request.headerValue('referer'));
this._endHandling();
}
async fulfill(overrides: channels.RouteFulfillParams) {
@ -322,7 +339,6 @@ export class Route extends SdkObject {
}
async continue(overrides: types.NormalizedContinueOverrides) {
this._startHandling();
if (overrides.url) {
const newUrl = new URL(overrides.url);
const oldUrl = new URL(this._request.url());
@ -331,9 +347,18 @@ export class Route extends SdkObject {
}
if (overrides.headers)
overrides.headers = overrides.headers?.filter(header => header.name.toLowerCase() !== 'cookie');
this._request._setOverrides(overrides);
overrides = this._request._applyOverrides(overrides);
const nextHandler = this._futureHandlers.shift();
if (nextHandler) {
this._currentHandler = nextHandler;
nextHandler(this, this._request);
return;
}
if (!overrides.isFallback)
this._request._context.emit(BrowserContext.Events.RequestContinued, this._request);
this._startHandling();
await this._delegate.continue(overrides);
this._endHandling();
}
@ -341,14 +366,17 @@ export class Route extends SdkObject {
private _startHandling() {
assert(!this._handled, 'Route is already handled!');
this._handled = true;
this._currentHandler = undefined;
}
private _endHandling() {
this._futureHandlers = [];
this._currentHandler = undefined;
this._request._context.removeRouteInFlight(this);
}
}
export type RouteHandler = (route: Route, request: Request) => boolean;
export type RouteHandler = (route: Route, request: Request) => void;
type GetResponseBodyCallback = () => Promise<Buffer>;

View File

@ -160,8 +160,7 @@ export class Page extends SdkObject {
private _workers = new Map<string, Worker>();
readonly pdf: ((options: channels.PagePdfParams) => Promise<Buffer>) | undefined;
readonly coverage: any;
clientRequestInterceptor: network.RouteHandler | undefined;
serverRequestInterceptor: network.RouteHandler | undefined;
readonly requestInterceptors: network.RouteHandler[] = [];
video: Artifact | null = null;
private _opener: Page | undefined;
private _isServerSideOnly = false;
@ -258,8 +257,6 @@ export class Page extends SdkObject {
async resetForReuse(metadata: CallMetadata) {
this._locatorHandlers.clear();
await this.setClientRequestInterceptor(undefined);
await this.setServerRequestInterceptor(undefined);
// Re-navigate once init scripts are gone.
// TODO: we should have a timeout for `resetForReuse`.
await this.mainFrame().goto(metadata, 'about:blank', { timeout: 0 });
@ -571,16 +568,23 @@ export class Page extends SdkObject {
}
needsRequestInterception(): boolean {
return !!this.clientRequestInterceptor || !!this.serverRequestInterceptor || !!this.browserContext._requestInterceptor;
return this.requestInterceptors.length > 0 || this.browserContext.requestInterceptors.length > 0;
}
async setClientRequestInterceptor(handler: network.RouteHandler | undefined): Promise<void> {
this.clientRequestInterceptor = handler;
async addRequestInterceptor(handler: network.RouteHandler, prepend?: 'prepend'): Promise<void> {
if (prepend)
this.requestInterceptors.unshift(handler);
else
this.requestInterceptors.push(handler);
await this.delegate.updateRequestInterception();
}
async setServerRequestInterceptor(handler: network.RouteHandler | undefined): Promise<void> {
this.serverRequestInterceptor = handler;
async removeRequestInterceptor(handler: network.RouteHandler): Promise<void> {
const index = this.requestInterceptors.indexOf(handler);
if (index === -1)
return;
this.requestInterceptors.splice(index, 1);
await this.browserContext.notifyRoutesInFlightAboutRemovedHandler(handler);
await this.delegate.updateRequestInterception();
}

View File

@ -63,9 +63,11 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
private async _init() {
await syncLocalStorageWithSettings(this._page, 'recorder');
await this._page.setServerRequestInterceptor(route => {
if (!route.request().url().startsWith('https://playwright/'))
return false;
await this._page.addRequestInterceptor(route => {
if (!route.request().url().startsWith('https://playwright/')) {
route.continue({ isFallback: true }).catch(() => {});
return;
}
const uri = route.request().url().substring('https://playwright/'.length);
const file = require.resolve('../../vite/recorder/' + uri);
@ -79,7 +81,6 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
isBase64: true
}).catch(() => {});
});
return true;
});
await this._page.exposeBinding('dispatch', false, (_, data: any) => this.emit('event', data));

View File

@ -50,7 +50,7 @@ const test = playwrightTest.extend<ExtraFixtures>({
});
test.slow(true, 'All connect tests are slow');
test.skip(({ mode }) => mode.startsWith('service'));
test.skip(({ mode }) => mode !== 'default');
async function disconnect(page: Page) {
await page.context().browser().close();
@ -161,6 +161,74 @@ test('last emulateMedia wins', async ({ twoPages }) => {
expect(await pageA.evaluate(() => window.matchMedia('print').matches)).toBe(false);
});
test('should chain routes', async ({ twoPages, server }) => {
const { pageA, pageB } = twoPages;
server.setRoute('/foo', (req, res) => res.writeHead(200, { 'Content-Type': 'text/html' }).end('<div>server-foo</div>'));
server.setRoute('/bar', (req, res) => res.writeHead(200, { 'Content-Type': 'text/html' }).end('<div>server-bar</div>'));
server.setRoute('/qux', (req, res) => res.writeHead(200, { 'Content-Type': 'text/html' }).end('<div>server-qux</div>'));
let stall = false;
let stallCallback;
const stallPromise = new Promise(f => stallCallback = f);
await pageA.route('**/foo', async route => {
if (stall)
stallCallback();
else
await route.fallback();
});
await pageA.route('**/bar', async route => {
await route.fulfill({ body: '<div>intercepted-bar</div>', contentType: 'text/html' });
});
await pageB.route('**/foo', async route => {
await route.fulfill({ body: '<div>intercepted2-foo</div>', contentType: 'text/html' });
});
await pageB.route('**/bar', async route => {
await route.fulfill({ body: '<div>intercepted2-bar</div>', contentType: 'text/html' });
});
await pageB.route('**/qux', async route => {
await route.fulfill({ body: '<div>intercepted2-qux</div>', contentType: 'text/html' });
});
await pageA.goto(server.PREFIX + '/foo');
await expect(pageB.locator('div')).toHaveText('intercepted2-foo');
await pageA.goto(server.PREFIX + '/bar');
await expect(pageB.locator('div')).toHaveText('intercepted-bar');
await pageA.goto(server.PREFIX + '/qux');
await expect(pageB.locator('div')).toHaveText('intercepted2-qux');
stall = true;
const gotoPromise = pageB.goto(server.PREFIX + '/foo');
await stallPromise;
await pageA.context().browser().close();
await gotoPromise;
await expect(pageB.locator('div')).toHaveText('intercepted2-foo');
await pageB.goto(server.PREFIX + '/bar');
await expect(pageB.locator('div')).toHaveText('intercepted2-bar');
});
test.fixme('should chain routes with changed url', async ({ twoPages, server }) => {
const { pageA, pageB } = twoPages;
server.setRoute('/foo', (req, res) => res.writeHead(200, { 'Content-Type': 'text/html' }).end('<div>server-foo</div>'));
server.setRoute('/baz', (req, res) => res.writeHead(200, { 'Content-Type': 'text/html' }).end('<div>server-baz</div>'));
await pageA.route('**/foo', async route => {
await route.fallback({ url: server.PREFIX + '/baz' });
});
await pageB.route('**/baz', async route => {
await route.fulfill({ body: '<div>intercepted2-baz</div>', contentType: 'text/html' });
});
await pageA.goto(server.PREFIX + '/foo');
await expect(pageB.locator('div')).toHaveText('intercepted2-baz');
});
test('should remove exposed bindings upon disconnect', async ({ twoPages }) => {
const { pageA, pageB } = twoPages;