From 8aa5d281f87bdf8e97dd45eead55c5813bc64ead Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:23:07 -0700 Subject: [PATCH] test(browser): route input into fresh sandboxed popup iframes before clicking them (#30966) Fresh sandboxed iframes run in their own renderer and can finish loading before the browser routes pointer input to them, so Playwright's click landed on the parent and no popup opened. Hover-poll until the new frame sees trusted input before clicking, in one helper shared by the adapter, native and background popup probes. Share the cold first-frame budget from #30854 with popup-origin-chrome, which still waited only 10s for the same one-time GPU startup. --- ...pup-adapter-compatibility.electron.test.ts | 8 +++--- .../browser-popup-background-cdp-probe.ts | 17 ++--------- ...browser-popup-compatibility-cdp-fixture.ts | 7 +++-- ...opup-native-compatibility.electron.test.ts | 10 +++---- .../hidden-frame-input-readiness-fixture.ts | 28 +++++++++++++++++++ .../hidden-renderer-frame-capture-fixture.ts | 3 ++ .../popup-origin-chrome.electron.test.ts | 11 ++++++-- 7 files changed, 55 insertions(+), 29 deletions(-) create mode 100644 src/main/browser/hidden-frame-input-readiness-fixture.ts diff --git a/src/main/browser/browser-popup-adapter-compatibility.electron.test.ts b/src/main/browser/browser-popup-adapter-compatibility.electron.test.ts index e13b927abfa..2dd05b30463 100644 --- a/src/main/browser/browser-popup-adapter-compatibility.electron.test.ts +++ b/src/main/browser/browser-popup-adapter-compatibility.electron.test.ts @@ -12,6 +12,7 @@ import { snapshots, status } from './browser-popup-compatibility-cdp-fixture' +import { waitForHiddenFrameInput } from './hidden-frame-input-readiness-fixture' enablePopupAdapterProbe() setPopupFixtureEvidenceName('adapter-electron-evidence.json') @@ -334,10 +335,9 @@ async function exerciseSandboxControls( const offset = status().adapterProbe?.length ?? 0 await installSandboxFrame(page, sandbox, destination, kind) const initialReceipt = receipts.length - await page - .frameLocator('#sandbox-probe') - .locator('#open') - .click(kind === 'link' ? { modifiers: ['Shift'] } : {}) + const open = page.frameLocator('#sandbox-probe').locator('#open') + await waitForHiddenFrameInput(open) + await open.click(kind === 'link' ? { modifiers: ['Shift'] } : {}) if (path === 'adopted' && kind === 'link') { await expect .poll(() => eventsSince(offset).some((event) => event.event === 'createWindow-missing')) diff --git a/src/main/browser/browser-popup-background-cdp-probe.ts b/src/main/browser/browser-popup-background-cdp-probe.ts index 7fca368da63..27ad3a58b4f 100644 --- a/src/main/browser/browser-popup-background-cdp-probe.ts +++ b/src/main/browser/browser-popup-background-cdp-probe.ts @@ -2,6 +2,7 @@ import { mkdirSync } from 'node:fs' import { join } from 'node:path' import type { Page } from 'playwright' import { expect } from 'vitest' +import { waitForHiddenFrameInput } from './hidden-frame-input-readiness-fixture' import { artifactDir, browser, @@ -103,22 +104,8 @@ export async function clickBackgroundPopupProbe( ? page.frameLocator('#background-probe').locator('#background-open') : page.locator('#background-open') if (probe.context === 'opaque') { - // A hidden OOPIF can finish loading before browser input reaches its new frame. - await locator.evaluate((element) => { - element.removeAttribute('data-background-input-ready') - element.addEventListener('pointermove', (event) => { - if (event.isTrusted) { - element.setAttribute('data-background-input-ready', 'true') - } - }) - }) await page.locator('#background-gesture').click() - await expect - .poll(async () => { - await locator.hover() - return locator.getAttribute('data-background-input-ready') - }) - .toBe('true') + await waitForHiddenFrameInput(locator) // Opaque frame input does not reach Electron's root route gesture gate. await page.locator('#background-gesture').click() } diff --git a/src/main/browser/browser-popup-compatibility-cdp-fixture.ts b/src/main/browser/browser-popup-compatibility-cdp-fixture.ts index 8b2ee9c0013..dbfa7c10826 100644 --- a/src/main/browser/browser-popup-compatibility-cdp-fixture.ts +++ b/src/main/browser/browser-popup-compatibility-cdp-fixture.ts @@ -12,7 +12,10 @@ import { build as buildVite } from 'vite' import { waitForBrowserCdpEndpoint } from './browser-session-ua-cdp-collector' import { popupFixtureHtml } from './browser-popup-compatibility-page-fixture' import { resolveElectronProbeLaunch } from './electron-probe-display-launch' -import { startHiddenRendererFrameCapture } from './hidden-renderer-frame-capture-fixture' +import { + HIDDEN_RENDERER_FIRST_FRAME_TIMEOUT_MS, + startHiddenRendererFrameCapture +} from './hidden-renderer-frame-capture-fixture' // Hidden renderer startup and CDP events can exceed the default one-second poll on CI. vi.setConfig({ expect: { poll: { timeout: 10_000 } } }) @@ -214,7 +217,7 @@ beforeAll(async () => { stopFrameCaptures.set( page, await startHiddenRendererFrameCapture(page, (hasFrame) => - waitForFixture(hasFrame, 45_000).catch((error: unknown) => { + waitForFixture(hasFrame, HIDDEN_RENDERER_FIRST_FRAME_TIMEOUT_MS).catch((error: unknown) => { throw new Error(`No first frame for ${name}`, { cause: error }) }) ) diff --git a/src/main/browser/browser-popup-native-compatibility.electron.test.ts b/src/main/browser/browser-popup-native-compatibility.electron.test.ts index 94b00d601a8..cfe8fdb80a6 100644 --- a/src/main/browser/browser-popup-native-compatibility.electron.test.ts +++ b/src/main/browser/browser-popup-native-compatibility.electron.test.ts @@ -14,6 +14,7 @@ import { status, setPopupFixtureEvidenceName } from './browser-popup-compatibility-cdp-fixture' +import { waitForHiddenFrameInput } from './hidden-frame-input-readiness-fixture' import { MIN_POPUP_CONTENT_HEIGHT, MIN_POPUP_CONTENT_WIDTH } from './popup-window-minimum-size' setPopupFixtureEvidenceName('native-electron-evidence.json') @@ -312,14 +313,13 @@ describe('hidden Chromium native popup request compatibility', () => { }, { targetOrigin, opaque, shift } ) + const post = page.frameLocator('#frame-probe').locator('#frame-post') if (opaque) { - // Prime hidden Linux OOPIF hit testing and the route's root gesture gate. + await waitForHiddenFrameInput(post) + // Opaque frame input does not reach the route's root gesture gate. await page.locator('#frame-gesture').click() } - await page - .frameLocator('#frame-probe') - .locator('#frame-post') - .click(shift ? { modifiers: ['Shift'] } : {}) + await post.click(shift ? { modifiers: ['Shift'] } : {}) await expect .poll(() => receipts.slice(before).find((receipt) => receipt.path === '/frame-post')) .toMatchObject({ diff --git a/src/main/browser/hidden-frame-input-readiness-fixture.ts b/src/main/browser/hidden-frame-input-readiness-fixture.ts new file mode 100644 index 00000000000..378c08a9472 --- /dev/null +++ b/src/main/browser/hidden-frame-input-readiness-fixture.ts @@ -0,0 +1,28 @@ +import type { Locator } from 'playwright' +import { expect } from 'vitest' + +const READY_ATTRIBUTE = 'data-hidden-frame-input-ready' + +// A new sandboxed iframe runs in its own renderer and can finish loading before the browser routes +// pointer input to it; until then Playwright's click lands on the parent and the frame sees nothing. +export async function waitForHiddenFrameInput(target: Locator): Promise { + await target.evaluate((element, attribute) => { + element.removeAttribute(attribute) + element.addEventListener('pointermove', (event) => { + if (event.isTrusted) { + element.setAttribute(attribute, 'true') + } + }) + }, READY_ATTRIBUTE) + await expect + .poll( + async () => { + // Leave first so every hover is a fresh move the browser has to route again. + await target.page().mouse.move(0, 0) + await target.hover() + return target.getAttribute(READY_ATTRIBUTE) + }, + { timeout: 10_000 } + ) + .toBe('true') +} diff --git a/src/main/browser/hidden-renderer-frame-capture-fixture.ts b/src/main/browser/hidden-renderer-frame-capture-fixture.ts index 8019bdd8e10..1df9095027e 100644 --- a/src/main/browser/hidden-renderer-frame-capture-fixture.ts +++ b/src/main/browser/hidden-renderer-frame-capture-fixture.ts @@ -1,5 +1,8 @@ import type { Page } from 'playwright' +// Setup-only budget: a cold runner's first frame took up to 14s across 28 measured runs. +export const HIDDEN_RENDERER_FIRST_FRAME_TIMEOUT_MS = 45_000 + export async function startHiddenRendererFrameCapture( page: Page, waitForFirstFrame: (hasFrame: () => boolean) => Promise diff --git a/src/main/browser/popup-origin-chrome.electron.test.ts b/src/main/browser/popup-origin-chrome.electron.test.ts index 566d0cb7173..4f0276da65e 100644 --- a/src/main/browser/popup-origin-chrome.electron.test.ts +++ b/src/main/browser/popup-origin-chrome.electron.test.ts @@ -8,7 +8,10 @@ import { createPopupIdentityProbeRoot, launchPopupIdentityProbe } from './popup-identity-electron-probe' -import { startHiddenRendererFrameCapture } from './hidden-renderer-frame-capture-fixture' +import { + HIDDEN_RENDERER_FIRST_FRAME_TIMEOUT_MS, + startHiddenRendererFrameCapture +} from './hidden-renderer-frame-capture-fixture' type OriginProbeWindow = Window & { originProbeChild?: Window } @@ -138,9 +141,11 @@ beforeAll(async () => { } page = found stopCapture = await startHiddenRendererFrameCapture(page, (hasFrame) => - vi.waitFor(() => expect(hasFrame()).toBe(true), { timeout: 10_000 }) + vi.waitFor(() => expect(hasFrame()).toBe(true), { + timeout: HIDDEN_RENDERER_FIRST_FRAME_TIMEOUT_MS + }) ) -}, 60_000) +}, 90_000) afterAll(async () => { for (const response of held) {