diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index e13652b98b7e1..274d225fbe6bf 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -487,8 +487,6 @@ class FrameSession { startAutomaticVideoRecording(this._crPage._page); let lifecycleEventsEnabled: Promise; - if (!this._isMainFrame()) - this._addRendererListeners(); this._addBrowserListeners(); // Buffer attachedToTarget events until we receive the frame tree. @@ -500,10 +498,9 @@ class FrameSession { const promises: Promise[] = [ this._client.send('Page.enable'), this._client.send('Page.getFrameTree').then(({ frameTree }) => { - if (this._isMainFrame()) { - this._handleFrameTree(frameTree); - this._addRendererListeners(); - } + const localFrames: frames.Frame[] = []; + this._handleFrameTree(frameTree, localFrames); + this._addRendererListeners(); // Now that we have the frame tree, it is possible to insert oopif targets at the right place. const attachedToTargetEvents = this._bufferedAttachedToTargetEvents || []; @@ -511,7 +508,6 @@ class FrameSession { for (const event of attachedToTargetEvents) this._onAttachedToTarget(event); - const localFrames = this._isMainFrame() ? this._page.frames() : [this._page.frameManager.frame(this._targetId)!]; for (const frame of localFrames) { // Note: frames might be removed before we send these. this._client._sendMayFail('Page.createIsolatedWorld', { @@ -630,14 +626,24 @@ class FrameSession { this._page.frameManager.frameLifecycleEvent(event.frameId, 'domcontentloaded'); } - _handleFrameTree(frameTree: Protocol.Page.FrameTree) { - this._onFrameAttached(frameTree.frame.id, frameTree.frame.parentId || null); - this._onFrameNavigated(frameTree.frame, true); - if (!frameTree.childFrames) + _handleFrameTree(frameTree: Protocol.Page.FrameTree, collectedLocalFrames: frames.Frame[]) { + const isOopif = !this._isMainFrame() && frameTree.frame.id === this._targetId; + // Note: OOPIF frame has been already attached in the parent session, so skip it. + if (!isOopif) + this._onFrameAttached(frameTree.frame.id, frameTree.frame.parentId || null); + + const isInitialOopifDocument = isOopif && (frameTree.frame.url === '' || frameTree.frame.url === ':'); + // Note: do not fake an empty document commit in a newly created OOPIF. + if (!isInitialOopifDocument) + this._onFrameNavigated(frameTree.frame, true); + + const frame = this._page.frameManager.frame(frameTree.frame.id); + if (!frame) return; - for (const child of frameTree.childFrames) - this._handleFrameTree(child); + collectedLocalFrames.push(frame); + for (const child of frameTree.childFrames || []) + this._handleFrameTree(child, collectedLocalFrames); } private _eventBelongsToStaleFrame(frameId: string) { @@ -759,7 +765,8 @@ class FrameSession { if (!frame && event.targetInfo.parentFrameId) { // When connecting to an existing page with an iframe, there is an "iframe" target, // but no local frame is reported in getFrameTree. We can create a remote frame here. - frame = this._page.frameManager.frameAttached(targetId, event.targetInfo.parentFrameId); + const parentFrame = this._page.frameManager.frame(event.targetInfo.parentFrameId) ?? this._page.mainFrame(); + frame = this._page.frameManager.frameAttached(targetId, parentFrame._id); } if (!frame) return; // Subtree may be already gone due to renderer/browser race. diff --git a/tests/library/chromium/oopif.spec.ts b/tests/library/chromium/oopif.spec.ts index 2a9f525472778..cb37a8576ada6 100644 --- a/tests/library/chromium/oopif.spec.ts +++ b/tests/library/chromium/oopif.spec.ts @@ -398,6 +398,25 @@ it('should intercept response body from oopif', async function({ page, browser, expect(await response.text()).toBeTruthy(); }); +it('should navigate a local frame to oopif with frame.goto', async ({ page, browser, server }) => { + await page.goto(server.PREFIX + '/frames/one-frame.html'); + await assertOOPIFCount(browser, 0); + const frame = page.frames()[1]; + expect(frame.url()).toBe(server.PREFIX + '/frames/frame.html'); + + const navigations: string[] = []; + page.on('framenavigated', f => navigations.push(f.url())); + + const response = await frame.goto(server.CROSS_PROCESS_PREFIX + '/grid.html'); + await assertOOPIFCount(browser, 1); + expect(response!.url()).toBe(server.CROSS_PROCESS_PREFIX + '/grid.html'); + expect(response!.frame()).toBe(frame); + expect(frame.url()).toBe(server.CROSS_PROCESS_PREFIX + '/grid.html'); + expect(await frame.evaluate(() => '' + location.href)).toBe(server.CROSS_PROCESS_PREFIX + '/grid.html'); + expect(navigations).toEqual([server.CROSS_PROCESS_PREFIX + '/grid.html']); + expect(page.frames().length).toBe(2); +}); + it('should allow to re-connect to OOPIFs with CDP when iframes were there already', async ({ browserType, server }) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/36095' }); it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/17656' }); @@ -417,6 +436,61 @@ it('should allow to re-connect to OOPIFs with CDP when iframes were there alread await hostBrowser.close(); }); +it('should report the whole frame tree when connecting over CDP to nested OOPIFs', async ({ browserType, server }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42955' }); + it.skip(!!process.env.PWTEST_CHANNEL, 'Test default channel only'); + + // top > cross-site iframe (oopif) > same-process child iframe > sandboxed srcdoc iframe (oopif). + server.setRoute('/nested-top.html', (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(`

TOP

`); + }); + server.setRoute('/nested-b.html', (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(``); + }); + server.setRoute('/nested-c.html', (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(``); + }); + + const cdpPort = 10123 + it.info().parallelIndex * 4; + const hostBrowser = await browserType.launch({ + channel: 'chromium', + args: ['--remote-debugging-port=' + cdpPort, '--site-per-process', '--enable-features=IsolateSandboxedIframes'], + }); + const hostPage = await hostBrowser.newPage(); + await hostPage.goto(server.PREFIX + '/nested-top.html'); + + const browser = await browserType.connectOverCDP(`http://localhost:${cdpPort}`); + await assertOOPIFCount(browser, 2); + const page = browser.contexts()[0].pages()[0]; + + const expectedTree = [ + server.PREFIX + '/nested-top.html', + ' ' + server.CROSS_PROCESS_PREFIX + '/nested-b.html', + ' ' + server.CROSS_PROCESS_PREFIX + '/nested-c.html', + ' about:srcdoc', + ]; + const dumpFrames = async (frame: Frame, indent = ''): Promise => { + expect(await frame.evaluate(() => '' + location.href)).toBe(frame.url()); + const result = [indent + frame.url()]; + for (const child of frame.childFrames()) + result.push(...await dumpFrames(child, indent + ' ')); + return result; + }; + expect(await dumpFrames(page.mainFrame())).toEqual(expectedTree); + expect(page.frames().length).toBe(4); + + expect(page.url()).toBe(server.PREFIX + '/nested-top.html'); + expect(await page.evaluate(() => window.top === window)).toBe(true); + await expect(page.locator('#top')).toHaveText('TOP'); + await expect(page.frameLocator('iframe').frameLocator('iframe').frameLocator('iframe').locator('p')).toHaveText('D'); + + await browser.close(); + await hostBrowser.close(); +}); + async function assertOOPIFCount(browser: Browser, count: number) { if (browser.browserType().name() !== 'chromium') return;