diff --git a/src/server/frames.ts b/src/server/frames.ts index 3e99bff193955..4c329830ef1ce 100644 --- a/src/server/frames.ts +++ b/src/server/frames.ts @@ -823,6 +823,10 @@ export class Frame extends SdkObject { return this._parentFrame; } + isMainFrame(): boolean { + return this._page.mainFrame() === this; + } + childFrames(): Frame[] { return Array.from(this._childFrames); } diff --git a/src/server/webkit/wkInterceptableRequest.ts b/src/server/webkit/wkInterceptableRequest.ts index 8aff250bb224a..eb096a27eed22 100644 --- a/src/server/webkit/wkInterceptableRequest.ts +++ b/src/server/webkit/wkInterceptableRequest.ts @@ -45,7 +45,7 @@ const errorReasons: { [reason: string]: Protocol.Network.ResourceErrorType } = { export class WKInterceptableRequest { private readonly _session: WKSession; readonly request: network.Request; - readonly _requestId: string; + _requestId: string; _timestamp: number; _wallTime: number; readonly _route: WKRouteImpl | null; diff --git a/src/server/webkit/wkPage.ts b/src/server/webkit/wkPage.ts index d278547a5636c..3f6246ec873a1 100644 --- a/src/server/webkit/wkPage.ts +++ b/src/server/webkit/wkPage.ts @@ -76,6 +76,7 @@ export class WKPage implements PageDelegate { private _nextWindowOpenPopupFeatures?: string[]; private _recordingVideoFile: string | null = null; private _screencastGeneration: number = 0; + private _currentMainFrameNavigation: WKNavigation | undefined; constructor(browserContext: WKBrowserContext, pageProxySession: WKSession, opener: WKPage | null) { this._pageProxySession = pageProxySession; @@ -411,6 +412,8 @@ export class WKPage implements PageDelegate { } private _onFrameStoppedLoading(frameId: string) { + if (this._currentMainFrameNavigation?.shouldIgnoreFrameStoppedLoading(frameId)) + return; this._page._frameManager.frameStoppedLoading(frameId); } @@ -940,7 +943,26 @@ export class WKPage implements PageDelegate { return result.handle; } - _onRequestWillBeSent(session: WKSession, event: Protocol.Network.requestWillBeSentPayload) { + _onRequestWillBeSentProvisional(session: WKSession, event: Protocol.Network.requestWillBeSentPayload) { + if (!this._currentMainFrameNavigation?.checkIfSameNavigationInNewProcess(event)) { + this._onRequestWillBeSent(session, event); + return; + } + const oldId = this._currentMainFrameNavigation._originalRequestId; + const request = this._requestIdToRequest.get(oldId); + if (!request) + return; + const payload = this._requestIdToResponseReceivedPayloadEvent.get(oldId); + this._requestIdToRequest.delete(oldId); + this._requestIdToResponseReceivedPayloadEvent.delete(oldId); + const newId = event.requestId; + request._requestId = newId; + this._requestIdToRequest.set(newId, request); + if (payload) + this._requestIdToResponseReceivedPayloadEvent.set(newId, payload); + } + + private _onRequestWillBeSent(session: WKSession, event: Protocol.Network.requestWillBeSentPayload) { if (event.request.url.startsWith('data:')) return; let redirectedFrom: WKInterceptableRequest | null = null; @@ -961,6 +983,8 @@ 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; + if (isNavigationRequest && frame.isMainFrame()) + this._currentMainFrameNavigation = new WKNavigation(this, event); let route = null; // We do not support intercepting redirects. if (this._page._needsRequestInterception() && !redirectedFrom) @@ -986,7 +1010,7 @@ export class WKPage implements PageDelegate { session.sendMayFail('Network.interceptRequestWithError', { errorType: 'Cancellation', requestId: event.requestId }); return; } - if (!request._route) { + if (!request._route || this._currentMainFrameNavigation?.shoudIgnoreRequestInterception(event)) { // Intercepted, although we do not intend to allow interception. // Just continue. session.sendMayFail('Network.interceptWithRequest', { requestId: request._requestId }); @@ -1006,6 +1030,8 @@ export class WKPage implements PageDelegate { } _onResponseReceived(event: Protocol.Network.responseReceivedPayload) { + if (this._currentMainFrameNavigation?.checkIfDuplicateResponseEvent(event)) + return; const request = this._requestIdToRequest.get(event.requestId); // FileUpload sends a response without a matching request. if (!request) @@ -1063,7 +1089,12 @@ export class WKPage implements PageDelegate { this._page._frameManager.reportRequestFinished(request.request, response); } - _onLoadingFailed(event: Protocol.Network.loadingFailedPayload) { + private _onLoadingFailed(event: Protocol.Network.loadingFailedPayload) { + if (!this._currentMainFrameNavigation?.shouldIgnoreLoadingFailedEvent(event)) + this._onLoadingFailedShared(event); + } + + _onLoadingFailedShared(event: Protocol.Network.loadingFailedPayload) { const request = this._requestIdToRequest.get(event.requestId); // For certain requestIds we never receive requestWillBeSent event. // @see https://crbug.com/750469 @@ -1099,6 +1130,10 @@ export class WKPage implements PageDelegate { async _clearPermissions() { await this._pageProxySession.send('Emulation.resetPermissions', {}); } + + hasProvisionalPage() { + return !!this._provisionalPage; + } } /** @@ -1160,3 +1195,68 @@ function isLoadedSecurely(url: string, timing: network.ResourceTiming) { return true; } catch (_) {} } + + +class WKNavigation { + private readonly _page: WKPage; + // loaderId is a navigation id which is unique within browser and persists during cross-process navigation. + private readonly _loaderId: string; + readonly _originalRequestId: Protocol.Network.RequestId; + private readonly _frameId: string; + private _responseReceived: boolean = false; + private _didCheckFirstRequestInProvisionalPage: boolean = false; + private _provisionalPageRequestId: string | undefined; + + constructor(page: WKPage, event: Protocol.Network.requestWillBeSentPayload) { + this._page = page; + this._loaderId = event.loaderId; + this._originalRequestId = event.requestId; + this._frameId = event.frameId; + } + + shouldIgnoreFrameStoppedLoading(frameId: string): boolean { + if (frameId !== this._frameId) + return false; + // Navigation in the original frame is canceled and actually continues in the + // provisional page, so we ignore failure events from the original page. + return this._page.hasProvisionalPage(); + } + + shouldIgnoreLoadingFailedEvent(event: Protocol.Network.loadingFailedPayload): boolean { + if (event.requestId !== this._originalRequestId) + return false; + if (!event.canceled) + return false; + // Navigation in the original frame is canceled and actually continues in the + // provisional page, so we ignore failure events from the original page. + return this._page.hasProvisionalPage(); + } + + checkIfSameNavigationInNewProcess(event: Protocol.Network.requestWillBeSentPayload): boolean { + if (this._didCheckFirstRequestInProvisionalPage) + return false; + this._didCheckFirstRequestInProvisionalPage = true; + if (event.loaderId !== this._loaderId) + return false; + this._provisionalPageRequestId = event.requestId; + return true; + } + + checkIfDuplicateResponseEvent(event: Protocol.Network.responseReceivedPayload): boolean { + if (this._originalRequestId === event.requestId || this._provisionalPageRequestId === event.requestId) { + // In case of cross-process navigation caused by Cross-Origin-Opener-Policy response header + // WebKit sends two responseReceived events from the old web process and one such event from the + // new provisonal web process. + if (this._responseReceived) + return true; + this._responseReceived = true; + } + return false; + } + + shoudIgnoreRequestInterception(event: Protocol.Network.requestInterceptedPayload): boolean { + // It only makes sense to intercept request in the old process before it is sent to + // the server (in the new process the response is already received and will be replayed) + return this._provisionalPageRequestId === event.requestId; + } +} \ No newline at end of file diff --git a/src/server/webkit/wkProvisionalPage.ts b/src/server/webkit/wkProvisionalPage.ts index cccb8080edc1f..cf3dc42237277 100644 --- a/src/server/webkit/wkProvisionalPage.ts +++ b/src/server/webkit/wkProvisionalPage.ts @@ -42,12 +42,12 @@ export class WKProvisionalPage { const wkPage = this._wkPage; this._sessionListeners = [ - eventsHelper.addEventListener(session, 'Network.requestWillBeSent', overrideFrameId(e => wkPage._onRequestWillBeSent(session, e))), + eventsHelper.addEventListener(session, 'Network.requestWillBeSent', overrideFrameId(e => wkPage._onRequestWillBeSentProvisional(session, e))), eventsHelper.addEventListener(session, 'Network.requestIntercepted', overrideFrameId(e => wkPage._onRequestIntercepted(session, e))), eventsHelper.addEventListener(session, 'Network.responseIntercepted', overrideFrameId(e => wkPage._onResponseIntercepted(session, e))), eventsHelper.addEventListener(session, 'Network.responseReceived', overrideFrameId(e => wkPage._onResponseReceived(e))), eventsHelper.addEventListener(session, 'Network.loadingFinished', overrideFrameId(e => wkPage._onLoadingFinished(e))), - eventsHelper.addEventListener(session, 'Network.loadingFailed', overrideFrameId(e => wkPage._onLoadingFailed(e))), + eventsHelper.addEventListener(session, 'Network.loadingFailed', overrideFrameId(e => wkPage._onLoadingFailedShared(e))), ]; this.initializationPromise = this._wkPage._initializeSession(session, true, ({ frameTree }) => this._handleFrameTree(frameTree)); diff --git a/tests/download.spec.ts b/tests/download.spec.ts index fb338f9c51045..52b975ce3ce7c 100644 --- a/tests/download.spec.ts +++ b/tests/download.spec.ts @@ -39,6 +39,12 @@ it.describe('download event', () => { res.write('foo'); res.uncork(); }); + server.setRoute('/downloadWithCOOP', (req, res) => { + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Disposition', 'attachment'); + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.end(`Hello world`); + }); }); it('should report download when navigation turns into download', async ({ browser, server, browserName }) => { @@ -67,6 +73,32 @@ it.describe('download event', () => { await page.close(); }); + it('should work with Cross-Origin-Opener-Policy', async ({ browser, server, browserName }) => { + const page = await browser.newPage({ acceptDownloads: true }); + const [ download, responseOrError ] = await Promise.all([ + page.waitForEvent('download'), + page.goto(server.PREFIX + '/downloadWithCOOP').catch(e => e) + ]); + expect(download.page()).toBe(page); + expect(download.url()).toBe(`${server.PREFIX}/downloadWithCOOP`); + const path = await download.path(); + expect(fs.existsSync(path)).toBeTruthy(); + expect(fs.readFileSync(path).toString()).toBe('Hello world'); + if (browserName === 'chromium') { + expect(responseOrError instanceof Error).toBeTruthy(); + expect(responseOrError.message).toContain('net::ERR_ABORTED'); + expect(page.url()).toBe('about:blank'); + } else if (browserName === 'webkit') { + expect(responseOrError instanceof Error).toBeTruthy(); + expect(responseOrError.message).toContain('Download is starting'); + expect(page.url()).toBe('about:blank'); + } else { + expect(responseOrError.status()).toBe(200); + expect(page.url()).toBe(server.PREFIX + '/download'); + } + await page.close(); + }); + it('should report downloads with acceptDownloads: false', async ({ browser, server }) => { const page = await browser.newPage(); await page.setContent(`download`); diff --git a/tests/page/page-goto.spec.ts b/tests/page/page-goto.spec.ts index 7fdfae4f8aef7..5706724dbe32e 100644 --- a/tests/page/page-goto.spec.ts +++ b/tests/page/page-goto.spec.ts @@ -58,14 +58,85 @@ it('should work cross-process', async ({ page, server }) => { expect(response.url()).toBe(url); }); -it('should work with Cross-Origin-Opener-Policy', async ({ page, server, browserName }) => { - it.fail(browserName === 'webkit', 'Regressed in https://trac.webkit.org/changeset/281516/webkit'); +it('should work with cross-process that fails before committing', async ({ page, server, browserName }) => { + server.setRoute('/empty.html', (req, res) => { + req.socket.destroy(); + }); + const response1 = await page.goto(server.CROSS_PROCESS_PREFIX + '/title.html'); + await response1.finished(); + const error = await page.goto(server.EMPTY_PAGE).catch(e => e); + if (browserName === 'chromium') + expect(error.message).toContain('net::ERR_EMPTY_RESPONSE'); + if (browserName === 'webkit') + expect(error.message).toContain('Message Corrupt'); + if (browserName === 'firefox') + expect(error.message).toContain('NS_ERROR_NET_RESET'); +}); + +it('should work with Cross-Origin-Opener-Policy', async ({ page, server }) => { server.setRoute('/empty.html', (req, res) => { res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); res.end(); }); - await page.goto(server.EMPTY_PAGE); + const requests = new Set(); + const events = []; + page.on('request', r => { + events.push('request'); + requests.add(r); + }); + page.on('requestfailed', r => { + events.push('requestfailed'); + requests.add(r); + }); + page.on('requestfinished', r => { + events.push('requestfinished'); + requests.add(r); + }); + page.on('response', r => { + events.push('response'); + requests.add(r.request()); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(page.url()).toBe(server.EMPTY_PAGE); + await response.finished(); + expect(events).toEqual(['request', 'response', 'requestfinished']); + expect(requests.size).toBe(1); + expect(response.request().failure()).toBeNull(); +}); + +it('should work with Cross-Origin-Opener-Policy after redirect', async ({ page, server }) => { + server.setRedirect('/redirect', '/empty.html'); + server.setRoute('/empty.html', (req, res) => { + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.end(); + }); + const requests = new Set(); + const events = []; + page.on('request', r => { + events.push('request'); + requests.add(r); + }); + page.on('requestfailed', r => { + events.push('requestfailed'); + requests.add(r); + }); + page.on('requestfinished', r => { + events.push('requestfinished'); + requests.add(r); + }); + page.on('response', r => { + events.push('response'); + requests.add(r.request()); + }); + const response = await page.goto(server.PREFIX + '/redirect'); expect(page.url()).toBe(server.EMPTY_PAGE); + await response.finished(); + expect(events).toEqual(['request', 'response', 'requestfinished', 'request', 'response', 'requestfinished']); + expect(requests.size).toBe(2); + expect(response.request().failure()).toBeNull(); + const firstRequest = response.request().redirectedFrom(); + expect(firstRequest).toBeTruthy(); + expect(firstRequest.url()).toBe(server.PREFIX + '/redirect'); }); it('should capture iframe navigation request', async ({ page, server }) => { diff --git a/tests/page/page-request-continue.spec.ts b/tests/page/page-request-continue.spec.ts index d1d4c24b2cd7a..c9553d655dec7 100644 --- a/tests/page/page-request-continue.spec.ts +++ b/tests/page/page-request-continue.spec.ts @@ -167,3 +167,55 @@ it.describe('', () => { expect(arr[i]).toBe(buffer[i]); }); }); + +it('should work with Cross-Origin-Opener-Policy', async ({ page, server, browserName }) => { + let serverHeaders; + const serverRequests = []; + server.setRoute('/empty.html', (req, res) => { + serverRequests.push(req.url); + serverHeaders ??= req.headers; + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.end(); + }); + + const intercepted = []; + await page.route('**/*', (route, req) => { + intercepted.push(req.url()); + route.continue({ + headers: { + foo: 'bar' + } + }); + }); + const requests = new Set(); + const events = []; + page.on('request', r => { + events.push('request'); + requests.add(r); + }); + page.on('requestfailed', r => { + events.push('requestfailed'); + requests.add(r); + }); + page.on('requestfinished', r => { + events.push('requestfinished'); + requests.add(r); + }); + page.on('response', r => { + events.push('response'); + requests.add(r.request()); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(intercepted).toEqual([server.EMPTY_PAGE]); + // There should be only one request to the server. + if (browserName === 'webkit') + expect(serverRequests).toEqual(['/empty.html', '/empty.html']); + else + expect(serverRequests).toEqual(['/empty.html']); + expect(serverHeaders['foo']).toBe('bar'); + expect(page.url()).toBe(server.EMPTY_PAGE); + await response.finished(); + expect(events).toEqual(['request', 'response', 'requestfinished']); + expect(requests.size).toBe(1); + expect(response.request().failure()).toBeNull(); +});