api(popups): expose BrowserContext.route() (#1295)

This commit is contained in:
Dmitry Gozman 2020-03-09 21:02:54 -07:00 committed by GitHub
parent adee9a9bd3
commit ea6978a3d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 164 additions and 33 deletions

View file

@ -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)

View file

@ -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>;
}
@ -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;
@ -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) {

View file

@ -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;

View file

@ -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<void> {
await this._networkManager.setRequestInterception(enabled);
async updateRequestInterception(): Promise<void> {
await this._networkManager.setRequestInterception(this._page._needsRequestInterception());
}
async setFileChooserIntercepted(enabled: boolean) {

View file

@ -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;

View file

@ -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) {

View file

@ -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 ];

View file

@ -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;
@ -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<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) {
@ -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<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;

View file

@ -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;

View file

@ -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<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)
@ -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 });
}
@ -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);
}

View file

@ -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');

View file

@ -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());

View file

@ -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('<a target=_blank rel=noopener href="empty.html">link</a>');
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);