Skip to content

Commit

Permalink
api(popups): expose BrowserContext.route() (#1295)
Browse files Browse the repository at this point in the history
  • Loading branch information
dgozman authored Mar 10, 2020
1 parent adee9a9 commit ea6978a
Show file tree
Hide file tree
Showing 13 changed files with 164 additions and 33 deletions.
41 changes: 38 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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**
Expand Down Expand Up @@ -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
Expand All @@ -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();
```
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface BrowserContext {
setHTTPCredentials(httpCredentials: types.Credentials | null): Promise<void>;
addInitScript(script: Function | string | { path?: string, content?: string }, ...args: any[]): Promise<void>;
exposeFunction(name: string, playwrightFunction: Function): Promise<void>;
route(url: types.URLMatch, handler: network.RouteHandler): Promise<void>;
waitForEvent(event: string, optionsOrPredicate?: Function | (types.TimeoutOptions & { predicate?: Function })): Promise<any>;
close(): Promise<void>;
}
Expand All @@ -62,6 +63,7 @@ export abstract class BrowserContextBase extends platform.EventEmitter implement
readonly _timeoutSettings = new TimeoutSettings();
readonly _pageBindings = new Map<string, PageBinding>();
readonly _options: BrowserContextOptions;
readonly _routes: { url: types.URLMatch, handler: (request: network.Request) => any }[] = [];
_closed = false;
private readonly _closePromise: Promise<Error>;
private _closePromiseFulfill: ((error: Error) => void) | undefined;
Expand Down Expand Up @@ -100,6 +102,7 @@ export abstract class BrowserContextBase extends platform.EventEmitter implement
abstract setOffline(offline: boolean): Promise<void>;
abstract addInitScript(script: string | Function | { path?: string | undefined; content?: string | undefined; }, ...args: any[]): Promise<void>;
abstract exposeFunction(name: string, playwrightFunction: Function): Promise<void>;
abstract route(url: types.URLMatch, handler: network.RouteHandler): Promise<void>;
abstract close(): Promise<void>;

setDefaultNavigationTimeout(timeout: number) {
Expand Down
6 changes: 6 additions & 0 deletions src/chromium/crBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
this._routes.push({ url, handler });
for (const page of this._existingPages())
await (page._delegate as CRPage).updateRequestInterception();
}

async close() {
if (this._closed)
return;
Expand Down
5 changes: 3 additions & 2 deletions src/chromium/crPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -376,8 +377,8 @@ export class CRPage implements PageDelegate {
await this._client.send('Emulation.setEmulatedMedia', { media: mediaType || '', features });
}

async setRequestInterception(enabled: boolean): Promise<void> {
await this._networkManager.setRequestInterception(enabled);
async updateRequestInterception(): Promise<void> {
await this._networkManager.setRequestInterception(this._page._needsRequestInterception());
}

async setFileChooserIntercepted(enabled: boolean) {
Expand Down
6 changes: 6 additions & 0 deletions src/firefox/ffBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,12 @@ export class FFBrowserContext extends BrowserContextBase {
throw new Error('Not implemented');
}

async route(url: types.URLMatch, handler: network.RouteHandler): Promise<void> {
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;
Expand Down
4 changes: 2 additions & 2 deletions src/firefox/ffPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,8 @@ export class FFPage implements PageDelegate {
});
}

async setRequestInterception(enabled: boolean): Promise<void> {
await this._networkManager.setRequestInterception(enabled);
async updateRequestInterception(): Promise<void> {
await this._networkManager.setRequestInterception(this._page._needsRequestInterception());
}

async setFileChooserIntercepted(enabled: boolean) {
Expand Down
2 changes: 2 additions & 0 deletions src/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ];
Expand Down
29 changes: 16 additions & 13 deletions src/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export interface PageDelegate {
updateExtraHTTPHeaders(): Promise<void>;
setViewportSize(viewportSize: types.Size): Promise<void>;
setEmulateMedia(mediaType: types.MediaType | null, colorScheme: types.ColorScheme | null): Promise<void>;
setRequestInterception(enabled: boolean): Promise<void>;
updateRequestInterception(): Promise<void>;
setFileChooserIntercepted(enabled: boolean): Promise<void>;

canScreenshotOutsideViewport(): boolean;
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -127,7 +125,7 @@ export class Page extends platform.EventEmitter {
private _workers = new Map<string, Worker>();
readonly pdf: ((options?: types.PDFOptions) => Promise<platform.BufferType>) | 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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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<void> {
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;
Expand Down
6 changes: 6 additions & 0 deletions src/webkit/wkBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
this._routes.push({ url, handler });
for (const page of this._existingPages())
await (page._delegate as WKPage).updateRequestInterception();
}

async close() {
if (this._closed)
return;
Expand Down
19 changes: 8 additions & 11 deletions src/webkit/wkPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand All @@ -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}`); }));
Expand Down Expand Up @@ -476,10 +474,10 @@ export class WKPage implements PageDelegate {

async setViewportSize(viewportSize: types.Size): Promise<void> {
assert(this._page._state.viewportSize === viewportSize);
await this._updateViewport(false /* updateTouch */);
await this._updateViewport();
}

async _updateViewport(updateTouch: boolean): Promise<void> {
async _updateViewport(): Promise<void> {
let viewport = this._browserContext._options.viewport || { width: 0, height: 0 };
const viewportSize = this._page._state.viewportSize;
if (viewportSize)
Expand All @@ -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<void> {
async updateRequestInterception(): Promise<void> {
const enabled = this._page._needsRequestInterception();
await this._updateState('Network.setInterceptionEnabled', { enabled, interceptRequests: enabled });
}

Expand Down Expand Up @@ -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);
}
Expand Down
38 changes: 38 additions & 0 deletions test/browsercontext.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
7 changes: 5 additions & 2 deletions test/interception.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down Expand Up @@ -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());
Expand Down
Loading

0 comments on commit ea6978a

Please sign in to comment.