From 1918ae5c4a5ba1d0254a40f5a554bfbcae2f0ae5 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 23 Jul 2024 15:02:47 -0700 Subject: [PATCH] fix(webkit): reenable CrossOriginOpenerPolicy (#31765) Depends on https://github.com/microsoft/playwright-browsers/pull/1160 Fixes: https://github.com/microsoft/playwright/issues/14043 --- packages/playwright-core/browsers.json | 2 +- .../server/webkit/wkInterceptableRequest.ts | 9 ++- .../src/server/webkit/wkPage.ts | 28 ++++++++++ .../src/server/webkit/wkProvisionalPage.ts | 45 ++++++++++++++- tests/library/capabilities.spec.ts | 3 +- tests/page/page-goto.spec.ts | 56 ++++++++++++++++++- 6 files changed, 133 insertions(+), 10 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 307546cdad..f92f4c4bce 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2048", + "revision": "2051", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", diff --git a/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts b/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts index 8941f18b70..a450127789 100644 --- a/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts +++ b/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts @@ -40,9 +40,9 @@ const errorReasons: { [reason: string]: Protocol.Network.ResourceErrorType } = { }; export class WKInterceptableRequest { - private readonly _session: WKSession; + private _session: WKSession; + private _requestId: string; readonly request: network.Request; - private readonly _requestId: string; _timestamp: number; _wallTime: number; @@ -59,6 +59,11 @@ export class WKInterceptableRequest { resourceType, event.request.method, postDataBuffer, headersObjectToArray(event.request.headers)); } + adoptRequestFromNewProcess(newSession: WKSession, requestId: string) { + this._session = newSession; + this._requestId = requestId; + } + createResponse(responsePayload: Protocol.Network.Response): network.Response { const getResponseBody = async () => { const response = await this._session.send('Network.getResponseBody', { requestId: this._requestId }); diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 3cd454deb2..d16d013973 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -250,6 +250,7 @@ export class WKPage implements PageDelegate { private _onTargetDestroyed(event: Protocol.Target.targetDestroyedPayload) { const { targetId, crashed } = event; if (this._provisionalPage && this._provisionalPage._session.sessionId === targetId) { + this._maybeCancelCoopNavigationRequest(this._provisionalPage); this._provisionalPage._session.dispose(); this._provisionalPage.dispose(); this._provisionalPage = null; @@ -1015,6 +1016,33 @@ export class WKPage implements PageDelegate { return context.createHandle(result.object) as dom.ElementHandle; } + private _maybeCancelCoopNavigationRequest(provisionalPage: WKProvisionalPage) { + const navigationRequest = provisionalPage.coopNavigationRequest(); + for (const [requestId, request] of this._requestIdToRequest) { + if (request.request === navigationRequest) { + // Make sure the request completes if the provisional navigation is canceled. + this._onLoadingFailed(provisionalPage._session, { + requestId: requestId, + errorText: 'Provisiolal navigation canceled.', + timestamp: request._timestamp, + canceled: true, + }); + return; + } + } + } + + _adoptRequestFromNewProcess(navigationRequest: network.Request, newSession: WKSession, newRequestId: string) { + for (const [requestId, request] of this._requestIdToRequest) { + if (request.request === navigationRequest) { + this._requestIdToRequest.delete(requestId); + request.adoptRequestFromNewProcess(newSession, newRequestId); + this._requestIdToRequest.set(newRequestId, request); + return; + } + } + } + _onRequestWillBeSent(session: WKSession, event: Protocol.Network.requestWillBeSentPayload) { if (event.request.url.startsWith('data:')) return; diff --git a/packages/playwright-core/src/server/webkit/wkProvisionalPage.ts b/packages/playwright-core/src/server/webkit/wkProvisionalPage.ts index 0b19ca7a17..b8af1b9ca3 100644 --- a/packages/playwright-core/src/server/webkit/wkProvisionalPage.ts +++ b/packages/playwright-core/src/server/webkit/wkProvisionalPage.ts @@ -20,10 +20,12 @@ import type { RegisteredListener } from '../../utils/eventsHelper'; import { eventsHelper } from '../../utils/eventsHelper'; import type { Protocol } from './protocol'; import { assert } from '../../utils'; +import type * as network from '../network'; export class WKProvisionalPage { readonly _session: WKSession; private readonly _wkPage: WKPage; + private _coopNavigationRequest: network.Request | undefined; private _sessionListeners: RegisteredListener[] = []; private _mainFrameId: string | null = null; readonly initializationPromise: Promise; @@ -31,6 +33,16 @@ export class WKProvisionalPage { constructor(session: WKSession, page: WKPage) { this._session = session; this._wkPage = page; + // Cross-Origin-Opener-Policy (COOP) request starts in one process and once response headers + // have been received, continues in another. + // + // Network.requestWillBeSent and requestIntercepted (if intercepting) from the original web process + // will always come before a provisional page is created based on the response COOP headers. + // Thereafter we'll receive targetCreated (provisional) and later on in some order loadingFailed from the + // original process and requestWillBeSent from the provisional one. We should ignore loadingFailed + // as the original request continues in the provisional process. But if the provisional load is later + // canceled we should dispatch loadingFailed to the client. + this._coopNavigationRequest = page._page.mainFrame().pendingDocument()?.request; const overrideFrameId = (handler: (p: any) => void) => { return (payload: any) => { @@ -43,16 +55,20 @@ 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 => this._onRequestWillBeSent(e))), eventsHelper.addEventListener(session, 'Network.requestIntercepted', overrideFrameId(e => wkPage._onRequestIntercepted(session, e))), eventsHelper.addEventListener(session, 'Network.responseReceived', overrideFrameId(e => wkPage._onResponseReceived(session, e))), - eventsHelper.addEventListener(session, 'Network.loadingFinished', overrideFrameId(e => wkPage._onLoadingFinished(e))), - eventsHelper.addEventListener(session, 'Network.loadingFailed', overrideFrameId(e => wkPage._onLoadingFailed(session, e))), + eventsHelper.addEventListener(session, 'Network.loadingFinished', overrideFrameId(e => this._onLoadingFinished(e))), + eventsHelper.addEventListener(session, 'Network.loadingFailed', overrideFrameId(e => this._onLoadingFailed(e))), ]; this.initializationPromise = this._wkPage._initializeSession(session, true, ({ frameTree }) => this._handleFrameTree(frameTree)); } + coopNavigationRequest(): network.Request | undefined { + return this._coopNavigationRequest; + } + dispose() { eventsHelper.removeEventListeners(this._sessionListeners); } @@ -62,6 +78,29 @@ export class WKProvisionalPage { this._wkPage._onFrameAttached(this._mainFrameId, null); } + private _onRequestWillBeSent(event: Protocol.Network.requestWillBeSentPayload) { + if (this._coopNavigationRequest && this._coopNavigationRequest.url() === event.request.url) { + // If it's a continuation of the main frame navigation request after COOP headers were received, + // take over original request, and replace its request id with the new one. + this._wkPage._adoptRequestFromNewProcess(this._coopNavigationRequest, this._session, event.requestId); + // Simply ignore this event as it has already been dispatched from the original process + // and there will ne no requestIntercepted event from the provisional process as it resumes + // existing network load (that has already received reponse headers). + return; + } + this._wkPage._onRequestWillBeSent(this._session, event); + } + + private _onLoadingFinished(event: Protocol.Network.loadingFinishedPayload): void { + this._coopNavigationRequest = undefined; + this._wkPage._onLoadingFinished(event); + } + + private _onLoadingFailed(event: Protocol.Network.loadingFailedPayload) { + this._coopNavigationRequest = undefined; + this._wkPage._onLoadingFailed(this._session, event); + } + private _handleFrameTree(frameTree: Protocol.Page.FrameResourceTree) { assert(!frameTree.frame.parentId); this._mainFrameId = frameTree.frame.id; diff --git a/tests/library/capabilities.spec.ts b/tests/library/capabilities.spec.ts index 47e022cd80..bc5fc10cf3 100644 --- a/tests/library/capabilities.spec.ts +++ b/tests/library/capabilities.spec.ts @@ -19,8 +19,7 @@ import url from 'url'; import { contextTest as it, expect } from '../config/browserTest'; import { hostPlatform } from '../../packages/playwright-core/src/utils/hostPlatform'; -it('SharedArrayBuffer should work @smoke', async function({ contextFactory, httpsServer, browserName }) { - it.fail(browserName === 'webkit', 'no shared array buffer on webkit'); +it('SharedArrayBuffer should work @smoke', async function({ contextFactory, httpsServer }) { const context = await contextFactory({ ignoreHTTPSErrors: true }); const page = await context.newPage(); httpsServer.setRoute('/sharedarraybuffer', (req, res) => { diff --git a/tests/page/page-goto.spec.ts b/tests/page/page-goto.spec.ts index 5ca9ee6e46..830a25e203 100644 --- a/tests/page/page-goto.spec.ts +++ b/tests/page/page-goto.spec.ts @@ -78,7 +78,7 @@ it('should work with cross-process that fails before committing', async ({ page, expect(error instanceof Error).toBeTruthy(); }); -it('should work with Cross-Origin-Opener-Policy', async ({ page, server, browserName }) => { +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(); @@ -109,7 +109,42 @@ it('should work with Cross-Origin-Opener-Policy', async ({ page, server, browser expect(response.request().failure()).toBeNull(); }); -it('should work with Cross-Origin-Opener-Policy after redirect', async ({ page, server, browserName }) => { +it('should work with Cross-Origin-Opener-Policy and interception', async ({ page, server }) => { + 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()); + }); + await page.route('**/*', async route => { + await new Promise(f => setTimeout(f, 100)); + await route.continue(); + }); + 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'); @@ -144,6 +179,23 @@ it('should work with Cross-Origin-Opener-Policy after redirect', async ({ page, expect(firstRequest.url()).toBe(server.PREFIX + '/redirect'); }); +it('should properly cancel Cross-Origin-Opener-Policy navigation', async ({ page, server }) => { + server.setRoute('/empty.html', (req, res) => { + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.end(); + }); + const requestPromise = page.waitForRequest(server.EMPTY_PAGE); + page.goto(server.EMPTY_PAGE).catch(() => {}); + await new Promise(f => setTimeout(f, 50)); + // Non COOP response. + await page.goto(server.CROSS_PROCESS_PREFIX + '/error.html'); + const req = await requestPromise; + const response = await Promise.race([req.response(), new Promise(f => setTimeout(() => f('timeout'), 5_000))]); + // First navigation request should either receive response or be canceled by the second + // navigation, but never hang unresolved. + expect(response).not.toBe('timeout'); +}); + it('should capture iframe navigation request', async ({ page, server }) => { await page.goto(server.EMPTY_PAGE); expect(page.url()).toBe(server.EMPTY_PAGE);