From c0cd2d4579900dcdd48efe9eef25d5c75561d44e Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 18 Jun 2021 11:04:48 -0700 Subject: [PATCH] feat: response interception (#7122) --- docs/src/api/class-route.md | 25 ++++ src/client/network.ts | 99 ++++++++++++- src/dispatchers/networkDispatchers.ts | 25 +++- src/protocol/channels.ts | 22 ++- src/protocol/protocol.yml | 22 +++ src/protocol/validator.ts | 11 ++ src/server/chromium/crNetworkManager.ts | 30 +++- src/server/chromium/crPage.ts | 6 +- src/server/firefox/ffNetworkManager.ts | 9 +- src/server/network.ts | 67 ++++++++- src/server/types.ts | 9 ++ src/server/webkit/wkInterceptableRequest.ts | 26 +++- src/server/webkit/wkPage.ts | 38 ++++- src/server/webkit/wkProvisionalPage.ts | 3 +- tests/config/checkCoverage.js | 4 + tests/page/page-request-intercept.spec.ts | 154 ++++++++++++++++++++ types/types.d.ts | 26 ++++ 17 files changed, 540 insertions(+), 36 deletions(-) create mode 100644 tests/page/page-request-intercept.spec.ts diff --git a/docs/src/api/class-route.md b/docs/src/api/class-route.md index 639324bb2f..72487b7195 100644 --- a/docs/src/api/class-route.md +++ b/docs/src/api/class-route.md @@ -220,6 +220,31 @@ Optional response body as raw bytes. File path to respond with. The content type will be inferred from file extension. If `path` is a relative path, then it is resolved relative to the current working directory. +## async method: Route.intercept +- returns: <[Response]> + +Continues route's request with optional overrides and intercepts response. + +### option: Route.intercept.url +- `url` <[string]> + +If set changes the request URL. New URL must have same protocol as original one. + +### option: Route.intercept.method +- `method` <[string]> + +If set changes the request method (e.g. GET or POST) + +### option: Route.intercept.postData +- `postData` <[string]|[Buffer]> + +If set changes the post data of request + +### option: Route.intercept.headers +- `headers` <[Object]<[string], [string]>> + +If set changes the request HTTP headers. Header values will be converted to a string. + ## method: Route.request - returns: <[Request]> diff --git a/src/client/network.ts b/src/client/network.ts index e385c22d6c..146aa76600 100644 --- a/src/client/network.ts +++ b/src/client/network.ts @@ -26,6 +26,7 @@ import { Events } from './events'; import { Page } from './page'; import { Waiter } from './waiter'; import * as api from '../../types/types'; +import { Serializable } from '../../types/structs'; export type NetworkCookie = { name: string, @@ -170,6 +171,80 @@ export class Request extends ChannelOwner { + return null; + } + + async serverAddr(): Promise<{ ipAddress: string; port: number; } | null> { + return null; + } + + async finished(): Promise { + const response = await this._request.response(); + if (!response) + return null; + return await response.finished(); + } + + frame(): api.Frame { + return this._request.frame(); + } + + ok(): boolean { + return this._initializer.status === 0 || (this._initializer.status >= 200 && this._initializer.status <= 299); + } + + url(): string { + return this._request.url(); + } + + status(): number { + return this._initializer.status; + } + + statusText(): string { + return this._initializer.statusText; + } + + headers(): Headers { + return { ...this._headers }; + } + + async body(): Promise { + return this._route._responseBody(); + } + + async text(): Promise { + const content = await this.body(); + return content.toString('utf8'); + } + + async json(): Promise { + const content = await this.text(); + return JSON.parse(content); + } + + request(): Request { + return this._request; + } +} + +type InterceptResponse = true; +type NotInterceptResponse = false; + export class Route extends ChannelOwner implements api.Route { static from(route: channels.RouteChannel): Route { return (route as any)._object; @@ -228,15 +303,35 @@ export class Route extends ChannelOwner { + return await this._continue('route.intercept', options, true); + } + async continue(options: { url?: string, method?: string, headers?: Headers, postData?: string | Buffer } = {}) { - return this._wrapApiCall('route.continue', async (channel: channels.RouteChannel) => { + await this._continue('route.continue', options, false); + } + + async _continue(apiName: string, options: { url?: string, method?: string, headers?: Headers, postData?: string | Buffer }, interceptResponse: NotInterceptResponse) : Promise; + async _continue(apiName: string, options: { url?: string, method?: string, headers?: Headers, postData?: string | Buffer }, interceptResponse: InterceptResponse) : Promise; + async _continue(apiName: string, options: { url?: string, method?: string, headers?: Headers, postData?: string | Buffer }, interceptResponse: boolean) : Promise { + return await this._wrapApiCall(apiName, async (channel: channels.RouteChannel) => { const postDataBuffer = isString(options.postData) ? Buffer.from(options.postData, 'utf8') : options.postData; - await channel.continue({ + const result = await channel.continue({ url: options.url, method: options.method, headers: options.headers ? headersObjectToArray(options.headers) : undefined, postData: postDataBuffer ? postDataBuffer.toString('base64') : undefined, + interceptResponse, }); + if (result.response) + return new InterceptedResponse(this, result.response); + return null; + }); + } + + async _responseBody(): Promise { + return this._wrapApiCall('response.body', async (channel: channels.RouteChannel) => { + return Buffer.from((await channel.responseBody()).binary, 'base64'); }); } } diff --git a/src/dispatchers/networkDispatchers.ts b/src/dispatchers/networkDispatchers.ts index 5bd337aeb1..1cc5784fc1 100644 --- a/src/dispatchers/networkDispatchers.ts +++ b/src/dispatchers/networkDispatchers.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Request, Response, Route, WebSocket } from '../server/network'; +import { InterceptedResponse, Request, Response, Route, WebSocket } from '../server/network'; import * as channels from '../protocol/channels'; import { Dispatcher, DispatcherScope, lookupNullableDispatcher, existingDispatcher } from './dispatcher'; import { FrameDispatcher } from './frameDispatcher'; @@ -97,10 +97,6 @@ export class RouteDispatcher extends Dispatcher { - await this._object.continue({ + async responseBody(params?: channels.RouteResponseBodyParams, metadata?: channels.Metadata): Promise { + return { binary: (await this._object.responseBody()).toString('base64') }; + } + + async continue(params: channels.RouteContinueParams, metadata?: channels.Metadata): Promise { + const response = await this._object.continue({ url: params.url, method: params.method, headers: params.headers, postData: params.postData ? Buffer.from(params.postData, 'base64') : undefined, + interceptResponse: params.interceptResponse }); + const result: channels.RouteContinueResult = {}; + if (response) { + result.response = { + request: RequestDispatcher.from(this._scope, response.request()), + status: response.status(), + statusText: response.statusText(), + headers: response.headers(), + }; + } + return result; } async fulfill(params: channels.RouteFulfillParams): Promise { diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 453ad17981..cab04c94cd 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -150,6 +150,16 @@ export type SerializedError = { value?: SerializedValue, }; +export type InterceptedResponse = { + request: RequestChannel, + status: number, + statusText: string, + headers: { + name: string, + value: string, + }[], +}; + // ----------- Playwright ----------- export type PlaywrightInitializer = { chromium: BrowserTypeChannel, @@ -2307,6 +2317,7 @@ export interface RouteChannel extends Channel { abort(params: RouteAbortParams, metadata?: Metadata): Promise; continue(params: RouteContinueParams, metadata?: Metadata): Promise; fulfill(params: RouteFulfillParams, metadata?: Metadata): Promise; + responseBody(params?: RouteResponseBodyParams, metadata?: Metadata): Promise; } export type RouteAbortParams = { errorCode?: string, @@ -2320,14 +2331,18 @@ export type RouteContinueParams = { method?: string, headers?: NameValue[], postData?: Binary, + interceptResponse?: boolean, }; export type RouteContinueOptions = { url?: string, method?: string, headers?: NameValue[], postData?: Binary, + interceptResponse?: boolean, +}; +export type RouteContinueResult = { + response?: InterceptedResponse, }; -export type RouteContinueResult = void; export type RouteFulfillParams = { status?: number, headers?: NameValue[], @@ -2341,6 +2356,11 @@ export type RouteFulfillOptions = { isBase64?: boolean, }; export type RouteFulfillResult = void; +export type RouteResponseBodyParams = {}; +export type RouteResponseBodyOptions = {}; +export type RouteResponseBodyResult = { + binary: Binary, +}; export type ResourceTiming = { startTime: number, diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index 5a7fdd3a28..acc5575651 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -220,6 +220,21 @@ SerializedError: value: SerializedValue? +InterceptedResponse: + type: object + properties: + request: Request + status: number + statusText: string + headers: + type: array + items: + type: object + properties: + name: string + value: string + + LaunchOptions: type: mixin properties: @@ -1868,6 +1883,9 @@ Route: type: array? items: NameValue postData: binary? + interceptResponse: boolean? + returns: + response: InterceptedResponse? fulfill: parameters: @@ -1879,6 +1897,10 @@ Route: body: string? isBase64: boolean? + responseBody: + returns: + binary: binary + ResourceTiming: type: object diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index 024ea5013f..679489b036 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -147,6 +147,15 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { })), value: tOptional(tType('SerializedValue')), }); + scheme.InterceptedResponse = tObject({ + request: tChannel('Request'), + status: tNumber, + statusText: tString, + headers: tArray(tObject({ + name: tString, + value: tString, + })), + }); scheme.PlaywrightSetForwardedPortsParams = tObject({ ports: tArray(tNumber), }); @@ -924,6 +933,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { method: tOptional(tString), headers: tOptional(tArray(tType('NameValue'))), postData: tOptional(tBinary), + interceptResponse: tOptional(tBoolean), }); scheme.RouteFulfillParams = tObject({ status: tOptional(tNumber), @@ -931,6 +941,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { body: tOptional(tString), isBase64: tOptional(tBoolean), }); + scheme.RouteResponseBodyParams = tOptional(tObject({})); scheme.ResourceTiming = tObject({ startTime: tNumber, domainLookupStart: tNumber, diff --git a/src/server/chromium/crNetworkManager.ts b/src/server/chromium/crNetworkManager.ts index 075b573ff5..8e702edba0 100644 --- a/src/server/chromium/crNetworkManager.ts +++ b/src/server/chromium/crNetworkManager.ts @@ -103,7 +103,7 @@ export class CRNetworkManager { this._client.send('Network.setCacheDisabled', { cacheDisabled: true }), this._client.send('Fetch.enable', { handleAuthRequests: true, - patterns: [{urlPattern: '*'}], + patterns: [{urlPattern: '*', requestStage: 'Request'}, {urlPattern: '*', requestStage: 'Response'}], }), ]); } else { @@ -177,6 +177,19 @@ export class CRNetworkManager { if (event.request.url.startsWith('data:')) return; + + if (event.responseStatusCode || event.responseHeaders || event.responseErrorReason) { + const request = this._requestIdToRequest.get(event.networkId); + if (!request || !request._onInterceptedResponse) { + this._client._sendMayFail('Fetch.continueRequest', { + requestId: event.requestId + }); + return; + } + request._onInterceptedResponse!(event); + return + } + const requestId = event.networkId; const requestWillBeSentEvent = this._requestIdToRequestWillBeSentEvent.get(requestId); if (requestWillBeSentEvent) { @@ -394,9 +407,10 @@ class InterceptableRequest implements network.RouteDelegate { _requestId: string; _interceptionId: string | null; _documentId: string | undefined; - private _client: CRSession; + private readonly _client: CRSession; _timestamp: number; _wallTime: number; + _onInterceptedResponse: ((event: Protocol.Fetch.requestPausedPayload) => void) | null = null; constructor(options: { client: CRSession; @@ -429,7 +443,13 @@ class InterceptableRequest implements network.RouteDelegate { this.request = new network.Request(allowInterception ? this : null, frame, redirectedFrom, documentId, url, type, method, postDataBuffer, headersObjectToArray(headers)); } - async continue(overrides: types.NormalizedContinueOverrides) { + async responseBody(): Promise { + const response = await this._client.send('Fetch.getResponseBody', { requestId: this._interceptionId! }); + return Buffer.from(response.body, response.base64Encoded ? 'base64' : 'utf8'); + } + + async continue(overrides: types.NormalizedContinueOverrides): Promise { + const interceptPromise = overrides.interceptResponse ? new Promise(resolve => this._onInterceptedResponse = resolve) : null; // In certain cases, protocol will return error if the request was already canceled // or the page was closed. We should tolerate these errors. await this._client._sendMayFail('Fetch.continueRequest', { @@ -439,6 +459,10 @@ class InterceptableRequest implements network.RouteDelegate { method: overrides.method, postData: overrides.postData ? overrides.postData.toString('base64') : undefined }); + if (!interceptPromise) + return null; + const event = await interceptPromise; + return new network.InterceptedResponse(this.request, event.responseStatusCode!, event.responseErrorReason!, event.responseHeaders!); } async fulfill(response: types.NormalizedFulfillResponse) { diff --git a/src/server/chromium/crPage.ts b/src/server/chromium/crPage.ts index 107537a1ed..aecbeb70af 100644 --- a/src/server/chromium/crPage.ts +++ b/src/server/chromium/crPage.ts @@ -204,7 +204,7 @@ export class CRPage implements PageDelegate { } async updateRequestInterception(): Promise { - await this._forAllFrameSessions(frame => frame._updateRequestInterception(false)); + await this._forAllFrameSessions(frame => frame._updateRequestInterception()); } async setFileChooserIntercepted(enabled: boolean) { @@ -521,7 +521,7 @@ class FrameSession { promises.push(emulateTimezone(this._client, options.timezoneId)); promises.push(this._updateGeolocation(true)); promises.push(this._updateExtraHTTPHeaders(true)); - promises.push(this._updateRequestInterception(true)); + promises.push(this._updateRequestInterception()); promises.push(this._updateOffline(true)); promises.push(this._updateHttpCredentials(true)); promises.push(this._updateEmulateMedia(true)); @@ -1007,7 +1007,7 @@ class FrameSession { await this._client.send('Emulation.setEmulatedMedia', { media: this._page._state.mediaType || '', features }); } - async _updateRequestInterception(initial: boolean): Promise { + async _updateRequestInterception(): Promise { await this._networkManager.setRequestInterception(this._page._needsRequestInterception()); } diff --git a/src/server/firefox/ffNetworkManager.ts b/src/server/firefox/ffNetworkManager.ts index 7c4c6721c8..5818256d4b 100644 --- a/src/server/firefox/ffNetworkManager.ts +++ b/src/server/firefox/ffNetworkManager.ts @@ -186,7 +186,11 @@ class InterceptableRequest implements network.RouteDelegate { payload.url, internalCauseToResourceType[payload.internalCause] || causeToResourceType[payload.cause] || 'other', payload.method, postDataBuffer, payload.headers); } - async continue(overrides: types.NormalizedContinueOverrides) { + responseBody(): Promise { + throw new Error('Method not implemented.'); + } + + async continue(overrides: types.NormalizedContinueOverrides): Promise { await this._session.sendMayFail('Network.resumeInterceptedRequest', { requestId: this._id, url: overrides.url, @@ -194,6 +198,9 @@ class InterceptableRequest implements network.RouteDelegate { headers: overrides.headers, postData: overrides.postData ? Buffer.from(overrides.postData).toString('base64') : undefined }); + if (overrides.interceptResponse) + throw new Error('Response interception not implemented'); + return null; } async fulfill(response: types.NormalizedFulfillResponse) { diff --git a/src/server/network.ts b/src/server/network.ts index 3b4fd46a0f..99cdf0662a 100644 --- a/src/server/network.ts +++ b/src/server/network.ts @@ -208,6 +208,7 @@ export class Route extends SdkObject { private readonly _request: Request; private readonly _delegate: RouteDelegate; private _handled = false; + private _response: InterceptedResponse | null = null; constructor(request: Request, delegate: RouteDelegate) { super(request.frame(), 'route'); @@ -225,26 +226,44 @@ export class Route extends SdkObject { await this._delegate.abort(errorCode); } - async fulfill(response: { status?: number, headers?: types.HeadersArray, body?: string, isBase64?: boolean }) { + async fulfill(overrides: { status?: number, headers?: types.HeadersArray, body?: string, isBase64?: boolean }) { assert(!this._handled, 'Route is already handled!'); this._handled = true; + let body = overrides.body; + let isBase64 = overrides.isBase64 || false; + if (!body) { + if (this._response) { + body = (await this._delegate.responseBody(true)).toString('utf8'); + isBase64 = false; + } else { + body = ''; + isBase64 = false; + } + } await this._delegate.fulfill({ - status: response.status === undefined ? 200 : response.status, - headers: response.headers || [], - body: response.body || '', - isBase64: response.isBase64 || false, + status: overrides.status || this._response?.status() || 200, + headers: overrides.headers || this._response?.headers() || [], + body, + isBase64, }); } - async continue(overrides: types.NormalizedContinueOverrides = {}) { + async continue(overrides: types.NormalizedContinueOverrides = {}): Promise { assert(!this._handled, 'Route is already handled!'); + assert(!this._response, 'Cannot call continue after response interception!'); if (overrides.url) { const newUrl = new URL(overrides.url); const oldUrl = new URL(this._request.url()); if (oldUrl.protocol !== newUrl.protocol) throw new Error('New URL must have same protocol as overridden URL'); } - await this._delegate.continue(overrides); + this._response = await this._delegate.continue(overrides); + return this._response + } + + async responseBody(): Promise { + assert(!this._handled, 'Route is already handled!'); + return this._delegate.responseBody(false); } } @@ -385,6 +404,37 @@ export class Response extends SdkObject { } } +export class InterceptedResponse extends SdkObject { + private readonly _request: Request; + private readonly _status: number; + private readonly _statusText: string; + private readonly _headers: types.HeadersArray; + + constructor(request: Request, status: number, statusText: string, headers: types.HeadersArray) { + super(request.frame(), 'interceptedResponse'); + this._request = request; + this._status = status; + this._statusText = statusText; + this._headers = headers; + } + + status(): number { + return this._status; + } + + statusText(): string { + return this._statusText; + } + + headers(): types.HeadersArray { + return this._headers; + } + + request(): Request { + return this._request; + } +} + export class WebSocket extends SdkObject { private _url: string; @@ -424,7 +474,8 @@ export class WebSocket extends SdkObject { export interface RouteDelegate { abort(errorCode: string): Promise; fulfill(response: types.NormalizedFulfillResponse): Promise; - continue(overrides: types.NormalizedContinueOverrides): Promise; + continue(overrides: types.NormalizedContinueOverrides): Promise; + responseBody(forFulfill: boolean): Promise; } // List taken from https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml with extra 306 and 418 codes. diff --git a/src/server/types.ts b/src/server/types.ts index 24e04d2e7e..fc5797f335 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -193,6 +193,15 @@ export type NormalizedContinueOverrides = { method?: string, headers?: HeadersArray, postData?: Buffer, + interceptResponse?: boolean, +}; + +export type NormalizedResponseContinueOverrides = { + status?: number, + statusText?: string, + headers?: HeadersArray, + body?: string, + isBase64?: boolean, }; export type NetworkCookie = { diff --git a/src/server/webkit/wkInterceptableRequest.ts b/src/server/webkit/wkInterceptableRequest.ts index bf3989b719..1bd773356e 100644 --- a/src/server/webkit/wkInterceptableRequest.ts +++ b/src/server/webkit/wkInterceptableRequest.ts @@ -21,6 +21,8 @@ import * as types from '../types'; import { Protocol } from './protocol'; import { WKSession } from './wkConnection'; import { assert, headersObjectToArray, headersArrayToObject } from '../../utils/utils'; +import { InterceptedResponse } from '../network'; +import { WKPage } from './wkPage'; const errorReasons: { [reason: string]: Protocol.Network.ResourceErrorType } = { 'aborted': 'Cancellation', @@ -45,6 +47,8 @@ export class WKInterceptableRequest implements network.RouteDelegate { readonly _requestId: string; _interceptedCallback: () => void = () => {}; private _interceptedPromise: Promise; + _responseInterceptedCallback: ((r: Protocol.Network.Response) => void) | undefined; + private _responseInterceptedPromise: Promise | undefined; readonly _allowInterception: boolean; _timestamp: number; _wallTime: number; @@ -64,6 +68,14 @@ export class WKInterceptableRequest implements network.RouteDelegate { this._interceptedPromise = new Promise(f => this._interceptedCallback = f); } + async responseBody(forFulfill: boolean): Promise { + // Empty buffer will result in the response being used. + if (forFulfill) + return Buffer.from(''); + const response = await this._session.send('Network.getResponseBody', { requestId: this._requestId }); + return Buffer.from(response.body, response.base64Encoded ? 'base64' : 'utf8'); + } + async abort(errorCode: string) { const errorType = errorReasons[errorCode]; assert(errorType, 'Unknown error code: ' + errorCode); @@ -86,7 +98,9 @@ export class WKInterceptableRequest implements network.RouteDelegate { const contentType = headers['content-type']; if (contentType) mimeType = contentType.split(';')[0].trim(); - await this._session.sendMayFail('Network.interceptRequestWithResponse', { + + const isResponseIntercepted = await this._responseInterceptedPromise; + await this._session.sendMayFail(isResponseIntercepted ? 'Network.interceptWithResponse' :'Network.interceptRequestWithResponse', { requestId: this._requestId, status: response.status, statusText: network.STATUS_TEXTS[String(response.status)], @@ -97,7 +111,11 @@ export class WKInterceptableRequest implements network.RouteDelegate { }); } - async continue(overrides: types.NormalizedContinueOverrides) { + async continue(overrides: types.NormalizedContinueOverrides): Promise { + if (overrides.interceptResponse) { + await (this.request.frame()._page._delegate as WKPage)._ensureResponseInterceptionEnabled(); + this._responseInterceptedPromise = new Promise(f => this._responseInterceptedCallback = f); + } await this._interceptedPromise; // In certain cases, protocol will return error if the request was already canceled // or the page was closed. We should tolerate these errors. @@ -108,6 +126,10 @@ export class WKInterceptableRequest implements network.RouteDelegate { headers: overrides.headers ? headersArrayToObject(overrides.headers, false /* lowerCase */) : undefined, postData: overrides.postData ? Buffer.from(overrides.postData).toString('base64') : undefined }); + if (!this._responseInterceptedPromise) + return null; + const responsePayload = await this._responseInterceptedPromise; + return new InterceptedResponse(this.request, responsePayload.status, responsePayload.statusText, headersObjectToArray(responsePayload.headers)); } createResponse(responsePayload: Protocol.Network.Response): network.Response { diff --git a/src/server/webkit/wkPage.ts b/src/server/webkit/wkPage.ts index 25193b63c4..16217357f5 100644 --- a/src/server/webkit/wkPage.ts +++ b/src/server/webkit/wkPage.ts @@ -70,6 +70,7 @@ export class WKPage implements PageDelegate { private _lastConsoleMessage: { derivedType: string, text: string, handles: JSHandle[]; count: number, location: types.ConsoleMessageLocation; } | null = null; private readonly _requestIdToResponseReceivedPayloadEvent = new Map(); + _needsResponseInterception: boolean = false; // Holds window features for the next popup being opened via window.open, // until the popup page proxy arrives. private _nextWindowOpenPopupFeatures?: string[]; @@ -176,6 +177,8 @@ export class WKPage implements PageDelegate { if (this._page._needsRequestInterception()) { promises.push(session.send('Network.setInterceptionEnabled', { enabled: true })); promises.push(session.send('Network.addInterception', { url: '.*', stage: 'request', isRegex: true })); + if (this._needsResponseInterception) + promises.push(session.send('Network.addInterception', { url: '.*', stage: 'response', isRegex: true })); } const contextOptions = this._browserContext._options; @@ -367,7 +370,8 @@ export class WKPage implements PageDelegate { helper.addEventListener(this._pageProxySession, 'Dialog.javascriptDialogOpening', event => this._onDialog(event)), helper.addEventListener(this._session, 'Page.fileChooserOpened', event => this._onFileChooserOpened(event)), helper.addEventListener(this._session, 'Network.requestWillBeSent', e => this._onRequestWillBeSent(this._session, e)), - helper.addEventListener(this._session, 'Network.requestIntercepted', e => this._onRequestIntercepted(e)), + helper.addEventListener(this._session, 'Network.requestIntercepted', e => this._onRequestIntercepted(this._session, e)), + helper.addEventListener(this._session, 'Network.responseIntercepted', e => this._onResponseIntercepted(this._session, e)), helper.addEventListener(this._session, 'Network.responseReceived', e => this._onResponseReceived(e)), helper.addEventListener(this._session, 'Network.loadingFinished', e => this._onLoadingFinished(e)), helper.addEventListener(this._session, 'Network.loadingFailed', e => this._onLoadingFailed(e)), @@ -380,7 +384,6 @@ export class WKPage implements PageDelegate { helper.addEventListener(this._session, 'Network.webSocketFrameError', e => this._page._frameManager.webSocketError(e.requestId, e.errorMessage)), ]; } - private async _updateState( method: T, params?: Protocol.CommandParameters[T] @@ -656,12 +659,22 @@ export class WKPage implements PageDelegate { await Promise.all(promises); } + async _ensureResponseInterceptionEnabled() { + if (this._needsResponseInterception) + return; + this._needsResponseInterception = true; + await this.updateRequestInterception(); + } + async updateRequestInterception(): Promise { const enabled = this._page._needsRequestInterception(); - await Promise.all([ + const promises = [ this._updateState('Network.setInterceptionEnabled', { enabled }), - this._updateState('Network.addInterception', { url: '.*', stage: 'request', isRegex: true }) - ]); + this._updateState('Network.addInterception', { url: '.*', stage: 'request', isRegex: true }), + ]; + if (this._needsResponseInterception) + this._updateState('Network.addInterception', { url: '.*', stage: 'response', isRegex: true }) + await Promise.all(promises); } async updateOffline() { @@ -962,21 +975,30 @@ export class WKPage implements PageDelegate { this._page._frameManager.requestFinished(request.request); } - _onRequestIntercepted(event: Protocol.Network.requestInterceptedPayload) { + _onRequestIntercepted(session: WKSession, event: Protocol.Network.requestInterceptedPayload) { const request = this._requestIdToRequest.get(event.requestId); if (!request) { - this._session.sendMayFail('Network.interceptRequestWithError', {errorType: 'Cancellation', requestId: event.requestId}); + session.sendMayFail('Network.interceptRequestWithError', {errorType: 'Cancellation', requestId: event.requestId}); return; } if (!request._allowInterception) { // Intercepted, although we do not intend to allow interception. // Just continue. - this._session.sendMayFail('Network.interceptWithRequest', { requestId: request._requestId }); + session.sendMayFail('Network.interceptWithRequest', { requestId: request._requestId }); } else { request._interceptedCallback(); } } + _onResponseIntercepted(session: WKSession, event: Protocol.Network.responseInterceptedPayload) { + const request = this._requestIdToRequest.get(event.requestId); + if (!request || !request._responseInterceptedCallback) { + session.sendMayFail('Network.interceptContinue', { requestId: event.requestId, stage: 'response' }); + return; + } + request._responseInterceptedCallback(event.response); + } + _onResponseReceived(event: Protocol.Network.responseReceivedPayload) { const request = this._requestIdToRequest.get(event.requestId); // FileUpload sends a response without a matching request. diff --git a/src/server/webkit/wkProvisionalPage.ts b/src/server/webkit/wkProvisionalPage.ts index e0747e9c27..db184cd2ff 100644 --- a/src/server/webkit/wkProvisionalPage.ts +++ b/src/server/webkit/wkProvisionalPage.ts @@ -43,7 +43,8 @@ export class WKProvisionalPage { this._sessionListeners = [ helper.addEventListener(session, 'Network.requestWillBeSent', overrideFrameId(e => wkPage._onRequestWillBeSent(session, e))), - helper.addEventListener(session, 'Network.requestIntercepted', overrideFrameId(e => wkPage._onRequestIntercepted(e))), + helper.addEventListener(session, 'Network.requestIntercepted', overrideFrameId(e => wkPage._onRequestIntercepted(session, e))), + helper.addEventListener(session, 'Network.responseIntercepted', overrideFrameId(e => wkPage._onResponseIntercepted(session, e))), helper.addEventListener(session, 'Network.responseReceived', overrideFrameId(e => wkPage._onResponseReceived(e))), helper.addEventListener(session, 'Network.loadingFinished', overrideFrameId(e => wkPage._onLoadingFinished(e))), helper.addEventListener(session, 'Network.loadingFailed', overrideFrameId(e => wkPage._onLoadingFailed(e))), diff --git a/tests/config/checkCoverage.js b/tests/config/checkCoverage.js index c13883d74c..fad3c0edb9 100644 --- a/tests/config/checkCoverage.js +++ b/tests/config/checkCoverage.js @@ -52,6 +52,10 @@ if (browserName !== 'chromium') { if (browserName === 'webkit') api.delete('browserContext.clearPermissions'); +// Response interception is not implemented in Firefox yet. +if (browserName === 'firefox') + api.delete('route.intercept'); + const coverageDir = path.join(__dirname, '..', 'coverage-report'); const coveredMethods = new Set(); diff --git a/tests/page/page-request-intercept.spec.ts b/tests/page/page-request-intercept.spec.ts new file mode 100644 index 0000000000..78e2ee2600 --- /dev/null +++ b/tests/page/page-request-intercept.spec.ts @@ -0,0 +1,154 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * 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 { fail } from 'assert'; +import type { Route } from '../../index'; + import { test as it, expect } from './pageTest'; + +it('should fulfill intercepted response', async ({page, server, browserName}) => { + it.fixme(browserName === 'firefox'); + await page.route('**/*', async route => { + await route.intercept({}); + await route.fulfill({ + status: 201, + headers: { + foo: 'bar' + }, + contentType: 'text/plain', + body: 'Yo, page!' + }); + }); + const response = await page.goto(server.PREFIX + '/empty.html'); + expect(response.status()).toBe(201); + expect(response.headers().foo).toBe('bar'); + expect(response.headers()['content-type']).toBe('text/plain'); + expect(await page.evaluate(() => document.body.textContent)).toBe('Yo, page!'); +}); + +it('should throw on continue after intercept', async ({page, server, browserName}) => { + it.fixme(browserName === 'firefox'); + + let routeCallback; + const routePromise = new Promise(f => routeCallback = f); + await page.route('**', routeCallback); + + page.goto(server.EMPTY_PAGE).catch(e => {}); + const route = await routePromise; + await route.intercept(); + try { + await route.continue(); + fail('did not throw'); + } catch (e) { + expect(e.message).toContain('Cannot call continue after response interception!') + } +}); + +it('should support fulfill after intercept', async ({page, server, browserName}) => { + it.fixme(browserName === 'firefox'); + const requestPromise = server.waitForRequest('/empty.html'); + await page.route('**', async route => { + await route.intercept(); + await route.fulfill(); + }); + await page.goto(server.EMPTY_PAGE); + const request = await requestPromise; + expect(request.url).toBe('/empty.html'); +}); + + +it('should support request overrides', async ({page, server, browserName}) => { + it.fixme(browserName === 'firefox'); + const requestPromise = server.waitForRequest('/empty.html'); + await page.route('**/foo', async route => { + await route.intercept({ + url: server.EMPTY_PAGE, + method: 'POST', + headers: {'foo': 'bar'}, + postData: 'my data', + }); + await route.fulfill(); + }); + await page.goto(server.PREFIX + '/foo'); + const request = await requestPromise; + expect(request.method).toBe('POST'); + expect(request.url).toBe('/empty.html'); + expect(request.headers['foo']).toBe('bar'); + expect((await request.postBody).toString('utf8')).toBe('my data'); +}); + +it('should give access to the intercepted response', async ({page, server, browserName}) => { + it.fixme(browserName === 'firefox'); + // it.fixme(browserName === 'webkit'); + + await page.goto(server.EMPTY_PAGE); + + let routeCallback; + const routePromise = new Promise(f => routeCallback = f); + await page.route('**/title.html', routeCallback); + + const evalPromise = page.evaluate(url => fetch(url), server.PREFIX + '/title.html').catch(console.log); + + const route = await routePromise; + const response = await route.intercept(); + + expect(response.status()).toBe(200); + expect(response.ok()).toBeTruthy(); + expect(response.url()).toBe(server.PREFIX + '/title.html'); + expect(response.headers()['content-type']).toBe('text/html; charset=utf-8'); + + await Promise.all([route.fulfill(), evalPromise]); +}); + +it('should give access to the intercepted response body', async ({page, server, browserName}) => { + it.fixme(browserName === 'firefox'); + it.fixme(browserName === 'webkit'); + + await page.goto(server.EMPTY_PAGE); + + let routeCallback; + const routePromise = new Promise(f => routeCallback = f); + await page.route('**/simple.json', routeCallback); + + const evalPromise = page.evaluate(url => fetch(url), server.PREFIX + '/simple.json').catch(console.log); + + const route = await routePromise; + const response = await route.intercept(); + + expect((await response.text())).toBe('{"foo": "bar"}\n'); + + await Promise.all([route.fulfill(), evalPromise]); +}); + +it('should be abortable after interception', async ({page, server, browserName}) => { + it.fixme(browserName === 'firefox'); + it.fixme(browserName === 'webkit'); + + await page.route(/\.css$/, async route => { + await route.intercept(); + await route.abort(); + }); + let failed = false; + page.on('requestfailed', request => { + if (request.url().includes('.css')) + failed = true; + }); + const response = await page.goto(server.PREFIX + '/one-style.html'); + expect(response.ok()).toBe(true); + expect(response.request().failure()).toBe(null); + expect(failed).toBe(true); +}); + diff --git a/types/types.d.ts b/types/types.d.ts index ec994ca77e..5cd0f36320 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -10546,6 +10546,32 @@ export interface Route { status?: number; }): Promise; + /** + * Continues route's request with optional overrides and intercepts response. + * @param options + */ + intercept(options?: { + /** + * If set changes the request HTTP headers. Header values will be converted to a string. + */ + headers?: { [key: string]: string; }; + + /** + * If set changes the request method (e.g. GET or POST) + */ + method?: string; + + /** + * If set changes the post data of request + */ + postData?: string|Buffer; + + /** + * If set changes the request URL. New URL must have same protocol as original one. + */ + url?: string; + }): Promise; + /** * A request to be routed. */