From b5e6474374ae6a88fc73cdb1a9906764c2ac5d70 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Fri, 2 Jul 2021 10:58:32 -0700 Subject: [PATCH] feat: add cooperative request intercepts (#6735) With this change,`request.respond`, `request.abort`, and `request.continue` can accept an optional `priority` to activate Cooperative Intercept Mode. In Cooperative Mode, all intercept handlers are guaranteed to run and all async handlers are awaited. The interception is resolved to the highest-priority resolution. See _Cooperative Intercept Mode and Legacy Intercept Mode_ in `docs/api.md` for details. --- docs/api.md | 265 +++++- src/common/HTTPRequest.ts | 187 +++- src/common/NetworkManager.ts | 4 + src/common/Page.ts | 12 +- test/requestinterception-experimental.spec.ts | 856 ++++++++++++++++++ 5 files changed, 1310 insertions(+), 14 deletions(-) create mode 100644 test/requestinterception-experimental.spec.ts diff --git a/docs/api.md b/docs/api.md index 256984a3..48d52c83 100644 --- a/docs/api.md +++ b/docs/api.md @@ -180,6 +180,8 @@ * [page.setJavaScriptEnabled(enabled)](#pagesetjavascriptenabledenabled) * [page.setOfflineMode(enabled)](#pagesetofflinemodeenabled) * [page.setRequestInterception(value)](#pagesetrequestinterceptionvalue) + - [Cooperative Intercept Mode and Legacy Intercept Mode](#cooperative-intercept-mode-and-legacy-intercept-mode) + - [Upgrading to Cooperative Mode for package maintainers](#upgrading-to-cooperative-mode-for-package-maintainers) * [page.setUserAgent(userAgent[, userAgentMetadata])](#pagesetuseragentuseragent-useragentmetadata) * [page.setViewport(viewport)](#pagesetviewportviewport) * [page.tap(selector)](#pagetapselector) @@ -328,9 +330,13 @@ * [elementHandle.type(text[, options])](#elementhandletypetext-options) * [elementHandle.uploadFile(...filePaths)](#elementhandleuploadfilefilepaths) - [class: HTTPRequest](#class-httprequest) - * [httpRequest.abort([errorCode])](#httprequestaborterrorcode) - * [httpRequest.continue([overrides])](#httprequestcontinueoverrides) + * [httpRequest.abort([errorCode], [priority])](#httprequestaborterrorcode-priority) + * [httpRequest.abortErrorReason()](#httprequestaborterrorreason) + * [httpRequest.continue([overrides], [priority])](#httprequestcontinueoverrides-priority) + * [httpRequest.continueRequestOverrides()](#httprequestcontinuerequestoverrides) + * [httpRequest.enqueueInterceptAction(pendingHandler)](#httprequestenqueueinterceptactionpendinghandler) * [httpRequest.failure()](#httprequestfailure) + * [httpRequest.finalizeInterceptions()](#httprequestfinalizeinterceptions) * [httpRequest.frame()](#httprequestframe) * [httpRequest.headers()](#httprequestheaders) * [httpRequest.isNavigationRequest()](#httprequestisnavigationrequest) @@ -338,8 +344,9 @@ * [httpRequest.postData()](#httprequestpostdata) * [httpRequest.redirectChain()](#httprequestredirectchain) * [httpRequest.resourceType()](#httprequestresourcetype) - * [httpRequest.respond(response)](#httprequestrespondresponse) + * [httpRequest.respond(response, [priority])](#httprequestrespondresponse-priority) * [httpRequest.response()](#httprequestresponse) + * [httpRequest.responseForRequest()](#httprequestresponseforrequest) * [httpRequest.url()](#httprequesturl) - [class: HTTPResponse](#class-httpresponse) * [httpResponse.buffer()](#httpresponsebuffer) @@ -1491,6 +1498,7 @@ If URLs are specified, only cookies for those URLs are returned. - returns: <[Coverage]> #### page.createPDFStream([options]) + - `options` <[Object]> Options object which might have the following properties: - `path` <[string]> The file path to save the PDF to. If `path` is a relative path, then it is resolved relative to [current working directory](https://nodejs.org/api/process.html#process_process_cwd). If no path is provided, the PDF won't be saved to the disk. - `scale` <[number]> Scale of the webpage rendering. Defaults to `1`. Scale amount must be between 0.1 and 2. @@ -2045,6 +2053,7 @@ Page is guaranteed to have a main frame which persists during navigations. - returns: <[Mouse]> #### page.pdf([options]) + - `options` <[Object]> Options object which might have the following properties: - `path` <[string]> The file path to save the PDF to. If `path` is a relative path, then it is resolved relative to [current working directory](https://nodejs.org/api/process.html#process_process_cwd). If no path is provided, the PDF won't be saved to the disk. - `scale` <[number]> Scale of the webpage rendering. Defaults to `1`. Scale amount must be between 0.1 and 2. @@ -2319,10 +2328,10 @@ await page.setGeolocation({ latitude: 59.95, longitude: 30.31667 }); - `value` <[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. +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 @@ -2345,6 +2354,207 @@ const puppeteer = require('puppeteer'); })(); ``` +##### Cooperative Intercept Mode and Legacy Intercept Mode + +`request.respond`, `request.abort`, and `request.continue` can accept an optional `priority` to activate Cooperative Intercept Mode. In Cooperative Mode, all intercept handlers are guaranteed to run and all async handlers are awaited. The interception is resolved to the highest-priority resolution. Here are the rules of Cooperative Mode: + +- Async handlers finish before intercept resolution is finalized. +- The highest priority interception resolution "wins", i.e. the interception is ultimately aborted/responded/continued according to which resolution was given the highest priority. +- In the event of a tie, `abort` > `respond` > `continue`. + +For standardization, when specifying a Cooperative Mode priority use `0` unless you have a clear reason to use a higher priority. This gracefully prefers `respond` over `continue` and `abort` over `respond`. If you do intentionally want to use a different priority, higher priorities win over lower priorities. Negative priorities are allowed. For example, `continue({}, 4)` would win over `continue({}, -2)`. + +To preserve backward compatibility, any handler resolving the intercept without specifying `priority` (Legacy Mode) causes immediate resolution. For Cooperative Mode to work, all resolutions must use a `priority`. + +In this example, Legacy Mode prevails and the request is aborted immediately because at least one handler omits `priority` when resolving the intercept: + +```ts +// Final outcome: immediate abort() +page.setRequestInterception(true); +page.on('request', (request) => { + // Legacy Mode: interception is aborted immediately. + request.abort('failed'); +}); +page.on('request', (request) => { + // ['already-handled'], meaning a legacy resolution has taken place + console.log(request.interceptResolution()); + + // Cooperative Mode: votes for continue at priority 0. + // Ultimately throws an exception after all handlers have finished + // running and Cooperative Mode resolutions are evaluated becasue + // abort() was called using Legacy Mode. + request.continue({}, 0); +}); +``` + +In this example, Legacy Mode prevails and the request is continued because at least one handler does not specify a `priority`: + +```ts +// Final outcome: immediate continue() +page.setRequestInterception(true); +page.on('request', (request) => { + // Cooperative Mode: votes to abort at priority 0. + // Ultimately throws an exception after all handlers have finished + // running and Cooperative Mode resolutions are evaluated becasue + // continue() was called using Legacy Mode. + request.abort('failed', 0); +}); +page.on('request', (request) => { + // ['abort', 0], meaning an abort @ 0 is the current winning resolution + console.log(request.interceptResolution()); + + // Legacy Mode: intercept continues immediately. + request.continue({}); +}); +``` + +In this example, Cooperative Mode is active because all handlers specify a `priority`. `continue()` wins because it has a higher priority than `abort()`. + +```ts +// Final outcome: cooperative continue() @ 5 +page.setRequestInterception(true); +page.on('request', (request) => { + // Cooperative Mode: votes to abort at priority 10 + request.abort('failed', 0); +}); +page.on('request', (request) => { + // Cooperative Mode: votes to continue at priority 5 + request.continue(request.continueRequestOverrides(), 5); +}); +page.on('request', (request) => { + // ['continue', 5], because continue @ 5 > abort @ 0 + console.log(request.interceptResolution()); +}); +``` + +In this example, Cooperative Mode is active because all handlers specify `priority`. `respond()` wins because its priority ties with `continue()`, but `respond()` beats `continue()`. + +```ts +// Final outcome: cooperative respond() @ 15 +page.setRequestInterception(true); +page.on('request', (request) => { + // Cooperative Mode: votes to abort at priority 10 + request.abort('failed', 10); +}); +page.on('request', (request) => { + // Cooperative Mode: votes to continue at priority 15 + request.continue(request.continueRequestOverrides(), 15); +}); +page.on('request', (request) => { + // Cooperative Mode: votes to respond at priority 15 + request.respond(request.responseForRequest(), 15); +}); +page.on('request', (request) => { + // Cooperative Mode: votes to respond at priority 12 + request.respond(request.responseForRequest(), 12); +}); +page.on('request', (request) => { + // ['respond', 15], because respond @ 15 > continue @ 15 > respond @ 12 > abort @ 10 + console.log(request.interceptResolution()); +}); +``` + +##### Upgrading to Cooperative Mode for package maintainers + +If you are package maintainer and your package uses intercept handlers, you can update your intercept handlers to use Cooperative Mode. Suppose you have the following existing handler: + +```ts +page.on('request', (interceptedRequest) => { + if ( + interceptedRequest.url().endsWith('.png') || + interceptedRequest.url().endsWith('.jpg') + ) + interceptedRequest.abort(); + else interceptedRequest.continue(); +}); +``` + +To use Cooperative Mode, upgrade `continue()` and `abort()`: + +```ts +page.on('request', (interceptedRequest) => { + if ( + interceptedRequest.url().endsWith('.png') || + interceptedRequest.url().endsWith('.jpg') + ) + interceptedRequest.abort('failed', 0); + else + interceptedRequest.continue( + interceptedRequest.continueRequestOverrides(), + 0 + ); +}); +``` + +With those simple upgrades, your handler now uses Cooperative Mode instead. + +However, we recommend a slightly more robust solution because the above introduces two subtle issues: + +1. **Backward compatibility.** Cooperative Mode resolves interceptions only if no Legacy Mode resolution has taken place. If any handler uses a Legacy Mode resolution (ie, does not specify a priority), that handler will resolve the interception immediately even if your handler runs first. This could cause disconcerting behavior for your users because suddenly your handler is not resolving the interception and a different handler is taking priority when all they did was upgrade your package. +2. **Hard-coded priority.** Your package user has no ability to specify the default resolution priority for your handlers. This can become important when the user wishes to manipulate the priorities based on use case. For example, one user might want your package to take a high priority while another user might want it to take a low priority. + +To resolve both of these issues, our recommended approach is to export a `setInterceptResolutionStrategy()` from your package. The user can then call `setInterceptResolutionStrategy()` to explicitly activate Cooperative Mode in your package so they aren't surprised by changes in how the interception is resolved. They can also optionally specify a custom priority using `setInterceptResolutionStrategy(priority)` that works for their use case: + +```ts +// Defaults to undefined which preserves Legacy Mode behavior +let _priority = undefined; + +// Export a module configuration function +export const setInterceptResolutionStrategy = (defaultPriority = 0) => + (_priority = defaultPriority); + +page.on('request', (interceptedRequest) => { + if ( + interceptedRequest.url().endsWith('.png') || + interceptedRequest.url().endsWith('.jpg') + ) + interceptedRequest.abort('failed', _priority); + else + interceptedRequest.continue( + interceptedRequest.continueRequestOverrides(), + _priority + ); +}); +``` + +If your package calls for more fine-grained control resolution priorities, use a config pattern like this: + +```ts +interface ResolutionStrategy { + abortPriority: number; + continuePriority: number; +} + +// This strategy supports multiple priorities based on situational +// differences. You could, for example, create a strategy that +// allowed separate priorities for PNG vs JPG. +const DEFAULT_STRATEGY: ResolutionStrategy = { + abortPriority: 0, + continuePriority: 0, +}; + +// Defaults to undefined which preserves Legacy Mode behavior +let _strategy: Partial = {}; + +export const setInterceptResolutionStrategy = (strategy: ResolutionStrategy) => + (_strategy = { ...DEFAULT_STRATEGY, ...strategy }); + +page.on('request', (interceptedRequest) => { + if ( + interceptedRequest.url().endsWith('.png') || + interceptedRequest.url().endsWith('.jpg') + ) + interceptedRequest.abort('failed', _strategy.abortPriority); + else + interceptedRequest.continue( + interceptedRequest.continueRequestOverrides(), + _strategy.continuePriority + ); +}); +``` + +The above solution ensures backward compatibility while also allowing the user to adjust the importance of your package in the resolution chain when Cooperative Mode is being used. Your package continues to work as expected until the user has fully upgraded their code and all third party packages to use Cooperative Mode. If any handler or package still uses Legacy Mode, your package can still operate in Legacy Mode too. + #### page.setUserAgent(userAgent[, userAgentMetadata]) - `userAgent` <[string]> Specific user agent to use in this page @@ -2365,7 +2575,7 @@ const puppeteer = require('puppeteer'); > protocol and more properties will be added. Providing the optional `userAgentMetadata` header will update the related -entries in `navigator.userAgentData` and associated `Sec-CH-UA`* headers. +entries in `navigator.userAgentData` and associated `Sec-CH-UA`\* headers. ```js const page = await browser.newPage(); @@ -4442,7 +4652,7 @@ 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. -#### httpRequest.abort([errorCode]) +#### httpRequest.abort([errorCode], [priority]) - `errorCode` <[string]> Optional error code. Defaults to `failed`, could be one of the following: @@ -4461,18 +4671,26 @@ If request gets a 'redirect' response, the request is successfully finished with - `namenotresolved` - The host name could not be resolved. - `timedout` - An operation timed out. - `failed` - A generic failure occurred. +- `priority` <[number]> - Optional intercept abort priority. If provided, intercept will be resolved using coopeative handling rules. Otherwise, intercept will be resovled immediately. - 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. -#### httpRequest.continue([overrides]) +#### httpRequest.abortErrorReason() + +- returns: <[string]> of type [Protocol.Network.ErrorReason](https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-ErrorReason). + +Returns the most recent reason for aborting set by the previous call to abort() in Cooperative Mode. + +#### httpRequest.continue([overrides], [priority]) - `overrides` <[Object]> Optional request overwrites, which can be one of the following: - `url` <[string]> If set changes the request URL. 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. +- `priority` <[number]> - Optional intercept abort priority. If provided, intercept will be resolved using coopeative handling rules. Otherwise, intercept will be resovled immediately. - returns: <[Promise]> Continues request with optional request overrides. To use this, request interception should be enabled with `page.setRequestInterception`. @@ -4490,6 +4708,22 @@ page.on('request', (request) => { }); ``` +#### httpRequest.continueRequestOverrides() + +- returns: <[Object]> Optional request overwrites, which can be one of the following: + - `url` <[string]> If set changes the request URL. 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 the most recent set of request overrides set with a previous call to continue() in Cooperative Mode. + +#### httpRequest.enqueueInterceptAction(pendingHandler) + +- `pendingHandler` <[function]> The request interception handler to enqueue + +Enqueues a request handler for processing. This facilitates proper execution of async handlers. + #### httpRequest.failure() - returns: Object describing request failure, if any @@ -4506,6 +4740,12 @@ page.on('requestfailed', (request) => { }); ``` +#### httpRequest.finalizeInterceptions() + +- returns: <[Promise]> + +When in Cooperative Mode, awaits pending interception handlers and then decides how to fulfill the request interception. + #### httpRequest.frame() - returns: A [Frame] that initiated this request, or `null` if navigating to error pages. @@ -4565,13 +4805,14 @@ console.log(chain.length); // 0 Contains the request's resource type as it was perceived by the rendering engine. ResourceType will be one of the following: `document`, `stylesheet`, `image`, `media`, `font`, `script`, `texttrack`, `xhr`, `fetch`, `eventsource`, `websocket`, `manifest`, `other`. -#### httpRequest.respond(response) +#### httpRequest.respond(response, [priority]) - `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 +- `priority` <[number]> - Optional intercept abort priority. If provided, intercept will be resolved using coopeative handling rules. Otherwise, intercept will be resovled immediately. - returns: <[Promise]> Fulfills request with given response. To use this, request interception should @@ -4598,6 +4839,12 @@ page.on('request', (request) => { - returns: A matching [HTTPResponse] object, or `null` if the response has not been received yet. +#### httpRequest.responseForRequest() + +- returns: A matching [HTTPResponse] object, or `null` if the response has not been received yet. + +Returns the current response object set by the previous call to respond() in Cooperative Mode. + #### httpRequest.url() - returns: <[string]> URL of the request. diff --git a/src/common/HTTPRequest.ts b/src/common/HTTPRequest.ts index 7c4dcd7e..61e2ad8a 100644 --- a/src/common/HTTPRequest.ts +++ b/src/common/HTTPRequest.ts @@ -124,6 +124,12 @@ export class HTTPRequest { private _postData?: string; private _headers: Record = {}; private _frame: Frame; + private _continueRequestOverrides: ContinueRequestOverrides; + private _responseForRequest: Partial; + private _abortErrorReason: Protocol.Network.ErrorReason; + private _currentStrategy: InterceptResolutionStrategy; + private _currentPriority: number | undefined; + private _interceptActions: Array<() => void | PromiseLike>; /** * @internal @@ -148,6 +154,10 @@ export class HTTPRequest { this._postData = event.request.postData; this._frame = frame; this._redirectChain = redirectChain; + this._continueRequestOverrides = {}; + this._currentStrategy = 'none'; + this._currentPriority = undefined; + this._interceptActions = []; for (const key of Object.keys(event.request.headers)) this._headers[key.toLowerCase()] = event.request.headers[key]; @@ -160,6 +170,81 @@ export class HTTPRequest { return this._url; } + /** + * @returns the `ContinueRequestOverrides` that will be used + * if the interception is allowed to continue (ie, `abort()` and + * `respond()` aren't called). + */ + continueRequestOverrides(): ContinueRequestOverrides { + assert(this._allowInterception, 'Request Interception is not enabled!'); + return this._continueRequestOverrides; + } + + /** + * @returns The `ResponseForRequest` that gets used if the + * interception is allowed to respond (ie, `abort()` is not called). + */ + responseForRequest(): Partial { + assert(this._allowInterception, 'Request Interception is not enabled!'); + return this._responseForRequest; + } + + /** + * @returns the most recent reason for aborting the request + */ + abortErrorReason(): Protocol.Network.ErrorReason { + assert(this._allowInterception, 'Request Interception is not enabled!'); + return this._abortErrorReason; + } + + /** + * @returns An array of the current intercept resolution strategy and priority + * `[strategy,priority]`. Strategy is one of: `abort`, `respond`, `continue`, + * `disabled`, `none`, or `already-handled`. + */ + private interceptResolution(): [InterceptResolutionStrategy, number?] { + if (!this._allowInterception) return ['disabled']; + if (this._interceptionHandled) return ['alreay-handled']; + return [this._currentStrategy, this._currentPriority]; + } + + /** + * Adds an async request handler to the processing queue. + * Deferred handlers are not guaranteed to execute in any particular order, + * but they are guarnateed to resolve before the request interception + * is finalized. + */ + enqueueInterceptAction( + pendingHandler: () => void | PromiseLike + ): void { + this._interceptActions.push(pendingHandler); + } + + /** + * Awaits pending interception handlers and then decides how to fulfill + * the request interception. + */ + async finalizeInterceptions(): Promise { + await this._interceptActions.reduce( + (promiseChain, interceptAction) => + promiseChain.then(interceptAction).catch((error) => { + // This is here so cooperative handlers that fail do not stop other handlers + // from running + debugError(error); + }), + Promise.resolve() + ); + const [resolution] = this.interceptResolution(); + switch (resolution) { + case 'abort': + return this._abort(this._abortErrorReason); + case 'respond': + return this._respond(this._responseForRequest); + case 'continue': + return this._continue(this._continueRequestOverrides); + } + } + /** * Contains the request's resource type as it was perceived by the rendering * engine. @@ -295,12 +380,45 @@ export class HTTPRequest { * ``` * * @param overrides - optional overrides to apply to the request. + * @param priority - If provided, intercept is resolved using + * cooperative handling rules. Otherwise, intercept is resolved + * immediately. */ - async continue(overrides: ContinueRequestOverrides = {}): Promise { + async continue( + overrides: ContinueRequestOverrides = {}, + priority?: number + ): Promise { // Request interception is not supported for data: urls. if (this._url.startsWith('data:')) return; assert(this._allowInterception, 'Request Interception is not enabled!'); assert(!this._interceptionHandled, 'Request is already handled!'); + if (priority === undefined) { + return this._continue(overrides); + } + this._continueRequestOverrides = overrides; + if ( + priority > this._currentPriority || + this._currentPriority === undefined + ) { + this._currentStrategy = 'continue'; + this._currentPriority = priority; + return; + } + if (priority === this._currentPriority) { + if ( + this._currentStrategy === 'abort' || + this._currentStrategy === 'respond' + ) { + return; + } + this._currentStrategy = 'continue'; + } + return; + } + + private async _continue( + overrides: ContinueRequestOverrides = {} + ): Promise { const { url, method, postData, headers } = overrides; this._interceptionHandled = true; @@ -351,12 +469,39 @@ export class HTTPRequest { * Calling `request.respond` for a dataURL request is a noop. * * @param response - the response to fulfill the request with. + * @param priority - If provided, intercept is resolved using + * cooperative handling rules. Otherwise, intercept is resolved + * immediately. */ - async respond(response: Partial): Promise { + async respond( + response: Partial, + priority?: number + ): Promise { // Mocking responses for dataURL requests is not currently supported. if (this._url.startsWith('data:')) return; assert(this._allowInterception, 'Request Interception is not enabled!'); assert(!this._interceptionHandled, 'Request is already handled!'); + if (priority === undefined) { + return this._respond(response); + } + this._responseForRequest = response; + if ( + priority > this._currentPriority || + this._currentPriority === undefined + ) { + this._currentStrategy = 'respond'; + this._currentPriority = priority; + return; + } + if (priority === this._currentPriority) { + if (this._currentStrategy === 'abort') { + return; + } + this._currentStrategy = 'respond'; + } + } + + private async _respond(response: Partial): Promise { this._interceptionHandled = true; const responseBody: Buffer | null = @@ -403,14 +548,37 @@ export class HTTPRequest { * throw an exception immediately. * * @param errorCode - optional error code to provide. + * @param priority - If provided, intercept is resolved using + * cooperative handling rules. Otherwise, intercept is resolved + * immediately. */ - async abort(errorCode: ErrorCode = 'failed'): Promise { + async abort( + errorCode: ErrorCode = 'failed', + priority?: number + ): Promise { // Request interception is not supported for data: urls. if (this._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!'); + if (priority === undefined) { + return this._abort(errorReason); + } + this._abortErrorReason = errorReason; + if ( + priority >= this._currentPriority || + this._currentPriority === undefined + ) { + this._currentStrategy = 'abort'; + this._currentPriority = priority; + return; + } + } + + private async _abort( + errorReason: Protocol.Network.ErrorReason + ): Promise { this._interceptionHandled = true; await this._client .send('Fetch.failRequest', { @@ -426,6 +594,17 @@ export class HTTPRequest { } } +/** + * @public + */ +export type InterceptResolutionStrategy = + | 'abort' + | 'respond' + | 'continue' + | 'disabled' + | 'none' + | 'alreay-handled'; + /** * @public */ @@ -462,6 +641,8 @@ const errorReasons: Record = { failed: 'Failed', } as const; +export type ActionResult = 'continue' | 'abort' | 'respond'; + function headersArray( headers: Record ): Array<{ name: string; value: string }> { diff --git a/src/common/NetworkManager.ts b/src/common/NetworkManager.ts index ab4e6fe1..dda138e6 100644 --- a/src/common/NetworkManager.ts +++ b/src/common/NetworkManager.ts @@ -382,6 +382,10 @@ export class NetworkManager extends EventEmitter { ); this._requestIdToRequest.set(event.requestId, request); this.emit(NetworkManagerEmittedEvents.Request, request); + request.finalizeInterceptions().catch((error) => { + // This should never happen, but catch just in case. + debugError(error); + }); } _onRequestServedFromCache( diff --git a/src/common/Page.ts b/src/common/Page.ts index 7ea47766..944b546d 100644 --- a/src/common/Page.ts +++ b/src/common/Page.ts @@ -610,12 +610,20 @@ export class Page extends EventEmitter { /** * Listen to page events. */ + // Note: this method exists to define event typings and handle + // proper wireup of cooperative request interception. Actual event listening and + // dispatching is delegated to EventEmitter. public on( eventName: K, handler: (event: PageEventObject[K]) => void ): EventEmitter { - // Note: this method only exists to define the types; we delegate the impl - // to EventEmitter. + if (eventName === 'request') { + return super.on(eventName, (event: HTTPRequest) => { + event.enqueueInterceptAction(() => + handler(event as PageEventObject[K]) + ); + }); + } return super.on(eventName, handler); } diff --git a/test/requestinterception-experimental.spec.ts b/test/requestinterception-experimental.spec.ts new file mode 100644 index 00000000..e73d1102 --- /dev/null +++ b/test/requestinterception-experimental.spec.ts @@ -0,0 +1,856 @@ +/** + * Copyright 2021 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import path from 'path'; +import utils from './utils.js'; +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, + describeFailsFirefox, +} from './mocha-utils'; // eslint-disable-line import/extensions +import { ActionResult } from '../lib/cjs/puppeteer/api-docs-entry.js'; + +describe('request interception', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + describeFailsFirefox('Page.setRequestInterception', function () { + const expectedActions: ActionResult[] = ['abort', 'continue', 'respond']; + while (expectedActions.length > 0) { + const expectedAction = expectedActions.pop(); + it(`should cooperatively ${expectedAction} by priority`, async () => { + const { page, server } = getTestState(); + + const actionResults: ActionResult[] = []; + await page.setRequestInterception(true); + page.on('request', (request) => { + if (request.url().endsWith('.css')) + request.continue( + { headers: { ...request.headers(), xaction: 'continue' } }, + expectedAction === 'continue' ? 1 : 0 + ); + else request.continue({}, 0); + }); + page.on('request', (request) => { + if (request.url().endsWith('.css')) + request.respond( + { headers: { xaction: 'respond' } }, + expectedAction === 'respond' ? 1 : 0 + ); + else request.continue({}, 0); + }); + page.on('request', (request) => { + if (request.url().endsWith('.css')) + request.abort('aborted', expectedAction === 'abort' ? 1 : 0); + else request.continue({}, 0); + }); + page.on('response', (response) => { + const { xaction } = response.headers(); + if (response.url().endsWith('.css') && !!xaction) + actionResults.push(xaction as ActionResult); + }); + page.on('requestfailed', (request) => { + if (request.url().endsWith('.css')) actionResults.push('abort'); + }); + + const response = await (async () => { + if (expectedAction === 'continue') { + const [serverRequest, response] = await Promise.all([ + server.waitForRequest('/one-style.css'), + page.goto(server.PREFIX + '/one-style.html'), + ]); + actionResults.push(serverRequest.headers.xaction as ActionResult); + return response; + } else { + return await page.goto(server.PREFIX + '/one-style.html'); + } + })(); + + expect(actionResults.length).toBe(1); + expect(actionResults[0]).toBe(expectedAction); + expect(response.ok()).toBe(true); + }); + } + + it('should intercept', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + if (utils.isFavicon(request)) { + request.continue({}, 0); + return; + } + 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({}, 0); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.ok()).toBe(true); + expect(response.remoteAddress().port).toBe(server.PORT); + }); + // @see https://github.com/puppeteer/puppeteer/pull/3105 + it('should work when POST is redirected with 302', async () => { + const { page, server } = getTestState(); + + server.setRedirect('/rredirect', '/empty.html'); + await page.goto(server.EMPTY_PAGE); + await page.setRequestInterception(true); + page.on('request', (request) => request.continue({}, 0)); + await page.setContent(` +
+ +
+ `); + await Promise.all([ + page.$eval('form', (form: HTMLFormElement) => form.submit()), + page.waitForNavigation(), + ]); + }); + // @see https://github.com/puppeteer/puppeteer/issues/3973 + it('should work when header manipulation headers with redirect', async () => { + const { page, server } = getTestState(); + + server.setRedirect('/rrredirect', '/empty.html'); + await page.setRequestInterception(true); + page.on('request', (request) => { + const headers = Object.assign({}, request.headers(), { + foo: 'bar', + }); + request.continue({ headers }, 0); + + expect(request.continueRequestOverrides()).toEqual({ headers }); + }); + // Make sure that the goto does not time out. + await page.goto(server.PREFIX + '/rrredirect'); + }); + // @see https://github.com/puppeteer/puppeteer/issues/4743 + it('should be able to remove headers', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + const headers = Object.assign({}, request.headers(), { + foo: 'bar', + origin: undefined, // remove "origin" header + }); + request.continue({ headers }, 0); + }); + + const [serverRequest] = await Promise.all([ + server.waitForRequest('/empty.html'), + page.goto(server.PREFIX + '/empty.html'), + ]); + + expect(serverRequest.headers.origin).toBe(undefined); + }); + it('should contain referer header', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + const requests = []; + page.on('request', (request) => { + if (!utils.isFavicon(request)) requests.push(request); + request.continue({}, 0); + }); + await page.goto(server.PREFIX + '/one-style.html'); + expect(requests[1].url()).toContain('/one-style.css'); + expect(requests[1].headers().referer).toContain('/one-style.html'); + }); + it('should properly return navigation response when URL has cookies', async () => { + const { page, server } = getTestState(); + + // Setup cookie. + await page.goto(server.EMPTY_PAGE); + await page.setCookie({ name: 'foo', value: 'bar' }); + + // Setup request interception. + await page.setRequestInterception(true); + page.on('request', (request) => request.continue({}, 0)); + const response = await page.reload(); + expect(response.status()).toBe(200); + }); + it('should stop intercepting', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.once('request', (request) => request.continue({}, 0)); + await page.goto(server.EMPTY_PAGE); + await page.setRequestInterception(false); + await page.goto(server.EMPTY_PAGE); + }); + it('should show custom HTTP headers', async () => { + const { page, server } = getTestState(); + + await page.setExtraHTTPHeaders({ + foo: 'bar', + }); + await page.setRequestInterception(true); + page.on('request', (request) => { + expect(request.headers()['foo']).toBe('bar'); + request.continue({}, 0); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.ok()).toBe(true); + }); + // @see https://github.com/puppeteer/puppeteer/issues/4337 + it('should work with redirect inside sync XHR', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + server.setRedirect('/logo.png', '/pptr.png'); + await page.setRequestInterception(true); + page.on('request', (request) => request.continue({}, 0)); + const status = await page.evaluate(async () => { + const request = new XMLHttpRequest(); + request.open('GET', '/logo.png', false); // `false` makes the request synchronous + request.send(null); + return request.status; + }); + expect(status).toBe(200); + }); + it('should work with custom referer headers', async () => { + const { page, server } = getTestState(); + + await page.setExtraHTTPHeaders({ referer: server.EMPTY_PAGE }); + await page.setRequestInterception(true); + page.on('request', (request) => { + expect(request.headers()['referer']).toBe(server.EMPTY_PAGE); + request.continue({}, 0); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.ok()).toBe(true); + }); + it('should be abortable', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + if (request.url().endsWith('.css')) request.abort('failed', 0); + else request.continue({}, 0); + }); + let failedRequests = 0; + page.on('requestfailed', () => ++failedRequests); + const response = await page.goto(server.PREFIX + '/one-style.html'); + expect(response.ok()).toBe(true); + expect(response.request().failure()).toBe(null); + expect(failedRequests).toBe(1); + }); + it('should be able to access the error reason', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + request.abort('failed', 0); + }); + let abortReason = null; + page.on('request', (request) => { + abortReason = request.abortErrorReason(); + request.continue({}, 0); + }); + await page.goto(server.EMPTY_PAGE).catch(() => {}); + expect(abortReason).toBe('Failed'); + }); + it('should be abortable with custom error codes', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + request.abort('internetdisconnected', 0); + }); + let failedRequest = null; + page.on('requestfailed', (request) => (failedRequest = request)); + await page.goto(server.EMPTY_PAGE).catch(() => {}); + expect(failedRequest).toBeTruthy(); + expect(failedRequest.failure().errorText).toBe( + 'net::ERR_INTERNET_DISCONNECTED' + ); + }); + it('should send referer', async () => { + const { page, server } = getTestState(); + + await page.setExtraHTTPHeaders({ + referer: 'http://google.com/', + }); + await page.setRequestInterception(true); + page.on('request', (request) => request.continue({}, 0)); + const [request] = await Promise.all([ + server.waitForRequest('/grid.html'), + page.goto(server.PREFIX + '/grid.html'), + ]); + expect(request.headers['referer']).toBe('http://google.com/'); + }); + it('should fail navigation when aborting main resource', async () => { + const { page, server, isChrome } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => request.abort('failed', 0)); + let error = null; + await page.goto(server.EMPTY_PAGE).catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + if (isChrome) expect(error.message).toContain('net::ERR_FAILED'); + else expect(error.message).toContain('NS_ERROR_FAILURE'); + }); + it('should work with redirects', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + const requests = []; + page.on('request', (request) => { + request.continue({}, 0); + requests.push(request); + }); + server.setRedirect( + '/non-existing-page.html', + '/non-existing-page-2.html' + ); + server.setRedirect( + '/non-existing-page-2.html', + '/non-existing-page-3.html' + ); + server.setRedirect( + '/non-existing-page-3.html', + '/non-existing-page-4.html' + ); + server.setRedirect('/non-existing-page-4.html', '/empty.html'); + const response = await page.goto( + server.PREFIX + '/non-existing-page.html' + ); + expect(response.status()).toBe(200); + expect(response.url()).toContain('empty.html'); + expect(requests.length).toBe(5); + expect(requests[2].resourceType()).toBe('document'); + // Check redirect chain + const redirectChain = response.request().redirectChain(); + expect(redirectChain.length).toBe(4); + expect(redirectChain[0].url()).toContain('/non-existing-page.html'); + expect(redirectChain[2].url()).toContain('/non-existing-page-3.html'); + for (let i = 0; i < redirectChain.length; ++i) { + const request = redirectChain[i]; + expect(request.isNavigationRequest()).toBe(true); + expect(request.redirectChain().indexOf(request)).toBe(i); + } + }); + it('should work with redirects for subresources', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + const requests = []; + page.on('request', (request) => { + request.continue({}, 0); + if (!utils.isFavicon(request)) requests.push(request); + }); + server.setRedirect('/one-style.css', '/two-style.css'); + server.setRedirect('/two-style.css', '/three-style.css'); + server.setRedirect('/three-style.css', '/four-style.css'); + server.setRoute('/four-style.css', (req, res) => + res.end('body {box-sizing: border-box; }') + ); + + const response = await page.goto(server.PREFIX + '/one-style.html'); + expect(response.status()).toBe(200); + expect(response.url()).toContain('one-style.html'); + expect(requests.length).toBe(5); + expect(requests[0].resourceType()).toBe('document'); + expect(requests[1].resourceType()).toBe('stylesheet'); + // Check redirect chain + const redirectChain = requests[1].redirectChain(); + expect(redirectChain.length).toBe(3); + expect(redirectChain[0].url()).toContain('/one-style.css'); + expect(redirectChain[2].url()).toContain('/three-style.css'); + }); + it('should be able to abort redirects', async () => { + const { page, server, isChrome } = getTestState(); + + await page.setRequestInterception(true); + 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')) + request.abort('failed', 0); + else request.continue({}, 0); + }); + await page.goto(server.EMPTY_PAGE); + const result = await page.evaluate(async () => { + try { + await fetch('/non-existing.json'); + } catch (error) { + return error.message; + } + }); + if (isChrome) expect(result).toContain('Failed to fetch'); + else expect(result).toContain('NetworkError'); + }); + it('should work with equal requests', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + let responseCount = 1; + server.setRoute('/zzz', (req, res) => res.end(responseCount++ * 11 + '')); + await page.setRequestInterception(true); + + let spinner = false; + // Cancel 2nd request. + page.on('request', (request) => { + if (utils.isFavicon(request)) { + request.continue({}, 0); + return; + } + spinner ? request.abort('failed', 0) : request.continue({}, 0); + spinner = !spinner; + }); + const results = await page.evaluate(() => + Promise.all([ + fetch('/zzz') + .then((response) => response.text()) + .catch(() => 'FAILED'), + fetch('/zzz') + .then((response) => response.text()) + .catch(() => 'FAILED'), + fetch('/zzz') + .then((response) => response.text()) + .catch(() => 'FAILED'), + ]) + ); + expect(results).toEqual(['11', 'FAILED', '22']); + }); + it('should navigate to dataURL and fire dataURL requests', async () => { + const { page } = getTestState(); + + await page.setRequestInterception(true); + const requests = []; + page.on('request', (request) => { + requests.push(request); + request.continue({}, 0); + }); + const dataURL = 'data:text/html,
yo
'; + const response = await page.goto(dataURL); + expect(response.status()).toBe(200); + expect(requests.length).toBe(1); + expect(requests[0].url()).toBe(dataURL); + }); + it('should be able to fetch dataURL and fire dataURL requests', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setRequestInterception(true); + const requests = []; + page.on('request', (request) => { + requests.push(request); + request.continue({}, 0); + }); + const dataURL = 'data:text/html,
yo
'; + const text = await page.evaluate( + (url: string) => fetch(url).then((r) => r.text()), + dataURL + ); + expect(text).toBe('
yo
'); + expect(requests.length).toBe(1); + expect(requests[0].url()).toBe(dataURL); + }); + it('should navigate to URL with hash and fire requests without hash', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + const requests = []; + page.on('request', (request) => { + requests.push(request); + request.continue({}, 0); + }); + const response = await page.goto(server.EMPTY_PAGE + '#hash'); + expect(response.status()).toBe(200); + expect(response.url()).toBe(server.EMPTY_PAGE); + expect(requests.length).toBe(1); + expect(requests[0].url()).toBe(server.EMPTY_PAGE); + }); + it('should work with encoded server', async () => { + const { page, server } = getTestState(); + + // The requestWillBeSent will report encoded URL, whereas interception will + // report URL as-is. @see crbug.com/759388 + await page.setRequestInterception(true); + page.on('request', (request) => request.continue({}, 0)); + const response = await page.goto( + server.PREFIX + '/some nonexisting page' + ); + expect(response.status()).toBe(404); + }); + it('should work with badly encoded server', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + server.setRoute('/malformed?rnd=%911', (req, res) => res.end()); + page.on('request', (request) => request.continue({}, 0)); + const response = await page.goto(server.PREFIX + '/malformed?rnd=%911'); + expect(response.status()).toBe(200); + }); + it('should work with encoded server - 2', async () => { + const { page, server } = getTestState(); + + // The requestWillBeSent will report URL as-is, whereas interception will + // report encoded URL for stylesheet. @see crbug.com/759388 + await page.setRequestInterception(true); + const requests = []; + page.on('request', (request) => { + request.continue({}, 0); + requests.push(request); + }); + const response = await page.goto( + `data:text/html,` + ); + expect(response.status()).toBe(200); + expect(requests.length).toBe(2); + expect(requests[1].response().status()).toBe(404); + }); + it('should not throw "Invalid Interception Id" if the request was cancelled', async () => { + const { page, server } = getTestState(); + + await page.setContent(''); + await page.setRequestInterception(true); + let request = null; + page.on('request', async (r) => (request = r)); + page.$eval( + 'iframe', + (frame: HTMLIFrameElement, url: string) => (frame.src = url), + server.EMPTY_PAGE + ), + // Wait for request interception. + await utils.waitEvent(page, 'request'); + // Delete frame to cause request to be canceled. + await page.$eval('iframe', (frame) => frame.remove()); + let error = null; + await request.continue({}, 0).catch((error_) => (error = error_)); + expect(error).toBe(null); + }); + it('should throw if interception is not enabled', async () => { + const { page, server } = getTestState(); + + let error = null; + page.on('request', async (request) => { + try { + await request.continue({}, 0); + } catch (error_) { + error = error_; + } + }); + await page.goto(server.EMPTY_PAGE); + expect(error.message).toContain('Request Interception is not enabled'); + }); + it('should work with file URLs', async () => { + const { page } = getTestState(); + + await page.setRequestInterception(true); + const urls = new Set(); + page.on('request', (request) => { + urls.add(request.url().split('/').pop()); + request.continue({}, 0); + }); + 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); + }); + it('should not cache if cache disabled', async () => { + const { page, server } = getTestState(); + + // Load and re-load to make sure it's cached. + await page.goto(server.PREFIX + '/cached/one-style.html'); + + await page.setRequestInterception(true); + await page.setCacheEnabled(false); + page.on('request', (request) => request.continue({}, 0)); + + const cached = []; + page.on('requestservedfromcache', (r) => cached.push(r)); + + await page.reload(); + expect(cached.length).toBe(0); + }); + it('should cache if cache enabled', async () => { + const { page, server } = getTestState(); + + // Load and re-load to make sure it's cached. + await page.goto(server.PREFIX + '/cached/one-style.html'); + + await page.setRequestInterception(true); + await page.setCacheEnabled(true); + page.on('request', (request) => request.continue({}, 0)); + + const cached = []; + page.on('requestservedfromcache', (r) => cached.push(r)); + + await page.reload(); + expect(cached.length).toBe(1); + }); + it('should load fonts if cache enabled', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + await page.setCacheEnabled(true); + page.on('request', (request) => request.continue({}, 0)); + + await page.goto(server.PREFIX + '/cached/one-style-font.html'); + await page.waitForResponse((r) => r.url().endsWith('/one-style.woff')); + }); + }); + + describeFailsFirefox('Request.continue', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => request.continue({}, 0)); + await page.goto(server.EMPTY_PAGE); + }); + it('should amend HTTP headers', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + const headers = Object.assign({}, request.headers()); + headers['FOO'] = 'bar'; + request.continue({ headers }, 0); + }); + await page.goto(server.EMPTY_PAGE); + const [request] = await Promise.all([ + server.waitForRequest('/sleep.zzz'), + page.evaluate(() => fetch('/sleep.zzz')), + ]); + expect(request.headers['foo']).toBe('bar'); + }); + it('should redirect in a way non-observable to page', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + const redirectURL = request.url().includes('/empty.html') + ? server.PREFIX + '/consolelog.html' + : undefined; + request.continue({ url: redirectURL }, 0); + }); + 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('should amend method', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + + await page.setRequestInterception(true); + page.on('request', (request) => { + request.continue({ method: 'POST' }, 0); + }); + const [request] = await Promise.all([ + server.waitForRequest('/sleep.zzz'), + page.evaluate(() => fetch('/sleep.zzz')), + ]); + expect(request.method).toBe('POST'); + }); + it('should amend post data', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + + await page.setRequestInterception(true); + page.on('request', (request) => { + request.continue({ postData: 'doggo' }, 0); + }); + 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('should amend both post data and method on navigation', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + request.continue({ method: 'POST', postData: 'doggo' }, 0); + }); + 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'); + }); + }); + + describeFailsFirefox('Request.respond', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + request.respond( + { + status: 201, + headers: { + foo: 'bar', + }, + body: 'Yo, page!', + }, + 0 + ); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.status()).toBe(201); + expect(response.headers().foo).toBe('bar'); + expect(await page.evaluate(() => document.body.textContent)).toBe( + 'Yo, page!' + ); + }); + it('should be able to access the response', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + request.respond( + { + status: 200, + body: 'Yo, page!', + }, + 0 + ); + }); + let response = null; + page.on('request', (request) => { + response = request.responseForRequest(); + request.continue({}, 0); + }); + await page.goto(server.EMPTY_PAGE); + expect(response).toEqual({ status: 200, body: 'Yo, page!' }); + }); + it('should work with status code 422', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + request.respond( + { + status: 422, + body: 'Yo, page!', + }, + 0 + ); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.status()).toBe(422); + expect(response.statusText()).toBe('Unprocessable Entity'); + expect(await page.evaluate(() => document.body.textContent)).toBe( + 'Yo, page!' + ); + }); + it('should redirect', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + if (!request.url().includes('rrredirect')) { + request.continue({}, 0); + return; + } + request.respond( + { + status: 302, + headers: { + location: server.EMPTY_PAGE, + }, + }, + 0 + ); + }); + const response = await page.goto(server.PREFIX + '/rrredirect'); + expect(response.request().redirectChain().length).toBe(1); + expect(response.request().redirectChain()[0].url()).toBe( + server.PREFIX + '/rrredirect' + ); + expect(response.url()).toBe(server.EMPTY_PAGE); + }); + it('should allow mocking binary responses', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + const imageBuffer = fs.readFileSync( + path.join(__dirname, 'assets', 'pptr.png') + ); + request.respond( + { + contentType: 'image/png', + body: imageBuffer, + }, + 0 + ); + }); + await page.evaluate((PREFIX) => { + const img = document.createElement('img'); + img.src = PREFIX + '/does-not-exist.png'; + document.body.appendChild(img); + return new Promise((fulfill) => (img.onload = fulfill)); + }, server.PREFIX); + const img = await page.$('img'); + expect(await img.screenshot()).toBeGolden('mock-binary-response.png'); + }); + it('should stringify intercepted request response headers', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + request.respond( + { + status: 200, + headers: { + foo: true, + }, + body: 'Yo, page!', + }, + 0 + ); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.status()).toBe(200); + const headers = response.headers(); + expect(headers.foo).toBe('true'); + expect(await page.evaluate(() => document.body.textContent)).toBe( + 'Yo, page!' + ); + }); + }); +}); + +function pathToFileURL(path: string): string { + let pathName = path.replace(/\\/g, '/'); + // Windows drive letter must be prefixed with a slash. + if (!pathName.startsWith('/')) pathName = '/' + pathName; + return 'file://' + pathName; +}