diff --git a/docs/api.md b/docs/api.md index 835ae62fa4..a3ec8be2df 100644 --- a/docs/api.md +++ b/docs/api.md @@ -173,6 +173,7 @@ * [page.setDefaultNavigationTimeout(timeout)](#pagesetdefaultnavigationtimeouttimeout) * [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout) * [page.setExtraHTTPHeaders(headers)](#pagesetextrahttpheadersheaders) + * [page.setRequestInterception(enabled)](#pagesetrequestinterceptionenabled) * [page.setViewport(viewport)](#pagesetviewportviewport) * [page.title()](#pagetitle) * [page.tripleclick(selector[, options])](#pagetripleclickselector-options) @@ -188,8 +189,11 @@ * [page.waitForResponse(urlOrPredicate[, options])](#pagewaitforresponseurlorpredicate-options) * [page.waitForSelector(selector[, options])](#pagewaitforselectorselector-options) - [class: Request](#class-request) + * [request.abort([errorCode])](#requestaborterrorcode) + * [request.continue([overrides])](#requestcontinueoverrides) * [request.failure()](#requestfailure) * [request.frame()](#requestframe) + * [request.fulfill(response)](#requestfulfillresponse) * [request.headers()](#requestheaders) * [request.isNavigationRequest()](#requestisnavigationrequest) * [request.method()](#requestmethod) @@ -230,12 +234,7 @@ * [chromiumCoverage.stopCSSCoverage()](#chromiumcoveragestopcsscoverage) * [chromiumCoverage.stopJSCoverage()](#chromiumcoveragestopjscoverage) - [class: ChromiumInterception](#class-chromiuminterception) - * [chromiumInterception.abort(request, [errorCode])](#chromiuminterceptionabortrequest-errorcode) * [chromiumInterception.authenticate(credentials)](#chromiuminterceptionauthenticatecredentials) - * [chromiumInterception.continue(request, [overrides])](#chromiuminterceptioncontinuerequest-overrides) - * [chromiumInterception.disable()](#chromiuminterceptiondisable) - * [chromiumInterception.enable()](#chromiuminterceptionenable) - * [chromiumInterception.fulfill(request, response)](#chromiuminterceptionfulfillrequest-response) * [chromiumInterception.setOfflineMode(enabled)](#chromiuminterceptionsetofflinemodeenabled) - [class: ChromiumOverrides](#class-chromiumoverrides) * [chromiumOverrides.setGeolocation(options)](#chromiumoverridessetgeolocationoptions) @@ -1799,7 +1798,7 @@ const [popup] = await Promise.all([ - <[Request]> Emitted when a page issues a request. The [request] object is read-only. -In order to intercept and mutate requests, see `page.interception.enable()`. +In order to intercept and mutate requests, see `page.setRequestInterception(true)`. #### event: 'requestfailed' - <[Request]> @@ -2403,6 +2402,31 @@ The extra HTTP headers will be sent with every request the page initiates. > **NOTE** page.setExtraHTTPHeaders does not guarantee the order of headers in the outgoing requests. +#### page.setRequestInterception(enabled) +- `enabled` <[boolean]> Whether to enable request interception. +- returns: <[Promise]> + +Activating request interception enables `request.abort`, `request.continue` and +`request.respond` methods. This provides the capability to modify network requests that are made by a page. + +Once request interception is enabled, every request will stall unless it's continued, responded or aborted. +An example of a naïve request interceptor that aborts all image requests: + +```js +const page = await browser.newPage(); +await page.setRequestInterception(true); +page.on('request', interceptedRequest => { + if (interceptedRequest.url().endsWith('.png') || interceptedRequest.url().endsWith('.jpg')) + interceptedRequest.abort(); + else + interceptedRequest.continue(); +}); +await page.goto('https://example.com'); +await browser.close(); +``` + +> **NOTE** Enabling request interception disables page caching. + #### page.setViewport(viewport) - `viewport` <[Object]> - `width` <[number]> page width in pixels. **required** @@ -2689,6 +2713,49 @@ If request fails at some point, then instead of `'requestfinished'` event (and p If request gets a 'redirect' response, the request is successfully finished with the 'requestfinished' event, and a new request is issued to a redirected url. +#### request.abort([errorCode]) +- `errorCode` <[string]> Optional error code. Defaults to `failed`, could be + one of the following: + - `aborted` - An operation was aborted (due to user action) + - `accessdenied` - Permission to access a resource, other than the network, was denied + - `addressunreachable` - The IP address is unreachable. This usually means + that there is no route to the specified host or network. + - `blockedbyclient` - The client chose to block the request. + - `blockedbyresponse` - The request failed because the response was delivered along with requirements which are not met ('X-Frame-Options' and 'Content-Security-Policy' ancestor checks, for instance). + - `connectionaborted` - A connection timed out as a result of not receiving an ACK for data sent. + - `connectionclosed` - A connection was closed (corresponding to a TCP FIN). + - `connectionfailed` - A connection attempt failed. + - `connectionrefused` - A connection attempt was refused. + - `connectionreset` - A connection was reset (corresponding to a TCP RST). + - `internetdisconnected` - The Internet connection has been lost. + - `namenotresolved` - The host name could not be resolved. + - `timedout` - An operation timed out. + - `failed` - A generic failure occurred. +- returns: <[Promise]> + +Aborts request. To use this, request interception should be enabled with `page.setRequestInterception`. +Exception is immediately thrown if the request interception is not enabled. + +#### request.continue([overrides]) +- `overrides` <[Object]> Optional request overwrites, which can be one of the following: + - `headers` <[Object]> If set changes the request HTTP headers. Header values will be converted to a string. +- returns: <[Promise]> + +Continues request with optional request overrides. To use this, request interception should be enabled with `page.setRequestInterception`. +Exception is immediately thrown if the request interception is not enabled. + +```js +await page.setRequestInterception(true); +page.on('request', request => { + // Override headers + const headers = Object.assign({}, request.headers(), { + foo: 'bar', // set "foo" header + origin: undefined, // remove "origin" header + }); + request.continue({headers}); +}); +``` + #### request.failure() - returns: Object describing request failure, if any - `errorText` <[string]> Human-readable error message, e.g. `'net::ERR_FAILED'`. @@ -2707,6 +2774,34 @@ page.on('requestfailed', request => { #### request.frame() - returns: A [Frame] that initiated this request, or `null` if navigating to error pages. +#### request.fulfill(response) +- `response` <[Object]> Response that will fulfill this request + - `status` <[number]> Response status code, defaults to `200`. + - `headers` <[Object]> Optional response headers. Header values will be converted to a string. + - `contentType` <[string]> If set, equals to setting `Content-Type` response header + - `body` <[string]|[Buffer]> Optional response body +- returns: <[Promise]> + +Fulfills request with given response. To use this, request interception should +be enabled with `page.setRequestInterception`. Exception is thrown if +request interception is not enabled. + +An example of fulfilling all requests with 404 responses: + +```js +await page.setRequestInterception(true); +page.on('request', request => { + request.respond({ + status: 404, + contentType: 'text/plain', + body: 'Not Found!' + }); +}); +``` + +> **NOTE** Mocking responses for dataURL requests is not supported. +> Calling `request.respond` for a dataURL request is a noop. + #### request.headers() - returns: <[Object]> An object with HTTP headers associated with the request. All header names are lower-case. @@ -3041,30 +3136,6 @@ reported. ### class: ChromiumInterception -#### chromiumInterception.abort(request, [errorCode]) -- `request` <[Request]> -- `errorCode` <[string]> Optional error code. Defaults to `failed`, could be - one of the following: - - `aborted` - An operation was aborted (due to user action) - - `accessdenied` - Permission to access a resource, other than the network, was denied - - `addressunreachable` - The IP address is unreachable. This usually means - that there is no route to the specified host or network. - - `blockedbyclient` - The client chose to block the request. - - `blockedbyresponse` - The request failed because the response was delivered along with requirements which are not met ('X-Frame-Options' and 'Content-Security-Policy' ancestor checks, for instance). - - `connectionaborted` - A connection timed out as a result of not receiving an ACK for data sent. - - `connectionclosed` - A connection was closed (corresponding to a TCP FIN). - - `connectionfailed` - A connection attempt failed. - - `connectionrefused` - A connection attempt was refused. - - `connectionreset` - A connection was reset (corresponding to a TCP RST). - - `internetdisconnected` - The Internet connection has been lost. - - `namenotresolved` - The host name could not be resolved. - - `timedout` - An operation timed out. - - `failed` - A generic failure occurred. -- returns: <[Promise]> - -Aborts request. To use this, request interception should be enabled with `page.interception.enable()`. -Exception is immediately thrown if the request interception is not enabled. - #### chromiumInterception.authenticate(credentials) - `credentials` - `username` <[string]> @@ -3075,91 +3146,6 @@ Provide credentials for [HTTP authentication](https://developer.mozilla.org/en-U To disable authentication, pass `null`. -#### chromiumInterception.continue(request, [overrides]) -- `request` <[Request]> -- `overrides` <[Object]> Optional request overwrites, which can be one of the following: - - `url` <[string]> If set, the request url will be changed. This is not a redirect. The request will be silently forwarded to the new url. For example, the address bar will show the original url. - - `method` <[string]> If set changes the request method (e.g. `GET` or `POST`) - - `postData` <[string]> If set changes the post data of request - - `headers` <[Object]> If set changes the request HTTP headers. Header values will be converted to a string. -- returns: <[Promise]> - -Continues request with optional request overrides. To use this, request interception should be enabled with `page.interception.enable()`. -Exception is immediately thrown if the request interception is not enabled. - -```js -await page.interception.enable(); -page.on('request', request => { - // Override headers - const headers = Object.assign({}, request.headers(), { - foo: 'bar', // set "foo" header - origin: undefined, // remove "origin" header - }); - page.interception.continue(request, {headers}); -}); -``` - -#### chromiumInterception.disable() -- returns: <[Promise]> - -Disables network request interception. - -#### chromiumInterception.enable() -- returns: <[Promise]> - -Once request interception is enabled, every request will stall unless it's continued, responded or aborted. -An example of a naïve request interceptor that aborts all image requests: - -```js -const playwright = require('playwright'); - -(async () => { - const browser = await playwright.launch(); - const context = await browser.newContext(); - const page = await context.newPage(); - await page.interception.enable(); - page.on('request', interceptedRequest => { - if (interceptedRequest.url().endsWith('.png') || interceptedRequest.url().endsWith('.jpg')) - page.interception.abort(interceptedRequest); - else - page.interception.continue(interceptedRequest); - }); - await page.goto('https://example.com'); - await browser.close(); -})(); -``` - -> **NOTE** Enabling request interception disables page caching. - -#### chromiumInterception.fulfill(request, response) -- `request` <[Request]> -- `response` <[Object]> Response that will fulfill this request - - `status` <[number]> Response status code, defaults to `200`. - - `headers` <[Object]> Optional response headers. Header values will be converted to a string. - - `contentType` <[string]> If set, equals to setting `Content-Type` response header - - `body` <[string]|[Buffer]> Optional response body -- returns: <[Promise]> - -Fulfills request with given response. To use this, request interception should -be enabled with `page.interception.enable()`. Exception is thrown if -request interception is not enabled. - -An example of fulfilling all requests with 404 responses: - -```js -await page.interception.enable(); -page.on('request', request => { - page.interception.respond(request, { - status: 404, - contentType: 'text/plain', - body: 'Not Found!' - }); -}); -``` - -> **NOTE** Mocking responses for dataURL requests is not supported. -> Calling `request.respond` for a dataURL request is a noop. - #### chromiumInterception.setOfflineMode(enabled) - `enabled` <[boolean]> When `true`, enables offline mode for the page. - returns: <[Promise]> diff --git a/package.json b/package.json index ad7ba04d3b..15af0df881 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "playwright": { "chromium_revision": "724623", "firefox_revision": "1008", - "webkit_revision": "1052" + "webkit_revision": "1053" }, "scripts": { "unit": "node test/test.js", diff --git a/src/chromium/crNetworkManager.ts b/src/chromium/crNetworkManager.ts index 19d970195c..a9f85507c3 100644 --- a/src/chromium/crNetworkManager.ts +++ b/src/chromium/crNetworkManager.ts @@ -245,52 +245,27 @@ export class CRNetworkManager { } } -const interceptableRequestSymbol = Symbol('interceptableRequest'); - -export function toInterceptableRequest(request: network.Request): InterceptableRequest { - return (request as any)[interceptableRequestSymbol]; -} - -class InterceptableRequest { +class InterceptableRequest implements network.RequestDelegate { readonly request: network.Request; _requestId: string; _interceptionId: string; _documentId: string; private _client: CRSession; - private _allowInterception: boolean; - private _interceptionHandled = false; constructor(client: CRSession, frame: frames.Frame | null, interceptionId: string, documentId: string | undefined, allowInterception: boolean, event: Protocol.Network.requestWillBeSentPayload, redirectChain: network.Request[]) { this._client = client; this._requestId = event.requestId; this._interceptionId = interceptionId; this._documentId = documentId; - this._allowInterception = allowInterception; - this.request = new network.Request(frame, redirectChain, documentId, + this.request = new network.Request(allowInterception ? this : null, frame, redirectChain, documentId, event.request.url, event.type.toLowerCase(), event.request.method, event.request.postData, headersObject(event.request.headers)); - (this.request as any)[interceptableRequestSymbol] = this; } - async continue(overrides: { url?: string; method?: string; postData?: string; headers?: {[key: string]: string}; } = {}) { - // Request interception is not supported for data: urls. - if (this.request.url().startsWith('data:')) - return; - assert(this._allowInterception, 'Request Interception is not enabled!'); - assert(!this._interceptionHandled, 'Request is already handled!'); - const { - url, - method, - postData, - headers - } = overrides; - this._interceptionHandled = true; + async continue(overrides: { headers?: {[key: string]: string}; } = {}) { await this._client.send('Fetch.continueRequest', { requestId: this._interceptionId, - url, - method, - postData, - headers: headers ? headersArray(headers) : undefined, + headers: overrides.headers ? headersArray(overrides.headers) : undefined, }).catch(error => { // In certain cases, protocol will return error if the request was already canceled // or the page was closed. We should tolerate these errors. @@ -299,13 +274,6 @@ class InterceptableRequest { } async fulfill(response: { status: number; headers: {[key: string]: string}; contentType: string; body: (string | Buffer); }) { - // Mocking responses for dataURL requests is not currently supported. - if (this.request.url().startsWith('data:')) - return; - assert(this._allowInterception, 'Request Interception is not enabled!'); - assert(!this._interceptionHandled, 'Request is already handled!'); - this._interceptionHandled = true; - const responseBody = response.body && helper.isString(response.body) ? Buffer.from(/** @type {string} */(response.body)) : /** @type {?Buffer} */(response.body || null); const responseHeaders: { [s: string]: string; } = {}; @@ -321,7 +289,7 @@ class InterceptableRequest { await this._client.send('Fetch.fulfillRequest', { requestId: this._interceptionId, responseCode: response.status || 200, - responsePhrase: STATUS_TEXTS[String(response.status || 200)], + responsePhrase: network.STATUS_TEXTS[String(response.status || 200)], responseHeaders: headersArray(responseHeaders), body: responseBody ? responseBody.toString('base64') : undefined, }).catch(error => { @@ -332,14 +300,8 @@ class InterceptableRequest { } async abort(errorCode: string = 'failed') { - // Request interception is not supported for data: urls. - if (this.request.url().startsWith('data:')) - return; const errorReason = errorReasons[errorCode]; assert(errorReason, 'Unknown error code: ' + errorCode); - assert(this._allowInterception, 'Request Interception is not enabled!'); - assert(!this._interceptionHandled, 'Request is already handled!'); - this._interceptionHandled = true; await this._client.send('Fetch.failRequest', { requestId: this._interceptionId, errorReason @@ -384,69 +346,3 @@ function headersObject(headers: Protocol.Network.Headers): network.Headers { return result; } -// List taken from https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml with extra 306 and 418 codes. -const STATUS_TEXTS: { [status: string]: string } = { - '100': 'Continue', - '101': 'Switching Protocols', - '102': 'Processing', - '103': 'Early Hints', - '200': 'OK', - '201': 'Created', - '202': 'Accepted', - '203': 'Non-Authoritative Information', - '204': 'No Content', - '205': 'Reset Content', - '206': 'Partial Content', - '207': 'Multi-Status', - '208': 'Already Reported', - '226': 'IM Used', - '300': 'Multiple Choices', - '301': 'Moved Permanently', - '302': 'Found', - '303': 'See Other', - '304': 'Not Modified', - '305': 'Use Proxy', - '306': 'Switch Proxy', - '307': 'Temporary Redirect', - '308': 'Permanent Redirect', - '400': 'Bad Request', - '401': 'Unauthorized', - '402': 'Payment Required', - '403': 'Forbidden', - '404': 'Not Found', - '405': 'Method Not Allowed', - '406': 'Not Acceptable', - '407': 'Proxy Authentication Required', - '408': 'Request Timeout', - '409': 'Conflict', - '410': 'Gone', - '411': 'Length Required', - '412': 'Precondition Failed', - '413': 'Payload Too Large', - '414': 'URI Too Long', - '415': 'Unsupported Media Type', - '416': 'Range Not Satisfiable', - '417': 'Expectation Failed', - '418': 'I\'m a teapot', - '421': 'Misdirected Request', - '422': 'Unprocessable Entity', - '423': 'Locked', - '424': 'Failed Dependency', - '425': 'Too Early', - '426': 'Upgrade Required', - '428': 'Precondition Required', - '429': 'Too Many Requests', - '431': 'Request Header Fields Too Large', - '451': 'Unavailable For Legal Reasons', - '500': 'Internal Server Error', - '501': 'Not Implemented', - '502': 'Bad Gateway', - '503': 'Service Unavailable', - '504': 'Gateway Timeout', - '505': 'HTTP Version Not Supported', - '506': 'Variant Also Negotiates', - '507': 'Insufficient Storage', - '508': 'Loop Detected', - '510': 'Not Extended', - '511': 'Network Authentication Required', -}; diff --git a/src/chromium/crPage.ts b/src/chromium/crPage.ts index fb33c23a09..27112ceac3 100644 --- a/src/chromium/crPage.ts +++ b/src/chromium/crPage.ts @@ -298,6 +298,10 @@ export class CRPage implements PageDelegate { return this._networkManager.setCacheEnabled(enabled); } + async setRequestInterception(enabled: boolean): Promise { + await this._networkManager.setRequestInterception(enabled); + } + async reload(): Promise { await this._client.send('Page.reload'); } diff --git a/src/chromium/features/crInterception.ts b/src/chromium/features/crInterception.ts index b54f7a3c7c..2731bf1d69 100644 --- a/src/chromium/features/crInterception.ts +++ b/src/chromium/features/crInterception.ts @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { CRNetworkManager, toInterceptableRequest } from '../crNetworkManager'; -import * as network from '../../network'; +import { CRNetworkManager } from '../crNetworkManager'; export class CRInterception { private _networkManager: CRNetworkManager; @@ -11,26 +10,6 @@ export class CRInterception { this._networkManager = networkManager; } - async enable() { - await this._networkManager.setRequestInterception(true); - } - - async disable() { - await this._networkManager.setRequestInterception(false); - } - - async continue(request: network.Request, overrides: { url?: string; method?: string; postData?: string; headers?: {[key: string]: string}; } = {}) { - return toInterceptableRequest(request).continue(overrides); - } - - async fulfill(request: network.Request, response: { status: number; headers: {[key: string]: string}; contentType: string; body: (string | Buffer); }) { - return toInterceptableRequest(request).fulfill(response); - } - - async abort(request: network.Request, errorCode: string = 'failed') { - return toInterceptableRequest(request).abort(errorCode); - } - setOfflineMode(enabled: boolean) { return this._networkManager.setOfflineMode(enabled); } diff --git a/src/chromium/features/crOverrides.ts b/src/chromium/features/crOverrides.ts index 4999fe0ab1..7d8e2cca8f 100644 --- a/src/chromium/features/crOverrides.ts +++ b/src/chromium/features/crOverrides.ts @@ -17,7 +17,6 @@ import { BrowserContext } from '../../browserContext'; import { CRPage } from '../crPage'; -import { Page } from '../../page'; export class CROverrides { private _context: BrowserContext; diff --git a/src/firefox/features/ffInterception.ts b/src/firefox/features/ffInterception.ts deleted file mode 100644 index 7c54b5b4db..0000000000 --- a/src/firefox/features/ffInterception.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { FFNetworkManager, toInterceptableRequest } from '../ffNetworkManager'; -import * as network from '../../network'; - -export class FFInterception { - private _networkManager: FFNetworkManager; - - constructor(networkManager: FFNetworkManager) { - this._networkManager = networkManager; - } - - async enable() { - await this._networkManager.setRequestInterception(true); - } - - async disable() { - await this._networkManager.setRequestInterception(false); - } - - async continue(request: network.Request, overrides: { url?: string; method?: string; postData?: string; headers?: {[key: string]: string}; } = {}) { - return toInterceptableRequest(request).continue(overrides); - } - - async fulfill(request: network.Request, response: { status: number; headers: {[key: string]: string}; contentType: string; body: (string | Buffer); }) { - throw new Error('Not implemented'); - } - - async abort(request: network.Request, errorCode: string = 'failed') { - return toInterceptableRequest(request).abort(); - } -} diff --git a/src/firefox/ffApi.ts b/src/firefox/ffApi.ts index 10c360393a..6040ecd05d 100644 --- a/src/firefox/ffApi.ts +++ b/src/firefox/ffApi.ts @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export { FFInterception as FirefoxInterception } from './features/ffInterception'; export { FFBrowser as FirefoxBrowser } from './ffBrowser'; export { FFPlaywright as FirefoxPlaywright } from './ffPlaywright'; diff --git a/src/firefox/ffNetworkManager.ts b/src/firefox/ffNetworkManager.ts index 843f4aa88c..4529f2df19 100644 --- a/src/firefox/ffNetworkManager.ts +++ b/src/firefox/ffNetworkManager.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { assert, debugError, helper, RegisteredListener } from '../helper'; +import { debugError, helper, RegisteredListener } from '../helper'; import { FFSession } from './ffConnection'; import { Page } from '../page'; import * as network from '../network'; @@ -139,41 +139,24 @@ const causeToResourceType = { TYPE_WEB_MANIFEST: 'manifest', }; -const interceptableRequestSymbol = Symbol('interceptableRequest'); - -export function toInterceptableRequest(request: network.Request): InterceptableRequest { - return (request as any)[interceptableRequestSymbol]; -} - -class InterceptableRequest { +class InterceptableRequest implements network.RequestDelegate { readonly request: network.Request; _id: string; private _session: FFSession; - private _suspended: boolean; - private _interceptionHandled: boolean; constructor(session: FFSession, frame: frames.Frame, redirectChain: network.Request[], payload: any) { this._id = payload.requestId; this._session = session; - this._suspended = payload.suspended; - this._interceptionHandled = false; const headers: network.Headers = {}; for (const {name, value} of payload.headers) headers[name.toLowerCase()] = value; - this.request = new network.Request(frame, redirectChain, payload.navigationId, + this.request = new network.Request(payload.suspended ? this : null, frame, redirectChain, payload.navigationId, payload.url, causeToResourceType[payload.cause] || 'other', payload.method, payload.postData, headers); - (this.request as any)[interceptableRequestSymbol] = this; } - async continue(overrides: {url?: string, method?: string, postData?: string, headers?: {[key: string]: string}} = {}) { - assert(!overrides.url, 'Playwright-Firefox does not support overriding URL'); - assert(!overrides.method, 'Playwright-Firefox does not support overriding method'); - assert(!overrides.postData, 'Playwright-Firefox does not support overriding postData'); - assert(this._suspended, 'Request Interception is not enabled!'); - assert(!this._interceptionHandled, 'Request is already handled!'); - this._interceptionHandled = true; + async continue(overrides: { headers?: { [key: string]: string } } = {}) { const { headers, } = overrides; @@ -185,10 +168,11 @@ class InterceptableRequest { }); } + async fulfill(response: { status: number; headers: {[key: string]: string}; contentType: string; body: (string | Buffer); }) { + throw new Error('Fulfill is not supported in Firefox'); + } + async abort() { - assert(this._suspended, 'Request Interception is not enabled!'); - assert(!this._interceptionHandled, 'Request is already handled!'); - this._interceptionHandled = true; await this._session.send('Network.abortSuspendedRequest', { requestId: this._id, }).catch(error => { diff --git a/src/firefox/ffPage.ts b/src/firefox/ffPage.ts index a605b842eb..9db8f0252d 100644 --- a/src/firefox/ffPage.ts +++ b/src/firefox/ffPage.ts @@ -28,7 +28,6 @@ import { Protocol } from './protocol'; import * as input from '../input'; import { RawMouseImpl, RawKeyboardImpl } from './ffInput'; import { BrowserContext } from '../browserContext'; -import { FFInterception } from './features/ffInterception'; import { FFAccessibility } from './features/ffAccessibility'; import * as network from '../network'; import * as types from '../types'; @@ -38,7 +37,7 @@ export class FFPage implements PageDelegate { readonly rawKeyboard: RawKeyboardImpl; readonly _session: FFSession; readonly _page: Page; - private readonly _networkManager: FFNetworkManager; + readonly _networkManager: FFNetworkManager; private readonly _contextIdToContext: Map; private _eventListeners: RegisteredListener[]; @@ -65,7 +64,6 @@ export class FFPage implements PageDelegate { helper.addEventListener(this._session, 'Page.bindingCalled', this._onBindingCalled.bind(this)), helper.addEventListener(this._session, 'Page.fileChooserOpened', this._onFileChooserOpened.bind(this)), ]; - (this._page as any).interception = new FFInterception(this._networkManager); (this._page as any).accessibility = new FFAccessibility(session); } @@ -214,6 +212,10 @@ export class FFPage implements PageDelegate { await this._session.send('Page.setCacheDisabled', {cacheDisabled: !enabled}); } + async setRequestInterception(enabled: boolean): Promise { + await this._networkManager.setRequestInterception(enabled); + } + async reload(): Promise { await this._session.send('Page.reload', { frameId: this._page.mainFrame()._id }); } diff --git a/src/network.ts b/src/network.ts index 89bac112f7..af0b84398e 100644 --- a/src/network.ts +++ b/src/network.ts @@ -78,6 +78,7 @@ function stripFragmentFromUrl(url: string): string { export type Headers = { [key: string]: string }; export class Request { + private _delegate: RequestDelegate | null; private _response: Response | null = null; _redirectChain: Request[]; _finalRequest: Request; @@ -93,9 +94,11 @@ export class Request { private _waitForResponsePromiseCallback: (value?: Response) => void; private _waitForFinishedPromise: Promise; private _waitForFinishedPromiseCallback: (value?: Response | undefined) => void; + private _interceptionHandled = false; - constructor(frame: frames.Frame | null, redirectChain: Request[], documentId: string, + constructor(delegate: RequestDelegate | null, frame: frames.Frame | null, redirectChain: Request[], documentId: string, url: string, resourceType: string, method: string, postData: string, headers: Headers) { + this._delegate = delegate; this._frame = frame; this._redirectChain = redirectChain; this._finalRequest = this; @@ -169,12 +172,40 @@ export class Request { } failure(): { errorText: string; } | null { - if (!this._failureText) + if (this._failureText === null) return null; return { errorText: this._failureText }; } + + async abort(errorCode: string = 'failed') { + // Request interception is not supported for data: urls. + if (this.url().startsWith('data:')) + return; + assert(this._delegate, 'Request Interception is not enabled!'); + assert(!this._interceptionHandled, 'Request is already handled!'); + this._interceptionHandled = true; + await this._delegate.abort(errorCode); + } + + async fulfill(response: { status: number; headers: {[key: string]: string}; contentType: string; body: (string | Buffer); }) { // Mocking responses for dataURL requests is not currently supported. + if (this.url().startsWith('data:')) + return; + assert(this._delegate, 'Request Interception is not enabled!'); + assert(!this._interceptionHandled, 'Request is already handled!'); + this._interceptionHandled = true; + await this._delegate.fulfill(response); + } + + async continue(overrides: { headers?: { [key: string]: string } } = {}) { + // Request interception is not supported for data: urls. + if (this.url().startsWith('data:')) + return; + assert(this._delegate, 'Request Interception is not enabled!'); + assert(!this._interceptionHandled, 'Request is already handled!'); + await this._delegate.continue(overrides); + } } export type RemoteAddress = { @@ -267,3 +298,76 @@ export class Response { return this._request.frame(); } } + +export interface RequestDelegate { + abort(errorCode: string): Promise; + fulfill(response: { status: number; headers: {[key: string]: string}; contentType: string; body: (string | Buffer); }): Promise; + continue(overrides: { url?: string; method?: string; postData?: string; headers?: { [key: string]: string; }; }): Promise; +} + +// List taken from https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml with extra 306 and 418 codes. +export const STATUS_TEXTS: { [status: string]: string } = { + '100': 'Continue', + '101': 'Switching Protocols', + '102': 'Processing', + '103': 'Early Hints', + '200': 'OK', + '201': 'Created', + '202': 'Accepted', + '203': 'Non-Authoritative Information', + '204': 'No Content', + '205': 'Reset Content', + '206': 'Partial Content', + '207': 'Multi-Status', + '208': 'Already Reported', + '226': 'IM Used', + '300': 'Multiple Choices', + '301': 'Moved Permanently', + '302': 'Found', + '303': 'See Other', + '304': 'Not Modified', + '305': 'Use Proxy', + '306': 'Switch Proxy', + '307': 'Temporary Redirect', + '308': 'Permanent Redirect', + '400': 'Bad Request', + '401': 'Unauthorized', + '402': 'Payment Required', + '403': 'Forbidden', + '404': 'Not Found', + '405': 'Method Not Allowed', + '406': 'Not Acceptable', + '407': 'Proxy Authentication Required', + '408': 'Request Timeout', + '409': 'Conflict', + '410': 'Gone', + '411': 'Length Required', + '412': 'Precondition Failed', + '413': 'Payload Too Large', + '414': 'URI Too Long', + '415': 'Unsupported Media Type', + '416': 'Range Not Satisfiable', + '417': 'Expectation Failed', + '418': 'I\'m a teapot', + '421': 'Misdirected Request', + '422': 'Unprocessable Entity', + '423': 'Locked', + '424': 'Failed Dependency', + '425': 'Too Early', + '426': 'Upgrade Required', + '428': 'Precondition Required', + '429': 'Too Many Requests', + '431': 'Request Header Fields Too Large', + '451': 'Unavailable For Legal Reasons', + '500': 'Internal Server Error', + '501': 'Not Implemented', + '502': 'Bad Gateway', + '503': 'Service Unavailable', + '504': 'Gateway Timeout', + '505': 'HTTP Version Not Supported', + '506': 'Variant Also Negotiates', + '507': 'Insufficient Storage', + '508': 'Loop Detected', + '510': 'Not Extended', + '511': 'Network Authentication Required', +}; diff --git a/src/page.ts b/src/page.ts index 48dce67556..8f9ab144eb 100644 --- a/src/page.ts +++ b/src/page.ts @@ -48,6 +48,7 @@ export interface PageDelegate { setViewport(viewport: types.Viewport): Promise; setEmulateMedia(mediaType: input.MediaType | null, colorScheme: input.ColorScheme | null): Promise; setCacheEnabled(enabled: boolean): Promise; + setRequestInterception(enabled: boolean): Promise; getBoundingBoxForScreenshot(handle: dom.ElementHandle): Promise; canScreenshotOutsideViewport(): boolean; @@ -71,6 +72,7 @@ type PageState = { colorScheme: input.ColorScheme | null; extraHTTPHeaders: network.Headers | null; cacheEnabled: boolean | null; + interceptNetwork: boolean | null; }; export type FileChooser = { @@ -107,6 +109,7 @@ export class Page extends EventEmitter { colorScheme: browserContext._options.colorScheme || null, extraHTTPHeaders: null, cacheEnabled: null, + interceptNetwork: null }; this.keyboard = new input.Keyboard(delegate.rawKeyboard); this.mouse = new input.Mouse(delegate.rawMouse, this.keyboard); @@ -391,6 +394,13 @@ export class Page extends EventEmitter { await this._delegate.setCacheEnabled(enabled); } + async setRequestInterception(enabled: boolean) { + if (this._state.interceptNetwork === enabled) + return; + this._state.interceptNetwork = enabled; + await this._delegate.setRequestInterception(enabled); + } + async screenshot(options?: types.ScreenshotOptions): Promise { return this._screenshotter.screenshotPage(options); } diff --git a/src/webkit/wkNetworkManager.ts b/src/webkit/wkNetworkManager.ts index 04bcefa9b4..c28c58920f 100644 --- a/src/webkit/wkNetworkManager.ts +++ b/src/webkit/wkNetworkManager.ts @@ -17,16 +17,15 @@ import { WKTargetSession } from './wkConnection'; import { Page } from '../page'; -import { helper, RegisteredListener } from '../helper'; +import { helper, RegisteredListener, assert } from '../helper'; import { Protocol } from './protocol'; import * as network from '../network'; import * as frames from '../frames'; export class WKNetworkManager { private _session: WKTargetSession; - private _page: Page; + _page: Page; private _requestIdToRequest = new Map(); - private _attemptedAuthentications = new Set(); private _userCacheDisabled = false; private _sessionListeners: RegisteredListener[] = []; @@ -39,14 +38,19 @@ export class WKNetworkManager { this._session = session; this._sessionListeners = [ helper.addEventListener(this._session, 'Network.requestWillBeSent', this._onRequestWillBeSent.bind(this)), + helper.addEventListener(this._session, 'Network.requestIntercepted', this._onRequestIntercepted.bind(this)), helper.addEventListener(this._session, 'Network.responseReceived', this._onResponseReceived.bind(this)), helper.addEventListener(this._session, 'Network.loadingFinished', this._onLoadingFinished.bind(this)), helper.addEventListener(this._session, 'Network.loadingFailed', this._onLoadingFailed.bind(this)), ]; } - async initializeSession(session: WKTargetSession) { - await session.send('Network.enable'); + async initializeSession(session: WKTargetSession, enableInterception: boolean) { + const promises = []; + promises.push(session.send('Network.enable')); + if (enableInterception) + promises.push(session.send('Network.setInterceptionEnabled', { enabled: true })); + await Promise.all(promises); } dispose() { @@ -58,6 +62,10 @@ export class WKNetworkManager { await this._updateProtocolCacheDisabled(); } + async setRequestInterception(enabled: boolean): Promise { + await this._session.send('Network.setInterceptionEnabled', { enabled }); + } + async _updateProtocolCacheDisabled() { await this._session.send('Network.setResourceCachingDisabled', { disabled: this._userCacheDisabled @@ -78,11 +86,15 @@ export class WKNetworkManager { // TODO(einbinder) this will fail if we are an XHR document request const isNavigationRequest = event.type === 'Document'; const documentId = isNavigationRequest ? this._session._sessionId + '::' + event.loaderId : undefined; - const request = new InterceptableRequest(frame, undefined, event, redirectChain, documentId); + const request = new InterceptableRequest(this._session, this._page._state.interceptNetwork, frame, event, redirectChain, documentId); this._requestIdToRequest.set(event.requestId, request); this._page._frameManager.requestStarted(request.request); } + _onRequestIntercepted(event: Protocol.Network.requestInterceptedPayload) { + this._requestIdToRequest.get(event.requestId)._interceptedCallback(); + } + _createResponse(request: InterceptableRequest, responsePayload: Protocol.Network.Response): network.Response { const remoteAddress: network.RemoteAddress = { ip: '', port: 0 }; const getResponseBody = async () => { @@ -97,7 +109,6 @@ export class WKNetworkManager { request.request._redirectChain.push(request.request); response._requestFinished(new Error('Response body is unavailable for redirect responses')); this._requestIdToRequest.delete(request._requestId); - this._attemptedAuthentications.delete(request._interceptionId); this._page._frameManager.requestReceivedResponse(response); this._page._frameManager.requestFinished(request.request); } @@ -123,7 +134,6 @@ export class WKNetworkManager { if (request.request.response()) request.request.response()._requestFinished(); this._requestIdToRequest.delete(request._requestId); - this._attemptedAuthentications.delete(request._interceptionId); this._page._frameManager.requestFinished(request.request); } @@ -137,31 +147,93 @@ export class WKNetworkManager { if (response) response._requestFinished(); this._requestIdToRequest.delete(request._requestId); - this._attemptedAuthentications.delete(request._interceptionId); request.request._setFailureText(event.errorText); this._page._frameManager.requestFailed(request.request, event.errorText.includes('cancelled')); } + + authenticate(credentials: { username: string; password: string; }) { + throw new Error('Not implemented'); + } + + setOfflineMode(enabled: boolean) { + throw new Error('Not implemented'); + } } -const interceptableRequestSymbol = Symbol('interceptableRequest'); +const errorReasons: { [reason: string]: string } = { + 'aborted': 'Cancellation', + 'accessdenied': 'AccessControl', + 'addressunreachable': 'General', + 'blockedbyclient': 'Cancellation', + 'blockedbyresponse': 'General', + 'connectionaborted': 'General', + 'connectionclosed': 'General', + 'connectionfailed': 'General', + 'connectionrefused': 'General', + 'connectionreset': 'General', + 'internetdisconnected': 'General', + 'namenotresolved': 'General', + 'timedout': 'Timeout', + 'failed': 'General', +}; -export function toInterceptableRequest(request: network.Request): InterceptableRequest { - return (request as any)[interceptableRequestSymbol]; -} - -class InterceptableRequest { +class InterceptableRequest implements network.RequestDelegate { + private _session: WKTargetSession; readonly request: network.Request; _requestId: string; - _interceptionId: string; _documentId: string | undefined; + _interceptedCallback: () => void; + private _interceptedPromise: Promise; - constructor(frame: frames.Frame | null, interceptionId: string, event: Protocol.Network.requestWillBeSentPayload, redirectChain: network.Request[], documentId: string | undefined) { + constructor(session: WKTargetSession, allowInterception: boolean, frame: frames.Frame | null, event: Protocol.Network.requestWillBeSentPayload, redirectChain: network.Request[], documentId: string | undefined) { + this._session = session; this._requestId = event.requestId; - this._interceptionId = interceptionId; this._documentId = documentId; - this.request = new network.Request(frame, redirectChain, documentId, event.request.url, + this.request = new network.Request(allowInterception ? this : null, frame, redirectChain, documentId, event.request.url, event.type ? event.type.toLowerCase() : 'Unknown', event.request.method, event.request.postData, headersObject(event.request.headers)); - (this.request as any)[interceptableRequestSymbol] = this; + this._interceptedPromise = new Promise(f => this._interceptedCallback = f); + } + + async abort(errorCode: string) { + const reason = errorReasons[errorCode]; + assert(reason, 'Unknown error code: ' + errorCode); + await this._interceptedPromise; + await this._session.send('Network.interceptAsError', { requestId: this._requestId, reason }); + } + + async fulfill(response: { status: number; headers: {[key: string]: string}; contentType: string; body: (string | Buffer); }) { + await this._interceptedPromise; + + const base64Encoded = !!response.body && !helper.isString(response.body); + const responseBody = response.body ? (base64Encoded ? response.body.toString('base64') : response.body as string) : undefined; + + const responseHeaders: { [s: string]: string; } = {}; + if (response.headers) { + for (const header of Object.keys(response.headers)) + responseHeaders[header.toLowerCase()] = String(response.headers[header]); + } + if (response.contentType) + responseHeaders['content-type'] = response.contentType; + if (responseBody && !('content-length' in responseHeaders)) + responseHeaders['content-length'] = String(Buffer.byteLength(responseBody)); + + await this._session.send('Network.interceptWithResponse', { + requestId: this._requestId, + status: response.status || 200, + statusText: network.STATUS_TEXTS[String(response.status || 200)], + mimeType: response.contentType || (base64Encoded ? 'application/octet-stream' : 'text/plain'), + headers: responseHeaders, + base64Encoded, + content: responseBody + }); + } + + async continue(overrides: { headers?: { [key: string]: string; }; }) { + await this._interceptedPromise; + await this._session.send('Network.interceptContinue', { + requestId: this._requestId, + ...overrides + }); } } diff --git a/src/webkit/wkPage.ts b/src/webkit/wkPage.ts index 3d2e80c753..952b3e94b6 100644 --- a/src/webkit/wkPage.ts +++ b/src/webkit/wkPage.ts @@ -85,7 +85,7 @@ export class WKPage implements PageDelegate { session.send('Runtime.enable').then(() => this._ensureIsolatedWorld(UTILITY_WORLD_NAME)), session.send('Console.enable'), session.send('Page.setInterceptFileChooserDialog', { enabled: true }), - this._networkManager.initializeSession(session), + this._networkManager.initializeSession(session, this._page._state.interceptNetwork), ]; if (!session.isProvisional()) { // FIXME: move dialog agent to web process. @@ -305,6 +305,10 @@ export class WKPage implements PageDelegate { return this._networkManager.setCacheEnabled(enabled); } + setRequestInterception(enabled: boolean): Promise { + return this._networkManager.setRequestInterception(enabled); + } + async reload(): Promise { await this._session.send('Page.reload'); } diff --git a/test/chromium/chromium.spec.js b/test/chromium/chromium.spec.js index 2203995ffd..445a971a2c 100644 --- a/test/chromium/chromium.spec.js +++ b/test/chromium/chromium.spec.js @@ -238,8 +238,8 @@ module.exports.describe = function({testRunner, expect, playwright, FFOX, CHROME res.end('console.log(1);'); }); - await page.interception.enable(); - page.on('request', request => page.interception.continue(request)); + await page.setRequestInterception(true); + page.on('request', request => request.continue()); await page.goto(server.PREFIX + '/intervention'); // Check for feature URL substring rather than https://www.chromestatus.com to // make it work with Edgium. diff --git a/test/chromium/oopif.spec.js b/test/chromium/oopif.spec.js index 58a0ddc1da..23c384773a 100644 --- a/test/chromium/oopif.spec.js +++ b/test/chromium/oopif.spec.js @@ -44,8 +44,8 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p expect(page.frames().length).toBe(2); }); it('should load oopif iframes with subresources and request interception', async function({browser, page, server, context}) { - await page.interception.enable(); - page.on('request', request => page.interception.continue(request)); + await page.setRequestInterception(true); + page.on('request', request => request.continue()); await page.goto(server.PREFIX + '/dynamic-oopif.html'); expect(oopifs(browser).length).toBe(1); }); diff --git a/test/golden-webkit/mock-binary-response.png b/test/golden-webkit/mock-binary-response.png new file mode 100644 index 0000000000..8595e0598e Binary files /dev/null and b/test/golden-webkit/mock-binary-response.png differ diff --git a/test/features/interception.spec.js b/test/interception.spec.js similarity index 72% rename from test/features/interception.spec.js rename to test/interception.spec.js index 9b6650817f..10109ae4e3 100644 --- a/test/features/interception.spec.js +++ b/test/interception.spec.js @@ -17,19 +17,19 @@ const fs = require('fs'); const path = require('path'); -const utils = require('../utils'); +const utils = require('./utils'); module.exports.describe = function({testRunner, expect, defaultBrowserOptions, playwright, FFOX, CHROME, WEBKIT}) { const {describe, xdescribe, fdescribe} = testRunner; const {it, fit, xit, dit} = testRunner; const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; - describe('Interception.enable', function() { + describe('Page.setRequestInterception', function() { it('should intercept', async({page, server}) => { - await page.interception.enable(); + await page.setRequestInterception(true); page.on('request', request => { if (utils.isFavicon(request)) { - page.interception.continue(request); + request.continue(); return; } expect(request.url()).toContain('empty.html'); @@ -40,17 +40,16 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p expect(request.resourceType()).toBe('document'); expect(request.frame() === page.mainFrame()).toBe(true); expect(request.frame().url()).toBe('about:blank'); - page.interception.continue(request); + request.continue(); }); const response = await page.goto(server.EMPTY_PAGE); expect(response.ok()).toBe(true); - expect(response.remoteAddress().port).toBe(server.PORT); }); it('should work when POST is redirected with 302', async({page, server}) => { server.setRedirect('/rredirect', '/empty.html'); await page.goto(server.EMPTY_PAGE); - await page.interception.enable(); - page.on('request', request => page.interception.continue(request)); + await page.setRequestInterception(true); + page.on('request', request => request.continue()); await page.setContent(`
@@ -64,24 +63,24 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p // @see https://github.com/GoogleChrome/puppeteer/issues/3973 it('should work when header manipulation headers with redirect', async({page, server}) => { server.setRedirect('/rrredirect', '/empty.html'); - await page.interception.enable(); + await page.setRequestInterception(true); page.on('request', request => { const headers = Object.assign({}, request.headers(), { foo: 'bar' }); - page.interception.continue(request, { headers }); + request.continue({ headers }); }); await page.goto(server.PREFIX + '/rrredirect'); }); // @see https://github.com/GoogleChrome/puppeteer/issues/4743 it('should be able to remove headers', async({page, server}) => { - await page.interception.enable(); + await page.setRequestInterception(true); page.on('request', request => { const headers = Object.assign({}, request.headers(), { foo: 'bar', origin: undefined, // remove "origin" header }); - page.interception.continue(request, { headers }); + request.continue({ headers }); }); const [serverRequest] = await Promise.all([ @@ -92,12 +91,12 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p expect(serverRequest.headers.origin).toBe(undefined); }); it('should contain referer header', async({page, server}) => { - await page.interception.enable(); + await page.setRequestInterception(true); const requests = []; page.on('request', request => { if (!utils.isFavicon(request)) requests.push(request); - page.interception.continue(request); + request.continue(); }); await page.goto(server.PREFIX + '/one-style.html'); expect(requests[1].url()).toContain('/one-style.css'); @@ -109,26 +108,26 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p await context.setCookies([{ url: server.EMPTY_PAGE, name: 'foo', value: 'bar'}]); // Setup request interception. - await page.interception.enable(); - page.on('request', request => page.interception.continue(request)); + await page.setRequestInterception(true); + page.on('request', request => request.continue()); const response = await page.reload(); expect(response.status()).toBe(200); }); it('should stop intercepting', async({page, server}) => { - await page.interception.enable(); - page.once('request', request => page.interception.continue(request)); + await page.setRequestInterception(true); + page.once('request', request => request.continue()); await page.goto(server.EMPTY_PAGE); - await page.interception.disable(); + await page.setRequestInterception(false); await page.goto(server.EMPTY_PAGE); }); it('should show custom HTTP headers', async({page, server}) => { await page.setExtraHTTPHeaders({ foo: 'bar' }); - await page.interception.enable(); + await page.setRequestInterception(true); page.on('request', request => { expect(request.headers()['foo']).toBe('bar'); - page.interception.continue(request); + request.continue(); }); const response = await page.goto(server.EMPTY_PAGE); expect(response.ok()).toBe(true); @@ -137,8 +136,8 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p it('should work with redirect inside sync XHR', async({page, server}) => { await page.goto(server.EMPTY_PAGE); server.setRedirect('/logo.png', '/pptr.png'); - await page.interception.enable(); - page.on('request', request => page.interception.continue(request)); + await page.setRequestInterception(true); + page.on('request', request => request.continue()); const status = await page.evaluate(async() => { const request = new XMLHttpRequest(); request.open('GET', '/logo.png', false); // `false` makes the request synchronous @@ -149,21 +148,21 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p }); it('should work with custom referer headers', async({page, server}) => { await page.setExtraHTTPHeaders({ 'referer': server.EMPTY_PAGE }); - await page.interception.enable(); + await page.setRequestInterception(true); page.on('request', request => { expect(request.headers()['referer']).toBe(server.EMPTY_PAGE); - page.interception.continue(request); + request.continue(); }); const response = await page.goto(server.EMPTY_PAGE); expect(response.ok()).toBe(true); }); it('should be abortable', async({page, server}) => { - await page.interception.enable(); + await page.setRequestInterception(true); page.on('request', request => { if (request.url().endsWith('.css')) - page.interception.abort(request); + request.abort(); else - page.interception.continue(request); + request.continue(); }); let failedRequests = 0; page.on('requestfailed', event => ++failedRequests); @@ -172,23 +171,28 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p expect(response.request().failure()).toBe(null); expect(failedRequests).toBe(1); }); - it.skip(FFOX)('should be abortable with custom error codes', async({page, server}) => { - await page.interception.enable(); + it('should be abortable with custom error codes', async({page, server}) => { + await page.setRequestInterception(true); page.on('request', request => { - page.interception.abort(request, 'internetdisconnected'); + request.abort('internetdisconnected'); }); let failedRequest = null; page.on('requestfailed', request => failedRequest = request); await page.goto(server.EMPTY_PAGE).catch(e => {}); expect(failedRequest).toBeTruthy(); - expect(failedRequest.failure().errorText).toBe('net::ERR_INTERNET_DISCONNECTED'); + if (WEBKIT) + expect(failedRequest.failure().errorText).toBe('Request intercepted'); + else if (FFOX) + expect(failedRequest.failure().errorText).toBe('NS_ERROR_FAILURE'); + else + expect(failedRequest.failure().errorText).toBe('net::ERR_INTERNET_DISCONNECTED'); }); it('should send referer', async({page, server}) => { await page.setExtraHTTPHeaders({ referer: 'http://google.com/' }); - await page.interception.enable(); - page.on('request', request => page.interception.continue(request)); + await page.setRequestInterception(true); + page.on('request', request => request.continue()); const [request] = await Promise.all([ server.waitForRequest('/grid.html'), page.goto(server.PREFIX + '/grid.html'), @@ -196,21 +200,23 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p expect(request.headers['referer']).toBe('http://google.com/'); }); it('should fail navigation when aborting main resource', async({page, server}) => { - await page.interception.enable(); - page.on('request', request => page.interception.abort(request)); + await page.setRequestInterception(true); + page.on('request', request => request.abort()); let error = null; await page.goto(server.EMPTY_PAGE).catch(e => error = e); expect(error).toBeTruthy(); - if (CHROME || WEBKIT) - expect(error.message).toContain('net::ERR_FAILED'); - else + if (WEBKIT) + expect(error.message).toContain('Request intercepted'); + else if (FFOX) expect(error.message).toContain('NS_ERROR_FAILURE'); + else + expect(error.message).toContain('net::ERR_FAILED'); }); it('should work with redirects', async({page, server}) => { - await page.interception.enable(); + await page.setRequestInterception(true); const requests = []; page.on('request', request => { - page.interception.continue(request); + request.continue(); if (!utils.isFavicon(request)) requests.push(request); }); @@ -235,10 +241,10 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p } }); it('should work with redirects for subresources', async({page, server}) => { - await page.interception.enable(); + await page.setRequestInterception(true); const requests = []; page.on('request', request => { - page.interception.continue(request); + request.continue(); if (!utils.isFavicon(request)) requests.push(request); }); @@ -259,43 +265,20 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p expect(redirectChain[0].url()).toContain('/one-style.css'); expect(redirectChain[2].url()).toContain('/three-style.css'); }); - it('should be able to abort redirects', async({page, server}) => { - await page.interception.enable(); - server.setRedirect('/non-existing.json', '/non-existing-2.json'); - server.setRedirect('/non-existing-2.json', '/simple.html'); - page.on('request', request => { - if (request.url().includes('non-existing-2')) - page.interception.abort(request); - else - page.interception.continue(request); - }); - await page.goto(server.EMPTY_PAGE); - const result = await page.evaluate(async() => { - try { - await fetch('/non-existing.json'); - } catch (e) { - return e.message; - } - }); - if (CHROME) - expect(result).toContain('Failed to fetch'); - else - expect(result).toContain('NetworkError'); - }); it('should work with equal requests', async({page, server}) => { await page.goto(server.EMPTY_PAGE); let responseCount = 1; server.setRoute('/zzz', (req, res) => res.end((responseCount++) * 11 + '')); - await page.interception.enable(); + await page.setRequestInterception(true); let spinner = false; // Cancel 2nd request. page.on('request', request => { if (utils.isFavicon(request)) { - page.interception.continue(request); + request.continue(); return; } - spinner ? page.interception.abort(request) : page.interception.continue(request); + spinner ? request.abort() : request.continue(); spinner = !spinner; }); const results = await page.evaluate(() => Promise.all([ @@ -306,11 +289,11 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p expect(results).toEqual(['11', 'FAILED', '22']); }); it.skip(FFOX)('should navigate to dataURL and fire dataURL requests', async({page, server}) => { - await page.interception.enable(); + await page.setRequestInterception(true); const requests = []; page.on('request', request => { requests.push(request); - page.interception.continue(request); + request.continue(); }); const dataURL = 'data:text/html,
yo
'; const response = await page.goto(dataURL); @@ -320,11 +303,11 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p }); it.skip(FFOX)('should be able to fetch dataURL and fire dataURL requests', async({page, server}) => { await page.goto(server.EMPTY_PAGE); - await page.interception.enable(); + await page.setRequestInterception(true); const requests = []; page.on('request', request => { requests.push(request); - page.interception.continue(request); + request.continue(); }); const dataURL = 'data:text/html,
yo
'; const text = await page.evaluate(url => fetch(url).then(r => r.text()), dataURL); @@ -332,12 +315,12 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p expect(requests.length).toBe(1); expect(requests[0].url()).toBe(dataURL); }); - it.skip(FFOX)('should navigate to URL with hash and and fire requests without hash', async({page, server}) => { - await page.interception.enable(); + it('should navigate to URL with hash and and fire requests without hash', async({page, server}) => { + await page.setRequestInterception(true); const requests = []; page.on('request', request => { requests.push(request); - page.interception.continue(request); + request.continue(); }); const response = await page.goto(server.EMPTY_PAGE + '#hash'); expect(response.status()).toBe(200); @@ -348,25 +331,25 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p it('should work with encoded server', async({page, server}) => { // The requestWillBeSent will report encoded URL, whereas interception will // report URL as-is. @see crbug.com/759388 - await page.interception.enable(); - page.on('request', request => page.interception.continue(request)); + await page.setRequestInterception(true); + page.on('request', request => request.continue()); const response = await page.goto(server.PREFIX + '/some nonexisting page'); expect(response.status()).toBe(404); }); it('should work with badly encoded server', async({page, server}) => { - await page.interception.enable(); + await page.setRequestInterception(true); server.setRoute('/malformed?rnd=%911', (req, res) => res.end()); - page.on('request', request => page.interception.continue(request)); + page.on('request', request => request.continue()); const response = await page.goto(server.PREFIX + '/malformed?rnd=%911'); expect(response.status()).toBe(200); }); it.skip(FFOX)('should work with encoded server - 2', async({page, server}) => { // The requestWillBeSent will report URL as-is, whereas interception will // report encoded URL for stylesheet. @see crbug.com/759388 - await page.interception.enable(); + await page.setRequestInterception(true); const requests = []; page.on('request', request => { - page.interception.continue(request); + request.continue(); requests.push(request); }); const response = await page.goto(`data:text/html,`); @@ -376,7 +359,7 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p }); it('should not throw "Invalid Interception Id" if the request was cancelled', async({page, server}) => { await page.setContent(''); - await page.interception.enable(); + await page.setRequestInterception(true); let request = null; page.on('request', async r => request = r); page.$eval('iframe', (frame, url) => frame.src = url, server.EMPTY_PAGE), @@ -385,14 +368,15 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p // Delete frame to cause request to be canceled. await page.$eval('iframe', frame => frame.remove()); let error = null; - await page.interception.continue(request).catch(e => error = e); + await request.continue().catch(e => error = e); expect(error).toBe(null); }); - it('should throw if interception is not enabled', async({page, server}) => { + it('should throw if interception is not enabled', async({newPage, server}) => { let error = null; + const page = await newPage(); page.on('request', async request => { try { - await page.interception.continue(request); + await request.continue(); } catch (e) { error = e; } @@ -400,32 +384,20 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p await page.goto(server.EMPTY_PAGE); expect(error.message).toContain('Request Interception is not enabled'); }); - it.skip(FFOX)('should work with file URLs', async({page, server}) => { - await page.interception.enable(); - const urls = new Set(); - page.on('request', request => { - urls.add(request.url().split('/').pop()); - page.interception.continue(request); - }); - await page.goto(pathToFileURL(path.join(__dirname, '..', 'assets', 'one-style.html'))); - expect(urls.size).toBe(2); - expect(urls.has('one-style.html')).toBe(true); - expect(urls.has('one-style.css')).toBe(true); - }); }); - describe.skip(WEBKIT)('Interception.continue', function() { + describe('Interception.continue', function() { it('should work', async({page, server}) => { - await page.interception.enable(); - page.on('request', request => page.interception.continue(request)); + await page.setRequestInterception(true); + page.on('request', request => request.continue()); await page.goto(server.EMPTY_PAGE); }); it('should amend HTTP headers', async({page, server}) => { - await page.interception.enable(); + await page.setRequestInterception(true); page.on('request', request => { const headers = Object.assign({}, request.headers()); headers['FOO'] = 'bar'; - page.interception.continue(request, { headers }); + request.continue({ headers }); }); await page.goto(server.EMPTY_PAGE); const [request] = await Promise.all([ @@ -434,67 +406,18 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p ]); expect(request.headers['foo']).toBe('bar'); }); - it.skip(FFOX)('should redirect in a way non-observable to page', async({page, server}) => { - await page.interception.enable(); - page.on('request', request => { - const redirectURL = request.url().includes('/empty.html') ? server.PREFIX + '/consolelog.html' : undefined; - page.interception.continue(request, { url: redirectURL }); - }); - let consoleMessage = null; - page.on('console', msg => consoleMessage = msg); - await page.goto(server.EMPTY_PAGE); - expect(page.url()).toBe(server.EMPTY_PAGE); - expect(consoleMessage.text()).toBe('yellow'); - }); - it.skip(FFOX)('should amend method', async({page, server}) => { - await page.goto(server.EMPTY_PAGE); - - await page.interception.enable(); - page.on('request', request => { - page.interception.continue(request, { method: 'POST' }); - }); - const [request] = await Promise.all([ - server.waitForRequest('/sleep.zzz'), - page.evaluate(() => fetch('/sleep.zzz')) - ]); - expect(request.method).toBe('POST'); - }); - it.skip(FFOX)('should amend post data', async({page, server}) => { - await page.goto(server.EMPTY_PAGE); - - await page.interception.enable(); - page.on('request', request => { - page.interception.continue(request, { postData: 'doggo' }); - }); - const [serverRequest] = await Promise.all([ - server.waitForRequest('/sleep.zzz'), - page.evaluate(() => fetch('/sleep.zzz', { method: 'POST', body: 'birdy' })) - ]); - expect(await serverRequest.postBody).toBe('doggo'); - }); - it.skip(FFOX)('should amend both post data and method on navigation', async({page, server}) => { - await page.interception.enable(); - page.on('request', request => { - page.interception.continue(request, { method: 'POST', postData: 'doggo' }); - }); - const [serverRequest] = await Promise.all([ - server.waitForRequest('/empty.html'), - page.goto(server.EMPTY_PAGE), - ]); - expect(serverRequest.method).toBe('POST'); - expect(await serverRequest.postBody).toBe('doggo'); - }); }); - describe.skip(FFOX || WEBKIT)('interception.fulfill', function() { + describe.skip(FFOX)('interception.fulfill', function() { it('should work', async({page, server}) => { - await page.interception.enable(); + await page.setRequestInterception(true); page.on('request', request => { - page.interception.fulfill(request, { + request.fulfill({ status: 201, headers: { foo: 'bar' }, + contentType: 'text/html', body: 'Yo, page!' }); }); @@ -504,9 +427,9 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p expect(await page.evaluate(() => document.body.textContent)).toBe('Yo, page!'); }); it('should work with status code 422', async({page, server}) => { - await page.interception.enable(); + await page.setRequestInterception(true); page.on('request', request => { - page.interception.fulfill(request, { + request.fulfill({ status: 422, body: 'Yo, page!' }); @@ -516,14 +439,14 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p expect(response.statusText()).toBe('Unprocessable Entity'); expect(await page.evaluate(() => document.body.textContent)).toBe('Yo, page!'); }); - it('should redirect', async({page, server}) => { - await page.interception.enable(); + it.skip(WEBKIT)('should redirect', async({page, server}) => { + await page.setRequestInterception(true); page.on('request', request => { if (!request.url().includes('rrredirect')) { - page.interception.continue(request); + request.continue(); return; } - page.interception.fulfill(request, { + request.fulfill({ status: 302, headers: { location: server.EMPTY_PAGE, @@ -536,10 +459,10 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p expect(response.url()).toBe(server.EMPTY_PAGE); }); it('should allow mocking binary responses', async({page, server}) => { - await page.interception.enable(); + await page.setRequestInterception(true); page.on('request', request => { - const imageBuffer = fs.readFileSync(path.join(__dirname, '..', 'assets', 'pptr.png')); - page.interception.fulfill(request, { + const imageBuffer = fs.readFileSync(path.join(__dirname, 'assets', 'pptr.png')); + request.fulfill({ contentType: 'image/png', body: imageBuffer }); @@ -554,9 +477,9 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p expect(await img.screenshot()).toBeGolden('mock-binary-response.png'); }); it('should stringify intercepted request response headers', async({page, server}) => { - await page.interception.enable(); + await page.setRequestInterception(true); page.on('request', request => { - page.interception.fulfill(request, { + request.fulfill({ status: 200, headers: { 'foo': true @@ -572,7 +495,7 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p }); }); - describe.skip(FFOX)('Interception.authenticate', function() { + describe.skip(FFOX || WEBKIT)('Interception.authenticate', function() { it('should work', async({page, server}) => { server.setAuth('/empty.html', 'user', 'pass'); let response = await page.goto(server.EMPTY_PAGE); @@ -610,7 +533,7 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p }); }); - describe.skip(FFOX)('Interception.setOfflineMode', function() { + describe.skip(FFOX || WEBKIT)('Interception.setOfflineMode', function() { it('should work', async({page, server}) => { await page.interception.setOfflineMode(true); let error = null; @@ -634,9 +557,9 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p const requests = new Map(); page.on('request', request => { requests.set(request.url().split('/').pop(), request); - page.interception.continue(request); + request.continue(); }); - await page.interception.enable(); + await page.setRequestInterception(true); server.setRedirect('/rrredirect', '/frames/one-frame.html'); await page.goto(server.PREFIX + '/rrredirect'); expect(requests.get('rrredirect').isNavigationRequest()).toBe(true); @@ -650,8 +573,8 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p describe('Page.setCacheEnabled', function() { it('should stay disabled when toggling request interception on/off', async({page, server}) => { await page.setCacheEnabled(false); - await page.interception.enable(); - await page.interception.disable(); + await page.setRequestInterception(true); + await page.setRequestInterception(false); await page.goto(server.PREFIX + '/cached/one-style.html'); const [nonCachedRequest] = await Promise.all([ @@ -664,10 +587,10 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p describe('ignoreHTTPSErrors', function() { it('should work with request interception', async({newPage, httpsServer}) => { - const page = await newPage({ ignoreHTTPSErrors: true }); + const page = await newPage({ ignoreHTTPSErrors: true, interceptNetwork: true }); - await page.interception.enable(); - page.on('request', request => page.interception.continue(request)); + await page.setRequestInterception(true); + page.on('request', request => request.continue()); const response = await page.goto(httpsServer.EMPTY_PAGE); expect(response.status()).toBe(200); }); diff --git a/test/playwright.spec.js b/test/playwright.spec.js index 23e6e28092..3f472e4ee7 100644 --- a/test/playwright.spec.js +++ b/test/playwright.spec.js @@ -165,6 +165,7 @@ module.exports.describe = ({testRunner, product, playwrightPath}) => { testRunner.loadTests(require('./queryselector.spec.js'), testOptions); testRunner.loadTests(require('./screenshot.spec.js'), testOptions); testRunner.loadTests(require('./waittask.spec.js'), testOptions); + testRunner.loadTests(require('./interception.spec.js'), testOptions); if (CHROME) { testRunner.loadTests(require('./chromium/chromium.spec.js'), testOptions); @@ -178,7 +179,6 @@ module.exports.describe = ({testRunner, product, playwrightPath}) => { if (CHROME || FFOX) { testRunner.loadTests(require('./features/accessibility.spec.js'), testOptions); testRunner.loadTests(require('./features/permissions.spec.js'), testOptions); - testRunner.loadTests(require('./features/interception.spec.js'), testOptions); } });