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.
This commit is contained in:
Neil
2026-10-19 19:23:07 -07:00
committed by GitHub
parent 11b925d5a1
commit 8aa5d281f8
7 changed files with 55 additions and 29 deletions
@@ -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'))
@@ -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()
}
@@ -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 })
})
)
@@ -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({
@@ -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<void> {
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')
}
@@ -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<void>
@@ -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) {