From 28fb3c776ac29b60ee5f6dc23be4754d56f121a2 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 5 Aug 2021 08:49:02 -0700 Subject: [PATCH] feat: response interception after redirects in chromium (#7910) --- src/server/chromium/crNetworkManager.ts | 79 ++++++---- src/server/firefox/ffNetworkManager.ts | 39 +++-- src/server/frames.ts | 8 +- src/server/network.ts | 17 +- src/server/page.ts | 6 +- src/server/webkit/wkInterceptableRequest.ts | 166 +++++++++++--------- src/server/webkit/wkPage.ts | 25 +-- tests/page/page-request-intercept.spec.ts | 74 ++++++++- 8 files changed, 265 insertions(+), 149 deletions(-) diff --git a/src/server/chromium/crNetworkManager.ts b/src/server/chromium/crNetworkManager.ts index 545516bd43..08657f2376 100644 --- a/src/server/chromium/crNetworkManager.ts +++ b/src/server/chromium/crNetworkManager.ts @@ -178,16 +178,17 @@ 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) { + if (event.responseStatusCode || event.responseErrorReason) { + const isRedirect = event.responseStatusCode && event.responseStatusCode >= 300 && event.responseStatusCode < 400; + const request = this._requestIdToRequest.get(event.networkId!); + const route = request?._routeForRedirectChain(); + if (isRedirect || !route || !route._interceptingResponse) { this._client._sendMayFail('Fetch.continueRequest', { requestId: event.requestId }); return; } - request._onInterceptedResponse!(event); + route._responseInterceptedCallback(event); return; } @@ -204,13 +205,13 @@ export class CRNetworkManager { _onRequest(workerFrame: frames.Frame | undefined, requestWillBeSentEvent: Protocol.Network.requestWillBeSentPayload, requestPausedEvent: Protocol.Fetch.requestPausedPayload | null) { if (requestWillBeSentEvent.request.url.startsWith('data:')) return; - let redirectedFrom: network.Request | null = null; + let redirectedFrom: InterceptableRequest | null = null; if (requestWillBeSentEvent.redirectResponse) { const request = this._requestIdToRequest.get(requestWillBeSentEvent.requestId); // If we connect late to the target, we could have missed the requestWillBeSent event. if (request) { this._handleRequestRedirect(request, requestWillBeSentEvent.redirectResponse, requestWillBeSentEvent.timestamp); - redirectedFrom = request.request; + redirectedFrom = request; } } let frame = requestWillBeSentEvent.frameId ? this._page._frameManager.frame(requestWillBeSentEvent.frameId) : workerFrame; @@ -258,26 +259,26 @@ export class CRNetworkManager { return; } - let allowInterception = this._userRequestInterceptionEnabled; - if (redirectedFrom) { - allowInterception = false; + let route = null; + if (requestPausedEvent) { // We do not support intercepting redirects. - if (requestPausedEvent) + if (redirectedFrom) this._client._sendMayFail('Fetch.continueRequest', { requestId: requestPausedEvent.requestId }); + else + route = new RouteImpl(this._client, requestPausedEvent.requestId); } const isNavigationRequest = requestWillBeSentEvent.requestId === requestWillBeSentEvent.loaderId && requestWillBeSentEvent.type === 'Document'; const documentId = isNavigationRequest ? requestWillBeSentEvent.loaderId : undefined; const request = new InterceptableRequest({ - client: this._client, frame, documentId, - allowInterception, + route, requestWillBeSentEvent, requestPausedEvent, redirectedFrom }); this._requestIdToRequest.set(requestWillBeSentEvent.requestId, request); - this._page._frameManager.requestStarted(request.request); + this._page._frameManager.requestStarted(request.request, route || undefined); } _createResponse(request: InterceptableRequest, responsePayload: Protocol.Network.Response): network.Response { @@ -404,32 +405,32 @@ export class CRNetworkManager { } } -class InterceptableRequest implements network.RouteDelegate { +class InterceptableRequest { readonly request: network.Request; _requestId: string; _interceptionId: string | null; _documentId: string | undefined; - private readonly _client: CRSession; _timestamp: number; _wallTime: number; - _onInterceptedResponse: ((event: Protocol.Fetch.requestPausedPayload) => void) | null = null; + private _route: RouteImpl | null; + private _redirectedFrom: InterceptableRequest | null; constructor(options: { - client: CRSession; frame: frames.Frame; documentId?: string; - allowInterception: boolean; + route: RouteImpl | null; requestWillBeSentEvent: Protocol.Network.requestWillBeSentPayload; requestPausedEvent: Protocol.Fetch.requestPausedPayload | null; - redirectedFrom: network.Request | null; + redirectedFrom: InterceptableRequest | null; }) { - const { client, frame, documentId, allowInterception, requestWillBeSentEvent, requestPausedEvent, redirectedFrom } = options; - this._client = client; + const { frame, documentId, route, requestWillBeSentEvent, requestPausedEvent, redirectedFrom } = options; this._timestamp = requestWillBeSentEvent.timestamp; this._wallTime = requestWillBeSentEvent.wallTime; this._requestId = requestWillBeSentEvent.requestId; this._interceptionId = requestPausedEvent && requestPausedEvent.requestId; this._documentId = documentId; + this._route = route; + this._redirectedFrom = redirectedFrom; const { headers, @@ -442,7 +443,28 @@ class InterceptableRequest implements network.RouteDelegate { if (postDataEntries && postDataEntries.length && postDataEntries[0].bytes) postDataBuffer = Buffer.from(postDataEntries[0].bytes, 'base64'); - this.request = new network.Request(allowInterception ? this : null, frame, redirectedFrom, documentId, url, type, method, postDataBuffer, headersObjectToArray(headers)); + this.request = new network.Request(frame, redirectedFrom?.request || null, documentId, url, type, method, postDataBuffer, headersObjectToArray(headers)); + } + + _routeForRedirectChain(): RouteImpl | null { + let request: InterceptableRequest = this; + while (request._redirectedFrom) + request = request._redirectedFrom; + return request._route; + } +} + +class RouteImpl implements network.RouteDelegate { + private readonly _client: CRSession; + private _interceptionId: string; + private _responseInterceptedPromise: Promise; + _responseInterceptedCallback: ((event: Protocol.Fetch.requestPausedPayload) => void) = () => {}; + _interceptingResponse: boolean = false; + + constructor(client: CRSession, interceptionId: string) { + this._client = client; + this._interceptionId = interceptionId; + this._responseInterceptedPromise = new Promise(resolve => this._responseInterceptedCallback = resolve); } async responseBody(): Promise { @@ -450,8 +472,8 @@ class InterceptableRequest implements network.RouteDelegate { 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; + async continue(request: network.Request, overrides: types.NormalizedContinueOverrides): Promise { + this._interceptingResponse = !!overrides.interceptResponse; // 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', { @@ -461,10 +483,11 @@ class InterceptableRequest implements network.RouteDelegate { method: overrides.method, postData: overrides.postData ? overrides.postData.toString('base64') : undefined }); - if (!interceptPromise) + if (!this._interceptingResponse) return null; - const event = await interceptPromise; - return new network.InterceptedResponse(this.request, event.responseStatusCode!, event.responseErrorReason!, event.responseHeaders!); + const event = await this._responseInterceptedPromise; + this._interceptionId = event.requestId; + return new network.InterceptedResponse(request, event.responseStatusCode!, event.responseErrorReason!, event.responseHeaders!); } async fulfill(response: types.NormalizedFulfillResponse) { diff --git a/src/server/firefox/ffNetworkManager.ts b/src/server/firefox/ffNetworkManager.ts index d1781b9a9b..265c902b2c 100644 --- a/src/server/firefox/ffNetworkManager.ts +++ b/src/server/firefox/ffNetworkManager.ts @@ -60,9 +60,12 @@ export class FFNetworkManager { return; if (redirectedFrom) this._requests.delete(redirectedFrom._id); - const request = new InterceptableRequest(this._session, frame, redirectedFrom, event); + const request = new InterceptableRequest(frame, redirectedFrom, event); + let route; + if (event.isIntercepted) + route = new FFRouteImpl(this._session, request); this._requests.set(request._id, request); - this._page._frameManager.requestStarted(request.request); + this._page._frameManager.requestStarted(request.request, route); } _onResponseReceived(event: Protocol.Network.responseReceivedPayload) { @@ -173,34 +176,42 @@ const internalCauseToResourceType: {[key: string]: string} = { TYPE_INTERNAL_EVENTSOURCE: 'eventsource', }; -class InterceptableRequest implements network.RouteDelegate { +class InterceptableRequest { readonly request: network.Request; - _id: string; - private _session: FFSession; + readonly _id: string; - constructor(session: FFSession, frame: frames.Frame, redirectedFrom: InterceptableRequest | null, payload: Protocol.Network.requestWillBeSentPayload) { + constructor(frame: frames.Frame, redirectedFrom: InterceptableRequest | null, payload: Protocol.Network.requestWillBeSentPayload) { this._id = payload.requestId; - this._session = session; let postDataBuffer = null; if (payload.postData) postDataBuffer = Buffer.from(payload.postData, 'base64'); - this.request = new network.Request(payload.isIntercepted ? this : null, frame, redirectedFrom ? redirectedFrom.request : null, payload.navigationId, + this.request = new network.Request(frame, redirectedFrom ? redirectedFrom.request : null, payload.navigationId, payload.url, internalCauseToResourceType[payload.internalCause] || causeToResourceType[payload.cause] || 'other', payload.method, postDataBuffer, payload.headers); } +} + +class FFRouteImpl implements network.RouteDelegate { + private _request: InterceptableRequest; + private _session: FFSession; + + constructor(session: FFSession, request: InterceptableRequest) { + this._session = session; + this._request = request; + } 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._id + requestId: this._request._id }); return Buffer.from(response.base64body, 'base64'); } - async continue(overrides: types.NormalizedContinueOverrides): Promise { + async continue(request: network.Request, overrides: types.NormalizedContinueOverrides): Promise { const result = await this._session.sendMayFail('Network.resumeInterceptedRequest', { - requestId: this._id, + requestId: this._request._id, url: overrides.url, method: overrides.method, headers: overrides.headers, @@ -209,14 +220,14 @@ class InterceptableRequest implements network.RouteDelegate { }) as any; if (!overrides.interceptResponse) return null; - return new InterceptedResponse(this.request, result.response.status, result.response.statusText, result.response.headers); + return new InterceptedResponse(request, result.response.status, result.response.statusText, result.response.headers); } async fulfill(response: types.NormalizedFulfillResponse) { const base64body = response.isBase64 ? response.body : Buffer.from(response.body).toString('base64'); await this._session.sendMayFail('Network.fulfillInterceptedRequest', { - requestId: this._id, + requestId: this._request._id, status: response.status, statusText: network.STATUS_TEXTS[String(response.status)] || '', headers: response.headers, @@ -226,7 +237,7 @@ class InterceptableRequest implements network.RouteDelegate { async abort(errorCode: string) { await this._session.sendMayFail('Network.abortInterceptedRequest', { - requestId: this._id, + requestId: this._request._id, errorCode, }); } diff --git a/src/server/frames.ts b/src/server/frames.ts index fd317017db..07c09effcd 100644 --- a/src/server/frames.ts +++ b/src/server/frames.ts @@ -254,19 +254,19 @@ export class FrameManager { frame._onLifecycleEvent(event); } - requestStarted(request: network.Request) { + requestStarted(request: network.Request, route?: network.RouteDelegate) { const frame = request.frame(); this._inflightRequestStarted(request); if (request._documentId) frame.setPendingDocument({ documentId: request._documentId, request }); if (request._isFavicon) { - const route = request._route(); if (route) - route.continue(); + route.continue(request, {}); return; } this._page._browserContext.emit(BrowserContext.Events.Request, request); - this._page._requestStarted(request); + if (route) + this._page._requestStarted(request, route); } requestReceivedResponse(response: network.Response) { diff --git a/src/server/network.ts b/src/server/network.ts index 637997c35a..26a43dc9e3 100644 --- a/src/server/network.ts +++ b/src/server/network.ts @@ -79,7 +79,6 @@ export function stripFragmentFromUrl(url: string): string { } export class Request extends SdkObject { - readonly _routeDelegate: RouteDelegate | null; private _response: Response | null = null; private _redirectedFrom: Request | null; private _redirectedTo: Request | null = null; @@ -97,12 +96,10 @@ export class Request extends SdkObject { private _waitForResponsePromiseCallback: (value: Response | null) => void = () => {}; _responseEndTiming = -1; - constructor(routeDelegate: RouteDelegate | null, frame: frames.Frame, redirectedFrom: Request | null, documentId: string | undefined, + constructor(frame: frames.Frame, redirectedFrom: Request | null, documentId: string | undefined, url: string, resourceType: string, method: string, postData: Buffer | null, headers: types.HeadersArray) { super(frame, 'request'); assert(!url.startsWith('data:'), 'Data urls should not fire requests'); - assert(!(routeDelegate && redirectedFrom), 'Should not be able to intercept redirects'); - this._routeDelegate = routeDelegate; this._frame = frame; this._redirectedFrom = redirectedFrom; if (redirectedFrom) @@ -185,12 +182,6 @@ export class Request extends SdkObject { }; } - _route(): Route | null { - if (!this._routeDelegate) - return null; - return new Route(this, this._routeDelegate); - } - updateWithRawHeaders(headers: types.HeadersArray) { this._headers = headers; this._headersMap.clear(); @@ -257,7 +248,7 @@ export class Route extends SdkObject { if (oldUrl.protocol !== newUrl.protocol) throw new Error('New URL must have same protocol as overridden URL'); } - this._response = await this._delegate.continue(overrides); + this._response = await this._delegate.continue(this._request, overrides); return this._response; } @@ -420,7 +411,7 @@ export class InterceptedResponse extends SdkObject { constructor(request: Request, status: number, statusText: string, headers: types.HeadersArray) { super(request.frame(), 'interceptedResponse'); - this._request = request; + this._request = request._finalRequest(); this._status = status; this._statusText = statusText; this._headers = headers; @@ -482,7 +473,7 @@ export class WebSocket extends SdkObject { export interface RouteDelegate { abort(errorCode: string): Promise; fulfill(response: types.NormalizedFulfillResponse): Promise; - continue(overrides: types.NormalizedContinueOverrides): Promise; + continue(request: Request, overrides: types.NormalizedContinueOverrides): Promise; responseBody(forFulfill: boolean): Promise; } diff --git a/src/server/page.ts b/src/server/page.ts index ee4fb9a9d4..35940bb794 100644 --- a/src/server/page.ts +++ b/src/server/page.ts @@ -405,10 +405,8 @@ export class Page extends SdkObject { await this._delegate.updateRequestInterception(); } - _requestStarted(request: network.Request) { - const route = request._route(); - if (!route) - return; + _requestStarted(request: network.Request, routeDelegate: network.RouteDelegate) { + const route = new network.Route(request, routeDelegate); if (this._serverRequestInterceptor) { this._serverRequestInterceptor(route, request); return; diff --git a/src/server/webkit/wkInterceptableRequest.ts b/src/server/webkit/wkInterceptableRequest.ts index 5d7d4ce644..7a3496c441 100644 --- a/src/server/webkit/wkInterceptableRequest.ts +++ b/src/server/webkit/wkInterceptableRequest.ts @@ -41,95 +41,35 @@ const errorReasons: { [reason: string]: Protocol.Network.ResourceErrorType } = { 'failed': 'General', }; -export class WKInterceptableRequest implements network.RouteDelegate { +export class WKInterceptableRequest { private readonly _session: WKSession; readonly request: network.Request; 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; + readonly _route: WKRouteImpl | null; + private _redirectedFrom: WKInterceptableRequest | null; - constructor(session: WKSession, allowInterception: boolean, frame: frames.Frame, event: Protocol.Network.requestWillBeSentPayload, redirectedFrom: network.Request | null, documentId: string | undefined) { + constructor(session: WKSession, route: WKRouteImpl | null, frame: frames.Frame, event: Protocol.Network.requestWillBeSentPayload, redirectedFrom: WKInterceptableRequest | null, documentId: string | undefined) { this._session = session; this._requestId = event.requestId; - this._allowInterception = allowInterception; - const resourceType = event.type ? event.type.toLowerCase() : (redirectedFrom ? redirectedFrom.resourceType() : 'other'); + this._route = route; + this._redirectedFrom = redirectedFrom; + const resourceType = event.type ? event.type.toLowerCase() : (redirectedFrom ? redirectedFrom.request.resourceType() : 'other'); let postDataBuffer = null; this._timestamp = event.timestamp; this._wallTime = event.walltime * 1000; if (event.request.postData) postDataBuffer = Buffer.from(event.request.postData, 'base64'); - this.request = new network.Request(allowInterception ? this : null, frame, redirectedFrom, documentId, event.request.url, + this.request = new network.Request(frame, redirectedFrom?.request || null, documentId, event.request.url, resourceType, event.request.method, postDataBuffer, headersObjectToArray(event.request.headers)); - 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.getInterceptedResponseBody', { requestId: this._requestId }); - return Buffer.from(response.body, 'base64'); - } - - async abort(errorCode: string) { - const errorType = errorReasons[errorCode]; - assert(errorType, 'Unknown error code: ' + errorCode); - 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. - await this._session.sendMayFail('Network.interceptRequestWithError', { requestId: this._requestId, errorType }); - } - - async fulfill(response: types.NormalizedFulfillResponse) { - if (300 <= response.status && response.status < 400) - throw new Error('Cannot fulfill with redirect status: ' + response.status); - - 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. - let mimeType = response.isBase64 ? 'application/octet-stream' : 'text/plain'; - const headers = headersArrayToObject(response.headers, false /* lowerCase */); - const contentType = headers['content-type']; - if (contentType) - mimeType = contentType.split(';')[0].trim(); - - 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)], - mimeType, - headers, - base64Encoded: response.isBase64, - content: response.body - }); - } - - 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. - await this._session.sendMayFail('Network.interceptWithRequest', { - requestId: this._requestId, - url: overrides.url, - method: overrides.method, - 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)); + _routeForRedirectChain(): WKRouteImpl | null { + let request: WKInterceptableRequest = this; + while (request._redirectedFrom) + request = request._redirectedFrom; + return request._route; } createResponse(responsePayload: Protocol.Network.Response): network.Response { @@ -152,6 +92,86 @@ export class WKInterceptableRequest implements network.RouteDelegate { } } +export class WKRouteImpl implements network.RouteDelegate { + private readonly _session: WKSession; + private readonly _requestId: string; + _requestInterceptedCallback: () => void = () => {}; + private readonly _requestInterceptedPromise: Promise; + _responseInterceptedCallback: ((responsePayload: Protocol.Network.Response) => void) | undefined; + private _responseInterceptedPromise: Promise | undefined; + private readonly _page: WKPage; + + constructor(session: WKSession, page: WKPage, requestId: string) { + this._session = session; + this._page = page; + this._requestId = requestId; + this._requestInterceptedPromise = new Promise(f => this._requestInterceptedCallback = 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.getInterceptedResponseBody', { requestId: this._requestId }); + return Buffer.from(response.body, 'base64'); + } + + async abort(errorCode: string) { + const errorType = errorReasons[errorCode]; + assert(errorType, 'Unknown error code: ' + errorCode); + await this._requestInterceptedPromise; + // 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._session.sendMayFail('Network.interceptRequestWithError', { requestId: this._requestId, errorType }); + } + + async fulfill(response: types.NormalizedFulfillResponse) { + if (300 <= response.status && response.status < 400) + throw new Error('Cannot fulfill with redirect status: ' + response.status); + + await this._requestInterceptedPromise; + // In certain cases, protocol will return error if the request was already canceled + // or the page was closed. We should tolerate these errors. + let mimeType = response.isBase64 ? 'application/octet-stream' : 'text/plain'; + const headers = headersArrayToObject(response.headers, false /* lowerCase */); + const contentType = headers['content-type']; + if (contentType) + mimeType = contentType.split(';')[0].trim(); + + 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)], + mimeType, + headers, + base64Encoded: response.isBase64, + content: response.body + }); + } + + async continue(request: network.Request, overrides: types.NormalizedContinueOverrides): Promise { + if (overrides.interceptResponse) { + await this._page._ensureResponseInterceptionEnabled(); + this._responseInterceptedPromise = new Promise(f => this._responseInterceptedCallback = f); + } + await this._requestInterceptedPromise; + // 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._session.sendMayFail('Network.interceptWithRequest', { + requestId: this._requestId, + url: overrides.url, + method: overrides.method, + 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(request, responsePayload.status, responsePayload.statusText, headersObjectToArray(responsePayload.headers)); + } +} + function wkMillisToRoundishMillis(value: number): number { // WebKit uses -1000 for unavailable. if (value === -1000) diff --git a/src/server/webkit/wkPage.ts b/src/server/webkit/wkPage.ts index 4dbf312d7b..b682419b14 100644 --- a/src/server/webkit/wkPage.ts +++ b/src/server/webkit/wkPage.ts @@ -37,7 +37,7 @@ import { WKBrowserContext } from './wkBrowser'; import { WKSession } from './wkConnection'; import { WKExecutionContext } from './wkExecutionContext'; import { RawKeyboardImpl, RawMouseImpl, RawTouchscreenImpl } from './wkInput'; -import { WKInterceptableRequest } from './wkInterceptableRequest'; +import { WKInterceptableRequest, WKRouteImpl } from './wkInterceptableRequest'; import { WKProvisionalPage } from './wkProvisionalPage'; import { WKWorkers } from './wkWorkers'; import { debugLogger } from '../../utils/debugLogger'; @@ -949,16 +949,16 @@ export class WKPage implements PageDelegate { _onRequestWillBeSent(session: WKSession, event: Protocol.Network.requestWillBeSentPayload) { if (event.request.url.startsWith('data:')) return; - let redirectedFrom: network.Request | null = null; + let redirectedFrom: WKInterceptableRequest | null = null; if (event.redirectResponse) { const request = this._requestIdToRequest.get(event.requestId); // If we connect late to the target, we could have missed the requestWillBeSent event. if (request) { this._handleRequestRedirect(request, event.redirectResponse, event.timestamp); - redirectedFrom = request.request; + redirectedFrom = request; } } - const frame = redirectedFrom ? redirectedFrom.frame() : this._page._frameManager.frame(event.frameId); + const frame = redirectedFrom ? redirectedFrom.request.frame() : this._page._frameManager.frame(event.frameId); // sometimes we get stray network events for detached frames // TODO(einbinder) why? if (!frame) @@ -967,11 +967,13 @@ export class WKPage implements PageDelegate { // TODO(einbinder) this will fail if we are an XHR document request const isNavigationRequest = event.type === 'Document'; const documentId = isNavigationRequest ? event.loaderId : undefined; + let route = null; // We do not support intercepting redirects. - const allowInterception = this._page._needsRequestInterception() && !redirectedFrom; - const request = new WKInterceptableRequest(session, allowInterception, frame, event, redirectedFrom, documentId); + if (this._page._needsRequestInterception() && !redirectedFrom) + route = new WKRouteImpl(session, this, event.requestId); + const request = new WKInterceptableRequest(session, route, frame, event, redirectedFrom, documentId); this._requestIdToRequest.set(event.requestId, request); - this._page._frameManager.requestStarted(request.request); + this._page._frameManager.requestStarted(request.request, route || undefined); } private _handleRequestRedirect(request: WKInterceptableRequest, responsePayload: Protocol.Network.Response, timestamp: number) { @@ -990,22 +992,23 @@ export class WKPage implements PageDelegate { session.sendMayFail('Network.interceptRequestWithError', {errorType: 'Cancellation', requestId: event.requestId}); return; } - if (!request._allowInterception) { + if (!request._route) { // Intercepted, although we do not intend to allow interception. // Just continue. session.sendMayFail('Network.interceptWithRequest', { requestId: request._requestId }); } else { - request._interceptedCallback(); + request._route._requestInterceptedCallback(); } } _onResponseIntercepted(session: WKSession, event: Protocol.Network.responseInterceptedPayload) { const request = this._requestIdToRequest.get(event.requestId); - if (!request || !request._responseInterceptedCallback) { + const route = request?._routeForRedirectChain(); + if (!route?._responseInterceptedCallback) { session.sendMayFail('Network.interceptContinue', { requestId: event.requestId, stage: 'response' }); return; } - request._responseInterceptedCallback(event.response); + route._responseInterceptedCallback(event.response); } _onResponseReceived(event: Protocol.Network.responseReceivedPayload) { diff --git a/tests/page/page-request-intercept.spec.ts b/tests/page/page-request-intercept.spec.ts index de43d0c8d6..47a06bc664 100644 --- a/tests/page/page-request-intercept.spec.ts +++ b/tests/page/page-request-intercept.spec.ts @@ -150,8 +150,8 @@ it('should be abortable after interception', async ({page, server, browserName}) expect(failed).toBe(true); }); -it('should fulfill after redirects', async ({page, server}) => { - it.fixme(); +it('should fulfill after redirects', async ({page, server, browserName}) => { + it.fixme(browserName !== 'chromium'); server.setRedirect('/redirect/1.html', '/redirect/2.html'); server.setRedirect('/redirect/2.html', '/empty.html'); const expectedUrls = ['/redirect/1.html', '/redirect/2.html', '/empty.html'].map(s => server.PREFIX + s); @@ -161,7 +161,9 @@ it('should fulfill after redirects', async ({page, server}) => { page.on('request', request => requestUrls.push(request.url())); page.on('response', response => responseUrls.push(response.url())); page.on('requestfinished', request => requestFinishedUrls.push(request.url())); + let routeCalls = 0; await page.route('**/*', async route => { + ++routeCalls; // @ts-expect-error await route._intercept({}); await route.fulfill({ @@ -178,6 +180,7 @@ it('should fulfill after redirects', async ({page, server}) => { expect(responseUrls).toEqual(expectedUrls); await response.finished(); expect(requestFinishedUrls).toEqual(expectedUrls); + expect(routeCalls).toBe(1); const redirectChain = []; for (let req = response.request(); req; req = req.redirectedFrom()) @@ -189,3 +192,70 @@ it('should fulfill after redirects', async ({page, server}) => { expect(response.headers()['content-type']).toBe('text/plain'); expect(await response.text()).toBe('Yo, page!'); }); + +it('should fulfill original response after redirects', async ({page, browserName, server}) => { + it.fixme(browserName !== 'chromium'); + server.setRedirect('/redirect/1.html', '/redirect/2.html'); + server.setRedirect('/redirect/2.html', '/title.html'); + const expectedUrls = ['/redirect/1.html', '/redirect/2.html', '/title.html'].map(s => server.PREFIX + s); + const requestUrls = []; + const responseUrls = []; + const requestFinishedUrls = []; + page.on('request', request => requestUrls.push(request.url())); + page.on('response', response => responseUrls.push(response.url())); + page.on('requestfinished', request => requestFinishedUrls.push(request.url())); + let routeCalls = 0; + await page.route('**/*', async route => { + ++routeCalls; + // @ts-expect-error + await route._intercept({}); + await route.fulfill(); + }); + const response = await page.goto(server.PREFIX + '/redirect/1.html'); + expect(requestUrls).toEqual(expectedUrls); + expect(responseUrls).toEqual(expectedUrls); + await response.finished(); + expect(requestFinishedUrls).toEqual(expectedUrls); + expect(routeCalls).toBe(1); + + const redirectChain = []; + for (let req = response.request(); req; req = req.redirectedFrom()) + redirectChain.unshift(req.url()); + expect(redirectChain).toEqual(expectedUrls); + + expect(response.status()).toBe(200); + expect(await response.text()).toBe('Woof-Woof\n'); +}); + +it('should abort after redirects', async ({page, browserName, server}) => { + it.fixme(browserName !== 'chromium'); + server.setRedirect('/redirect/1.html', '/redirect/2.html'); + server.setRedirect('/redirect/2.html', '/title.html'); + const expectedUrls = ['/redirect/1.html', '/redirect/2.html', '/title.html'].map(s => server.PREFIX + s); + const requestUrls = []; + const responseUrls = []; + const requestFinishedUrls = []; + const requestFailedUrls = []; + page.on('request', request => requestUrls.push(request.url())); + page.on('response', response => responseUrls.push(response.url())); + page.on('requestfinished', request => requestFinishedUrls.push(request.url())); + page.on('requestfailed', request => requestFailedUrls.push(request.url())); + let routeCalls = 0; + await page.route('**/*', async route => { + ++routeCalls; + // @ts-expect-error + await route._intercept({}); + await route.abort('connectionreset'); + }); + + try { + await page.goto(server.PREFIX + '/redirect/1.html'); + } catch (e) { + expect(e.message).toContain('ERR_CONNECTION_RESET'); + } + expect(requestUrls).toEqual(expectedUrls); + expect(responseUrls).toEqual(expectedUrls.slice(0, -1)); + expect(requestFinishedUrls).toEqual(expectedUrls.slice(0, -1)); + expect(requestFailedUrls).toEqual(expectedUrls.slice(-1)); + expect(routeCalls).toBe(1); +});