diff --git a/docs/api.md b/docs/api.md index 93f15158cc..078c53bf36 100644 --- a/docs/api.md +++ b/docs/api.md @@ -290,6 +290,7 @@ await context.close(); - [browserContext.exposeFunction(name, playwrightFunction)](#browsercontextexposefunctionname-playwrightfunction) - [browserContext.newPage()](#browsercontextnewpage) - [browserContext.pages()](#browsercontextpages) +- [browserContext.route(url, handler)](#browsercontextrouteurl-handler) - [browserContext.setCookies(cookies)](#browsercontextsetcookiescookies) - [browserContext.setDefaultNavigationTimeout(timeout)](#browsercontextsetdefaultnavigationtimeouttimeout) - [browserContext.setDefaultTimeout(timeout)](#browsercontextsetdefaulttimeouttimeout) @@ -466,6 +467,38 @@ Creates a new page in the browser context. An array of all pages inside the browser context. +#### browserContext.route(url, handler) +- `url` <[string]|[RegExp]|[function]\([string]\):[boolean]> A glob pattern, regex pattern or predicate receiving [URL] to match while routing. +- `handler` <[function]\([Request]\)> handler function to route the request. +- returns: <[Promise]>. + +Routing activates the request interception and enables `request.abort`, `request.continue` and `request.fulfill` methods on the request. This provides the capability to modify network requests that are made by any page in the browser context. + +Once request interception is enabled, every request matching the url pattern will stall unless it's continued, fulfilled or aborted. +An example of a naïve request interceptor that aborts all image requests: + +```js +const context = await browser.newContext(); +await context.route('**/*.{png,jpg,jpeg}', request => request.abort()); +const page = await context.newPage(); +await page.goto('https://example.com'); +await browser.close(); +``` + +or the same snippet using a regex pattern instead: + +```js +const context = await browser.newContext(); +await context.route(/(\.png$)|(\.jpg$)/, request => request.abort()); +const page = await context.newPage(); +await page.goto('https://example.com'); +await browser.close(); +``` + +Page routes (set up with [page.route(url, handler)](#pagerouteurl-handler)) take precedence over browser context routes when request matches both handlers. + +> **NOTE** Enabling request interception disables http cache. + #### browserContext.setCookies(cookies) - `cookies` <[Array]<[Object]>> - `name` <[string]> **required** @@ -1433,7 +1466,7 @@ If `key` is a single character and no modifier keys besides `Shift` are being he #### page.route(url, handler) - `url` <[string]|[RegExp]|[function]\([string]\):[boolean]> A glob pattern, regex pattern or predicate receiving [URL] to match while routing. -- `handler` <[function]\([Request]\)> handler function to router the request. +- `handler` <[function]\([Request]\)> handler function to route the request. - returns: <[Promise]>. Routing activates the request interception and enables `request.abort`, `request.continue` and @@ -1445,7 +1478,6 @@ An example of a naïve request interceptor that aborts all image requests: ```js const page = await browser.newPage(); await page.route('**/*.{png,jpg,jpeg}', request => request.abort()); -// await page.route(/\.(png|jpeg|jpg)$/, request => request.abort()); // <-- same thing await page.goto('https://example.com'); await browser.close(); ``` @@ -1459,7 +1491,9 @@ await page.goto('https://example.com'); await browser.close(); ``` -> **NOTE** Enabling request interception disables page caching. +Page routes take precedence over browser context routes (set up with [browserContext.route(url, handler)](#browsercontextrouteurl-handler)) when request matches both handlers. + +> **NOTE** Enabling request interception disables http cache. #### page.screenshot([options]) - `options` <[Object]> Options object which might have the following properties: @@ -3987,6 +4021,7 @@ const backgroundPage = await backroundPageTarget.page(); - [browserContext.exposeFunction(name, playwrightFunction)](#browsercontextexposefunctionname-playwrightfunction) - [browserContext.newPage()](#browsercontextnewpage) - [browserContext.pages()](#browsercontextpages) +- [browserContext.route(url, handler)](#browsercontextrouteurl-handler) - [browserContext.setCookies(cookies)](#browsercontextsetcookiescookies) - [browserContext.setDefaultNavigationTimeout(timeout)](#browsercontextsetdefaultnavigationtimeouttimeout) - [browserContext.setDefaultTimeout(timeout)](#browsercontextsetdefaulttimeouttimeout) diff --git a/src/browserContext.ts b/src/browserContext.ts index 8a30fe3e3d..6b8c2beb81 100644 --- a/src/browserContext.ts +++ b/src/browserContext.ts @@ -54,6 +54,7 @@ export interface BrowserContext { setHTTPCredentials(httpCredentials: types.Credentials | null): Promise; addInitScript(script: Function | string | { path?: string, content?: string }, ...args: any[]): Promise; exposeFunction(name: string, playwrightFunction: Function): Promise; + route(url: types.URLMatch, handler: network.RouteHandler): Promise; waitForEvent(event: string, optionsOrPredicate?: Function | (types.TimeoutOptions & { predicate?: Function })): Promise; close(): Promise; } @@ -62,6 +63,7 @@ export abstract class BrowserContextBase extends platform.EventEmitter implement readonly _timeoutSettings = new TimeoutSettings(); readonly _pageBindings = new Map(); readonly _options: BrowserContextOptions; + readonly _routes: { url: types.URLMatch, handler: (request: network.Request) => any }[] = []; _closed = false; private readonly _closePromise: Promise; private _closePromiseFulfill: ((error: Error) => void) | undefined; @@ -100,6 +102,7 @@ export abstract class BrowserContextBase extends platform.EventEmitter implement abstract setOffline(offline: boolean): Promise; abstract addInitScript(script: string | Function | { path?: string | undefined; content?: string | undefined; }, ...args: any[]): Promise; abstract exposeFunction(name: string, playwrightFunction: Function): Promise; + abstract route(url: types.URLMatch, handler: network.RouteHandler): Promise; abstract close(): Promise; setDefaultNavigationTimeout(timeout: number) { diff --git a/src/chromium/crBrowser.ts b/src/chromium/crBrowser.ts index 315dbb69ea..bec8375e7d 100644 --- a/src/chromium/crBrowser.ts +++ b/src/chromium/crBrowser.ts @@ -393,6 +393,12 @@ export class CRBrowserContext extends BrowserContextBase { await (page._delegate as CRPage).exposeBinding(binding); } + async route(url: types.URLMatch, handler: network.RouteHandler): Promise { + this._routes.push({ url, handler }); + for (const page of this._existingPages()) + await (page._delegate as CRPage).updateRequestInterception(); + } + async close() { if (this._closed) return; diff --git a/src/chromium/crPage.ts b/src/chromium/crPage.ts index e9885ede93..20b33a038e 100644 --- a/src/chromium/crPage.ts +++ b/src/chromium/crPage.ts @@ -119,6 +119,7 @@ export class CRPage implements PageDelegate { if (options.geolocation) promises.push(this._client.send('Emulation.setGeolocationOverride', options.geolocation)); promises.push(this.updateExtraHTTPHeaders()); + promises.push(this.updateRequestInterception()); if (options.offline) promises.push(this._networkManager.setOffline(options.offline)); if (options.httpCredentials) @@ -376,8 +377,8 @@ export class CRPage implements PageDelegate { await this._client.send('Emulation.setEmulatedMedia', { media: mediaType || '', features }); } - async setRequestInterception(enabled: boolean): Promise { - await this._networkManager.setRequestInterception(enabled); + async updateRequestInterception(): Promise { + await this._networkManager.setRequestInterception(this._page._needsRequestInterception()); } async setFileChooserIntercepted(enabled: boolean) { diff --git a/src/firefox/ffBrowser.ts b/src/firefox/ffBrowser.ts index feedf0d72e..5b90207f20 100644 --- a/src/firefox/ffBrowser.ts +++ b/src/firefox/ffBrowser.ts @@ -290,6 +290,12 @@ export class FFBrowserContext extends BrowserContextBase { throw new Error('Not implemented'); } + async route(url: types.URLMatch, handler: network.RouteHandler): Promise { + this._routes.push({ url, handler }); + throw new Error('Not implemented'); + // TODO: update interception on the context if this is a first route. + } + async close() { if (this._closed) return; diff --git a/src/firefox/ffPage.ts b/src/firefox/ffPage.ts index e3201d6781..abfb6c168b 100644 --- a/src/firefox/ffPage.ts +++ b/src/firefox/ffPage.ts @@ -303,8 +303,8 @@ export class FFPage implements PageDelegate { }); } - async setRequestInterception(enabled: boolean): Promise { - await this._networkManager.setRequestInterception(enabled); + async updateRequestInterception(): Promise { + await this._networkManager.setRequestInterception(this._page._needsRequestInterception()); } async setFileChooserIntercepted(enabled: boolean) { diff --git a/src/network.ts b/src/network.ts index e06f09c120..37bee09c61 100644 --- a/src/network.ts +++ b/src/network.ts @@ -41,6 +41,8 @@ export type SetNetworkCookieParam = { sameSite?: 'Strict' | 'Lax' | 'None' }; +export type RouteHandler = (request: Request) => void; + export function filterCookies(cookies: NetworkCookie[], urls: string | string[] = []): NetworkCookie[] { if (!Array.isArray(urls)) urls = [ urls ]; diff --git a/src/page.ts b/src/page.ts index 3ff5375e49..2658f627aa 100644 --- a/src/page.ts +++ b/src/page.ts @@ -48,7 +48,7 @@ export interface PageDelegate { updateExtraHTTPHeaders(): Promise; setViewportSize(viewportSize: types.Size): Promise; setEmulateMedia(mediaType: types.MediaType | null, colorScheme: types.ColorScheme | null): Promise; - setRequestInterception(enabled: boolean): Promise; + updateRequestInterception(): Promise; setFileChooserIntercepted(enabled: boolean): Promise; canScreenshotOutsideViewport(): boolean; @@ -80,8 +80,6 @@ type PageState = { mediaType: types.MediaType | null; colorScheme: types.ColorScheme | null; extraHTTPHeaders: network.Headers | null; - interceptNetwork: boolean | null; - hasTouch: boolean | null; }; export type FileChooser = { @@ -127,7 +125,7 @@ export class Page extends platform.EventEmitter { private _workers = new Map(); readonly pdf: ((options?: types.PDFOptions) => Promise) | undefined; readonly coverage: any; - readonly _requestHandlers: { url: types.URLMatch, handler: (request: network.Request) => void }[] = []; + readonly _routes: { url: types.URLMatch, handler: (request: network.Request) => any }[] = []; _ownedContext: BrowserContext | undefined; constructor(delegate: PageDelegate, browserContext: BrowserContextBase) { @@ -150,8 +148,6 @@ export class Page extends platform.EventEmitter { mediaType: null, colorScheme: null, extraHTTPHeaders: null, - interceptNetwork: null, - hasTouch: null, }; this.accessibility = new accessibility.Accessibility(delegate.getAccessibilityTree.bind(delegate)); this.keyboard = new input.Keyboard(delegate.rawKeyboard); @@ -391,19 +387,26 @@ export class Page extends platform.EventEmitter { await this._delegate.evaluateOnNewDocument(await helper.evaluationScript(script, args)); } - async route(url: types.URLMatch, handler: (request: network.Request) => void) { - if (!this._state.interceptNetwork) { - this._state.interceptNetwork = true; - await this._delegate.setRequestInterception(true); - } - this._requestHandlers.push({ url, handler }); + _needsRequestInterception(): boolean { + return this._routes.length > 0 || this._browserContext._routes.length > 0; + } + + async route(url: types.URLMatch, handler: network.RouteHandler): Promise { + this._routes.push({ url, handler }); + await this._delegate.updateRequestInterception(); } _requestStarted(request: network.Request) { this.emit(Events.Page.Request, request); if (!request._isIntercepted()) return; - for (const { url, handler } of this._requestHandlers) { + for (const { url, handler } of this._routes) { + if (platform.urlMatches(request.url(), url)) { + handler(request); + return; + } + } + for (const { url, handler } of this._browserContext._routes) { if (platform.urlMatches(request.url(), url)) { handler(request); return; diff --git a/src/webkit/wkBrowser.ts b/src/webkit/wkBrowser.ts index d90865c7d0..4c85718923 100644 --- a/src/webkit/wkBrowser.ts +++ b/src/webkit/wkBrowser.ts @@ -315,6 +315,12 @@ export class WKBrowserContext extends BrowserContextBase { await (page._delegate as WKPage).exposeBinding(binding); } + async route(url: types.URLMatch, handler: network.RouteHandler): Promise { + this._routes.push({ url, handler }); + for (const page of this._existingPages()) + await (page._delegate as WKPage).updateRequestInterception(); + } + async close() { if (this._closed) return; diff --git a/src/webkit/wkPage.ts b/src/webkit/wkPage.ts index 6eff11d7d2..943c8d3e33 100644 --- a/src/webkit/wkPage.ts +++ b/src/webkit/wkPage.ts @@ -91,7 +91,7 @@ export class WKPage implements PageDelegate { if (contextOptions.javaScriptEnabled === false) promises.push(this._pageProxySession.send('Emulation.setJavaScriptEnabled', { enabled: false })); if (this._page._state.viewportSize || contextOptions.viewport) - promises.push(this._updateViewport(true /* updateTouch */)); + promises.push(this._updateViewport()); promises.push(this.updateHttpCredentials()); await Promise.all(promises); } @@ -132,8 +132,7 @@ export class WKPage implements PageDelegate { session.send('Network.enable'), this._workers.initializeSession(session) ]; - - if (this._page._state.interceptNetwork) + if (this._page._needsRequestInterception()) promises.push(session.send('Network.setInterceptionEnabled', { enabled: true, interceptRequests: true })); const contextOptions = this._browserContext._options; @@ -149,8 +148,7 @@ export class WKPage implements PageDelegate { promises.push(session.send('Network.setExtraHTTPHeaders', { headers: this._calculateExtraHTTPHeaders() })); if (contextOptions.offline) promises.push(session.send('Network.setEmulateOfflineState', { offline: true })); - if (this._page._state.hasTouch) - promises.push(session.send('Page.setTouchEmulationEnabled', { enabled: true })); + promises.push(session.send('Page.setTouchEmulationEnabled', { enabled: contextOptions.viewport ? !!contextOptions.viewport.isMobile : false })); if (contextOptions.timezoneId) { promises.push(session.send('Page.setTimeZone', { timeZone: contextOptions.timezoneId }). catch(e => { throw new Error(`Invalid timezone ID: ${contextOptions.timezoneId}`); })); @@ -476,10 +474,10 @@ export class WKPage implements PageDelegate { async setViewportSize(viewportSize: types.Size): Promise { assert(this._page._state.viewportSize === viewportSize); - await this._updateViewport(false /* updateTouch */); + await this._updateViewport(); } - async _updateViewport(updateTouch: boolean): Promise { + async _updateViewport(): Promise { let viewport = this._browserContext._options.viewport || { width: 0, height: 0 }; const viewportSize = this._page._state.viewportSize; if (viewportSize) @@ -492,12 +490,11 @@ export class WKPage implements PageDelegate { deviceScaleFactor: viewport.deviceScaleFactor || 1 }), ]; - if (updateTouch) - promises.push(this._updateState('Page.setTouchEmulationEnabled', { enabled: !!viewport.isMobile })); await Promise.all(promises); } - async setRequestInterception(enabled: boolean): Promise { + async updateRequestInterception(): Promise { + const enabled = this._page._needsRequestInterception(); await this._updateState('Network.setInterceptionEnabled', { enabled, interceptRequests: enabled }); } @@ -735,7 +732,7 @@ export class WKPage implements PageDelegate { // TODO(einbinder) this will fail if we are an XHR document request const isNavigationRequest = event.type === 'Document'; const documentId = isNavigationRequest ? event.loaderId : undefined; - const request = new WKInterceptableRequest(session, !!this._page._state.interceptNetwork, frame, event, redirectChain, documentId); + const request = new WKInterceptableRequest(session, this._page._needsRequestInterception(), frame, event, redirectChain, documentId); this._requestIdToRequest.set(event.requestId, request); this._page._frameManager.requestStarted(request.request); } diff --git a/test/browsercontext.spec.js b/test/browsercontext.spec.js index 7066f921bd..61f6e04839 100644 --- a/test/browsercontext.spec.js +++ b/test/browsercontext.spec.js @@ -367,6 +367,44 @@ module.exports.describe = function({testRunner, expect, playwright, CHROMIUM, FF }); }); + describe.fail(FFOX)('BrowserContext.route', () => { + it('should intercept', async({browser, server}) => { + const context = await browser.newContext(); + let intercepted = false; + await context.route('**/empty.html', request => { + intercepted = true; + expect(request.url()).toContain('empty.html'); + expect(request.headers()['user-agent']).toBeTruthy(); + expect(request.method()).toBe('GET'); + expect(request.postData()).toBe(undefined); + expect(request.isNavigationRequest()).toBe(true); + expect(request.resourceType()).toBe('document'); + expect(request.frame() === page.mainFrame()).toBe(true); + expect(request.frame().url()).toBe('about:blank'); + request.continue(); + }); + const page = await context.newPage(); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.ok()).toBe(true); + expect(intercepted).toBe(true); + await context.close(); + }); + it('should yield to page.route', async({browser, server}) => { + const context = await browser.newContext(); + await context.route('**/empty.html', request => { + request.fulfill({ status: 200, body: 'context' }); + }); + const page = await context.newPage(); + await page.route('**/empty.html', request => { + request.fulfill({ status: 200, body: 'page' }); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.ok()).toBe(true); + expect(await response.text()).toBe('page'); + await context.close(); + }); + }); + describe('BrowserContext.setHTTPCredentials', function() { it('should work', async({browser, server}) => { server.setAuth('/empty.html', 'user', 'pass'); diff --git a/test/interception.spec.js b/test/interception.spec.js index 59d4f11cf2..2680d36563 100644 --- a/test/interception.spec.js +++ b/test/interception.spec.js @@ -31,7 +31,8 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p describe('Page.route', function() { it('should intercept', async({page, server}) => { - await page.route('/empty.html', request => { + let intercepted = false; + await page.route('**/empty.html', request => { expect(request.url()).toContain('empty.html'); expect(request.headers()['user-agent']).toBeTruthy(); expect(request.method()).toBe('GET'); @@ -41,9 +42,11 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p expect(request.frame() === page.mainFrame()).toBe(true); expect(request.frame().url()).toBe('about:blank'); request.continue(); + intercepted = true; }); const response = await page.goto(server.EMPTY_PAGE); expect(response.ok()).toBe(true); + expect(intercepted).toBe(true); }); it('should work when POST is redirected with 302', async({page, server}) => { server.setRedirect('/rredirect', '/empty.html'); @@ -516,7 +519,7 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p describe('ignoreHTTPSErrors', function() { it('should work with request interception', async({browser, httpsServer}) => { - const context = await browser.newContext({ ignoreHTTPSErrors: true, interceptNetwork: true }); + const context = await browser.newContext({ ignoreHTTPSErrors: true }); const page = await context.newPage(); await page.route('**/*', request => request.continue()); diff --git a/test/popup.spec.js b/test/popup.spec.js index ceb914295b..0a971a60e9 100644 --- a/test/popup.spec.js +++ b/test/popup.spec.js @@ -39,6 +39,24 @@ module.exports.describe = function({testRunner, expect, playwright, CHROMIUM, WE expect(userAgent).toBe('hey'); expect(request.headers['user-agent']).toBe('hey'); }); + it.fail(CHROMIUM || FFOX)('should respect routes from browser context', async function({browser, server}) { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + await page.setContent('link'); + let intercepted = false; + await context.route('**/empty.html', request => { + request.continue(); + intercepted = true; + }); + const [popup] = await Promise.all([ + context.waitForEvent('page').then(pageEvent => pageEvent.page()), + page.click('a'), + ]); + await popup.waitForLoadState(); + await context.close(); + expect(intercepted).toBe(true); + }); }); describe('window.open', function() { @@ -122,6 +140,19 @@ module.exports.describe = function({testRunner, expect, playwright, CHROMIUM, WE await context.close(); expect(size).toEqual({width: 400, height: 500}); }); + it.fail(FFOX)('should respect routes from browser context', async function({browser, server}) { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + let intercepted = false; + await context.route('**/empty.html', request => { + request.continue(); + intercepted = true; + }); + await page.evaluate(url => window.__popup = window.open(url), server.EMPTY_PAGE); + await context.close(); + expect(intercepted).toBe(true); + }); it('should apply addInitScript from browser context', async function({browser, server}) { const context = await browser.newContext(); await context.addInitScript(() => window.injected = 123);