From d2bb60735b1c1a2e8d18cbcebb3bae0d9d20f8ba Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:00:10 -0700 Subject: [PATCH] test(browser): document blocked popup compatibility gates (#30537) * test(browser): expose native popup admission blockers with faithful app events * test(browser): probe Electron popup identity and inherited sandbox API limits * test(browser): handle requests without associated contents in popup probe * docs(browser): record blocked Electron popup compatibility gates and test routing * docs(browser): keep popup evidence in PR --- .github/workflows/pr.yml | 3 + config/scripts/ci-unit-files.mjs | 1 + config/scripts/pr-package-test-paths.mjs | 1 + config/scripts/vitest-node-runtime-files.mjs | 1 + ...pup-adapter-compatibility.electron.test.ts | 402 ++++++++++++++++++ .../browser-popup-adapter-probe-fixture.ts | 191 +++++++++ ...browser-popup-compatibility-cdp-fixture.ts | 57 +-- ...er-popup-compatibility-electron-fixture.ts | 69 +-- ...ser-popup-compatibility-manager-fixture.ts | 55 +++ .../browser-popup-compatibility-status.ts | 42 ++ .../browser-popup-native-window.test.ts | 142 ++++++- 11 files changed, 853 insertions(+), 111 deletions(-) create mode 100644 src/main/browser/browser-popup-adapter-compatibility.electron.test.ts create mode 100644 src/main/browser/browser-popup-adapter-probe-fixture.ts create mode 100644 src/main/browser/browser-popup-compatibility-manager-fixture.ts create mode 100644 src/main/browser/browser-popup-compatibility-status.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index beeed2b114d..b173efb769d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1147,6 +1147,8 @@ jobs: # probe carries its own in-process deadline. Four at once on a 4-vCPU runner starve each other # past those deadlines; serial, every probe owns the runner. - name: Test Linux Electron lifecycle boundary + env: + ORCA_BACKGROUND_LAUNCH: '1' run: >- xvfb-run --auto-servernum pnpm exec vitest run --config config/vitest.config.ts --no-file-parallelism @@ -1157,6 +1159,7 @@ jobs: src/main/browser/browser-route-dns-prefetch.electron.test.ts src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts src/main/browser/browser-popup-compatibility.electron.test.ts + src/main/browser/browser-popup-adapter-compatibility.electron.test.ts src/main/browser/browser-popup-native-compatibility.electron.test.ts src/main/browser/browser-popup-background-compatibility.electron.test.ts diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs index a8cc794828d..610a0ea177b 100644 --- a/config/scripts/ci-unit-files.mjs +++ b/config/scripts/ci-unit-files.mjs @@ -43,6 +43,7 @@ export const UNIT_EXCLUDE = [ 'src/main/browser/browser-route-dns-prefetch.electron.test.ts', 'src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts', 'src/main/browser/browser-popup-compatibility.electron.test.ts', + 'src/main/browser/browser-popup-adapter-compatibility.electron.test.ts', 'src/main/browser/browser-popup-native-compatibility.electron.test.ts', 'src/main/browser/browser-popup-background-compatibility.electron.test.ts', `${CROSS_VERSION_WIRE_DIR}**` diff --git a/config/scripts/pr-package-test-paths.mjs b/config/scripts/pr-package-test-paths.mjs index 14f0f83de5e..81d74ee3c31 100644 --- a/config/scripts/pr-package-test-paths.mjs +++ b/config/scripts/pr-package-test-paths.mjs @@ -6,6 +6,7 @@ export const LINUX_PACKAGE_TESTS = [ 'src/main/browser/browser-route-dns-prefetch.electron.test.ts', 'src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts', 'src/main/browser/browser-popup-compatibility.electron.test.ts', + 'src/main/browser/browser-popup-adapter-compatibility.electron.test.ts', 'src/main/browser/browser-popup-native-compatibility.electron.test.ts', 'src/main/browser/browser-popup-background-compatibility.electron.test.ts' ] diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 4387f935803..ef7364c1a08 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -61,6 +61,7 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/foreign-sqlite-readers/**/*.test.ts', 'src/main/browser/*cookie*.test.ts', // Playwright's CDP transport needs Node's WebSocket implementation. + 'src/main/browser/browser-popup-adapter-compatibility.electron.test.ts', 'src/main/browser/browser-popup-compatibility.electron.test.ts', 'src/main/browser/browser-popup-native-compatibility.electron.test.ts', 'src/main/browser/browser-popup-background-compatibility.electron.test.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 new file mode 100644 index 00000000000..e13b927abfa --- /dev/null +++ b/src/main/browser/browser-popup-adapter-compatibility.electron.test.ts @@ -0,0 +1,402 @@ +import { describe, expect, it } from 'vitest' +import type { Page } from 'playwright' +import { + browser, + closeChildren, + enablePopupAdapterProbe, + expectedSecurity, + guest, + origin, + receipts, + setPopupFixtureEvidenceName, + snapshots, + status +} from './browser-popup-compatibility-cdp-fixture' + +enablePopupAdapterProbe() +setPopupFixtureEvidenceName('adapter-electron-evidence.json') + +type CreationPath = 'adopted' | 'native' +const creationPaths = ['adopted', 'native'] as const + +function eventsSince(offset: number): Record[] { + return status().adapterProbe?.slice(offset) ?? [] +} + +async function childAt(url: string): Promise { + await expect + .poll(() => + browser + ?.contexts() + .flatMap((context) => context.pages()) + .some((candidate) => candidate.url() === url) + ) + .toBe(true) + const child = browser + ?.contexts() + .flatMap((context) => context.pages()) + .find((candidate) => candidate.url() === url) + if (!child) { + throw new Error('Missing probe child') + } + return child +} + +async function configure(page: Page, path: CreationPath): Promise { + await page.evaluate((path) => console.log(`orca-popup-fixture:probe-${path}`), path) +} + +async function installBurst(page: Page, count = 3): Promise { + await page.evaluate((count) => { + document.querySelector('#identity-burst')?.remove() + const button = document.createElement('button') + button.id = 'identity-burst' + button.onclick = () => { + for (let index = 0; index < count; index++) { + const child = window.open('/identical-child') + if (child) { + child.name = `original-request-${index}` + } + } + } + document.body.append(button) + }, count) +} + +function assertObservedCreationOrder(events: Record[], path: CreationPath): void { + const delivered = events.filter( + (event) => event.event === (path === 'adopted' ? 'createWindow' : 'did-create-window') + ) + const admitted = events.filter((event) => event.event === 'admission') + expect(delivered.length).toBe(admitted.length) + for (const [index, event] of delivered.entries()) { + expect(event.owner).toBe(admitted[index].owner) + expect(event.preferences).toMatchObject(expectedSecurity) + if (path === 'adopted') { + expect(event.callbackRequest).toBe(admitted[index].request) + } else { + expect(event.optionsArguments).toEqual([ + `--orca-popup-probe=${String(admitted[index].request)}` + ]) + } + const first = events.findIndex( + (candidate) => + candidate.event === 'did-start-navigation' && candidate.contentsId === event.contentsId + ) + expect(first).toBeGreaterThan(events.indexOf(event)) + expect( + events.find( + (candidate) => + candidate.event === 'before-request' && candidate.contentsId === event.contentsId + ) + ).toMatchObject({ observedBeforeRequest: true, webRtcPolicy: 'disable_non_proxied_udp' }) + } +} + +describe('Electron popup adapter API limits (not an admission safety gate)', () => { + it.each(creationPaths)( + 'records same-parent identical URLs with foreground, background and window intent: %s', + async (path) => { + expect(status().electronVersion).toBe('43.7.8') + const page = guest('probe-one') + await configure(page, path) + for (const modifier of [null, 'Shift', 'ControlOrMeta'] as const) { + const offset = status().adapterProbe?.length ?? 0 + const before = receipts.length + await installBurst(page) + await page.locator('#identity-burst').click(modifier ? { modifiers: [modifier] } : {}) + await expect + .poll( + () => + receipts.slice(before).filter((receipt) => receipt.path === '/identical-child').length + ) + .toBe(3) + await expect + .poll(() => eventsSince(offset).filter((event) => event.event === 'loaded').length) + .toBe(3) + const events = eventsSince(offset) + assertObservedCreationOrder(events, path) + expect( + events + .filter((event) => event.event === 'loaded') + .map((event) => event.frameName) + .sort() + ).toEqual(['original-request-0', 'original-request-1', 'original-request-2']) + const dispositions = events + .filter((event) => event.event === 'admission') + .map((event) => event.details) + expect(dispositions).toEqual( + Array.from({ length: 3 }, () => + expect.objectContaining({ + disposition: + modifier === 'Shift' ? 'new-window' : modifier ? 'background-tab' : 'foreground-tab' + }) + ) + ) + snapshots.push(status()) + await closeChildren(page) + } + } + ) + + it.each(creationPaths)( + 'records two parents sharing a session with distinct simultaneous intent: %s', + async (path) => { + const pages = [guest('probe-one'), guest('probe-two')] + for (const page of pages) { + await configure(page, path) + await installBurst(page, 1) + } + const offset = status().adapterProbe?.length ?? 0 + const before = receipts.length + await Promise.all([ + pages[0].locator('#identity-burst').click({ modifiers: ['Shift'] }), + pages[1].locator('#identity-burst').click({ modifiers: ['ControlOrMeta'] }) + ]) + await expect + .poll( + () => + receipts.slice(before).filter((receipt) => receipt.path === '/identical-child').length + ) + .toBe(2) + await expect + .poll(() => eventsSince(offset).filter((event) => event.event === 'loaded').length) + .toBe(2) + const events = eventsSince(offset) + const admitted = events.filter((event) => event.event === 'admission') + expect(new Set(admitted.map((event) => event.owner)).size).toBe(2) + // Each parent's delivery is observed independently; ordering across parents is not authority. + for (const owner of admitted.map((event) => event.owner)) { + assertObservedCreationOrder( + events.filter((event) => event.owner === owner || !event.owner), + path + ) + } + expect( + receipts + .slice(before) + .filter((receipt) => receipt.path === '/identical-child') + .every((receipt) => receipt.cookie === 'synthetic_session=local') + ).toBe(true) + snapshots.push(status()) + for (const page of pages) { + await closeChildren(page) + } + } + ) + + it.each(creationPaths)( + 'records immediate close, then reopen without inferring cancellation from delivery order: %s', + async (path) => { + const page = guest('probe-one') + await configure(page, path) + const offset = status().adapterProbe?.length ?? 0 + await page.evaluate(() => { + document.querySelector('#cancel-probe')?.remove() + const button = document.createElement('button') + button.id = 'cancel-probe' + button.onclick = () => { + window.open('/cancelled-child')?.close() + const child = window.open('/reopened-child') + if (child) { + child.name = 'reopened-original' + } + } + document.body.append(button) + }) + await page.locator('#cancel-probe').click({ modifiers: ['Shift'] }) + const reopened = await childAt(`${origin}/reopened-child`) + expect(await reopened.evaluate('window.name')).toBe('reopened-original') + const events = eventsSince(offset) + expect(events.filter((event) => event.event === 'admission')).toHaveLength(2) + expect( + events.filter( + (event) => event.event === (path === 'adopted' ? 'createWindow' : 'did-create-window') + ) + ).toHaveLength(2) + // Both creation callbacks precede the renderer's close(); this is not a missing-callback cancellation. + snapshots.push(status()) + await closeChildren(page) + } + ) + + it.each(creationPaths)( + 'cannot distinguish inherited script restrictions synchronously: %s', + async (path) => { + const records = await exerciseSandboxControls(path, 'script') + expect(records[0].preferences).toEqual(records[1].preferences) + expect(records[0].frameOrigin).toBe(records[1].frameOrigin) + if (path === 'adopted') { + expect(withoutRequestMarker(records[0].optionsPreferences)).toEqual( + withoutRequestMarker(records[1].optionsPreferences) + ) + } + } + ) + + it.each(creationPaths)( + 'exposes inherited flags on native OpenURL link options only: %s', + async (path) => { + const records = await exerciseSandboxControls(path, 'link') + const preferences = records.map((record) => record.optionsPreferences) + expect(preferences[0]).toHaveProperty('openerSandboxFlags', expect.any(Number)) + expect(preferences[1]).not.toHaveProperty('openerSandboxFlags') + expect(preferences[2]).not.toHaveProperty('openerSandboxFlags') + if (path === 'adopted') { + expect(records.every((record) => record.event === 'createWindow-missing')).toBe(true) + } + } + ) + + it('records baseline early tab dispatch for the restricted script with zero original-child requests', async () => { + const page = guest('local') + const before = receipts.length + const tabs = status().tabOpens.length + await installSandboxFrame(page, 'restricted', `${origin}/baseline-restricted`, 'script') + await page.frameLocator('#sandbox-probe').locator('#open').click() + await expect.poll(() => status().tabOpens.length).toBe(tabs + 1) + expect(status().tabOpens.at(-1)).toEqual({ + url: `${origin}/baseline-restricted`, + activate: true + }) + expect(receipts.slice(before).some((receipt) => receipt.path === '/baseline-restricted')).toBe( + false + ) + snapshots.push(status()) + }) + + it('reproduces production native admission attaching to an unrelated same-session app window', async () => { + const page = guest('probe-two') + await page.evaluate('console.log("orca-popup-fixture:probe-unrelated")') + await installBurst(page, 1) + const offset = status().adapterProbe?.length ?? 0 + await page.locator('#identity-burst').click({ modifiers: ['Shift'] }) + await childAt(`${origin}/identical-child`) + const events = eventsSince(offset) + const unrelated = events.find((event) => event.event === 'unrelated-window') + const admitted = events.find((event) => event.event === 'production-adapter-created') + expect(unrelated).toBeDefined() + expect(admitted?.contentsId).toBe(unrelated?.contentsId) + expect(events.filter((event) => event.event === 'production-adapter-created')).toHaveLength(1) + const realChild = events.find((event) => event.event === 'did-create-window') + expect(realChild?.contentsId).not.toBe(admitted?.contentsId) + snapshots.push(status()) + await page.evaluate('console.log("orca-popup-fixture:probe-cleanup")') + await closeChildren(page) + }) +}) + +function withoutRequestMarker(value: unknown): unknown { + if (typeof value !== 'object' || value === null) { + return value + } + return Object.fromEntries( + Object.entries(value).filter(([key]) => !['additionalArguments', 'session'].includes(key)) + ) +} + +async function installSandboxFrame( + page: Page, + sandbox: string, + destination: string, + kind: string +): Promise { + await page.evaluate( + ({ sandbox, destination, kind }) => { + document.querySelector('#sandbox-probe')?.remove() + const frame = document.createElement('iframe') + frame.id = 'sandbox-probe' + if (sandbox !== 'unsandboxed') { + frame.setAttribute( + 'sandbox', + `allow-scripts allow-popups${sandbox === 'escape' ? ' allow-popups-to-escape-sandbox' : ''}` + ) + } + frame.srcdoc = + kind === 'script' + ? `` + : `Open` + document.body.append(frame) + }, + { sandbox, destination, kind } + ) +} + +async function exerciseSandboxControls( + path: CreationPath, + kind: 'script' | 'link' +): Promise[]> { + const page = guest('probe-one') + await configure(page, path) + const records: Record[] = [] + for (const sandbox of ['restricted', 'escape', 'unsandboxed']) { + const destination = `${origin}/sandbox-${path}-${kind}-${sandbox}` + 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'] } : {}) + if (path === 'adopted' && kind === 'link') { + await expect + .poll(() => eventsSince(offset).some((event) => event.event === 'createWindow-missing')) + .toBe(true) + const creation = eventsSince(offset).find((event) => event.event === 'createWindow-missing') + if (!creation) { + throw new Error('Missing OpenURL options') + } + records.push(creation) + await new Promise((resolve) => setTimeout(resolve, 300)) + expect( + receipts.slice(initialReceipt).some((receipt) => destination.endsWith(receipt.path)) + ).toBe(false) + snapshots.push(status()) + await page.evaluate('console.log("orca-popup-fixture:probe-cleanup")') + continue + } + const child = await childAt(destination) + expect(await child.evaluate('window.opener === null')).toBe(true) + const before = receipts.length + await child.evaluate((action) => { + const form = document.createElement('form') + form.action = action + form.method = 'POST' + document.body.append(form) + form.submit() + }, `${origin}/sandbox-form-${path}-${kind}-${sandbox}`) + if (sandbox === 'restricted') { + await expect + .poll(() => eventsSince(offset).some((event) => event.event === 'sandbox-form-blocked')) + .toBe(true) + expect( + receipts.slice(before).some((receipt) => receipt.path.startsWith('/sandbox-form-')) + ).toBe(false) + expect(child.url()).toBe(destination) + } else { + await expect + .poll(() => + receipts.slice(before).some((receipt) => receipt.path.startsWith('/sandbox-form-')) + ) + .toBe(true) + } + await page.evaluate('console.log("orca-popup-fixture:snapshot")') + const creation = eventsSince(offset).find( + (event) => + event.event === + (path === 'adopted' + ? kind === 'script' + ? 'createWindow' + : 'createWindow-missing' + : 'did-create-window') + ) + if (!creation) { + throw new Error('Missing creation event') + } + records.push(creation) + expect(status().windowsHidden).toBe(true) + snapshots.push(status()) + await closeChildren(page) + } + return records +} diff --git a/src/main/browser/browser-popup-adapter-probe-fixture.ts b/src/main/browser/browser-popup-adapter-probe-fixture.ts new file mode 100644 index 00000000000..52a47943ae2 --- /dev/null +++ b/src/main/browser/browser-popup-adapter-probe-fixture.ts @@ -0,0 +1,191 @@ +import { app, BrowserWindow, type Session, type WebContents } from 'electron' +import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' +import { allowNativeBrowserPopup } from './browser-popup-native-window' + +export class BrowserPopupAdapterProbeFixture { + readonly events: Record[] = [] + private sequence = 0 + private readonly unrelatedWindows = new Set() + private readonly observedSessions = new WeakSet() + private readonly observedContents = new Set() + + constructor(private readonly writeStatus: () => void) { + app.on('browser-window-created', (_event, window) => { + this.record('browser-window-created', window.webContents) + }) + app.on('web-contents-created', (_event, contents) => { + this.record('web-contents-created', contents) + contents.once('will-frame-navigate', () => this.record('will-frame-navigate', contents)) + contents.once('did-start-navigation', () => this.record('did-start-navigation', contents)) + contents.on('console-message', ({ message }) => { + if (message.includes('Blocked form submission')) { + this.record('sandbox-form-blocked', contents, { message }) + } + }) + }) + } + + private record(event: string, contents: WebContents, extra: Record = {}): void { + const preferences = + 'getLastWebPreferences' in contents && typeof contents.getLastWebPreferences === 'function' + ? contents.getLastWebPreferences() + : null + this.events.push({ + event, + contentsId: contents.id, + url: contents.getURL(), + opener: contents.opener?.top?.url ?? null, + frameName: contents.mainFrame.name, + frameOrigin: contents.mainFrame.origin, + preferences, + ...extra + }) + this.writeStatus() + } + + install(guest: WebContents): void { + if (!this.observedSessions.has(guest.session)) { + this.observedSessions.add(guest.session) + guest.session.webRequest.onBeforeRequest((details, callback) => { + this.events.push({ + event: 'before-request', + contentsId: details.webContentsId, + url: details.url, + observedBeforeRequest: + details.webContentsId !== undefined && this.observedContents.has(details.webContentsId), + webRtcPolicy: details.webContents?.getWebRTCIPHandlingPolicy() + }) + this.writeStatus() + callback({}) + }) + } + let adopted = true + let unrelatedDuringAdmission = false + guest.on('console-message', ({ message }) => { + if (message === 'orca-popup-fixture:probe-native') { + adopted = false + } + if (message === 'orca-popup-fixture:probe-adopted') { + adopted = true + } + if (message === 'orca-popup-fixture:probe-unrelated') { + unrelatedDuringAdmission = true + } + if (message === 'orca-popup-fixture:snapshot') { + this.writeStatus() + } + if (message === 'orca-popup-fixture:probe-cleanup') { + for (const window of this.unrelatedWindows) { + if (!window.isDestroyed()) { + window.destroy() + } + } + this.unrelatedWindows.clear() + } + }) + guest.on('did-create-window', (window, details) => { + this.record('did-create-window', window.webContents, { + owner: guest.id, + optionsArguments: details.options.webPreferences?.additionalArguments, + optionsPreferences: details.options.webPreferences + }) + }) + guest.setWindowOpenHandler((details) => { + const request = ++this.sequence + this.events.push({ event: 'admission', request, owner: guest.id, details }) + this.writeStatus() + if (unrelatedDuringAdmission) { + unrelatedDuringAdmission = false + const response = allowNativeBrowserPopup({ + opener: guest, + targetUrl: details.url, + options: { show: false }, + onCreated: ({ nativeWindow }) => { + if (!nativeWindow) { + throw new Error('Missing native child') + } + this.record('production-adapter-created', nativeWindow.webContents, { + request, + owner: guest.id + }) + return nativeWindow.webContents + } + }) + const unrelated = new BrowserWindow({ + show: false, + webPreferences: { session: guest.session } + }) + this.unrelatedWindows.add(unrelated) + this.record('unrelated-window', unrelated.webContents, { request }) + unrelated.webContents.once('destroyed', () => + this.events.push({ event: 'unrelated-destroyed', request }) + ) + return response + } + const options = { + show: false, + webPreferences: { + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + session: guest.session, + additionalArguments: [`--orca-popup-probe=${request}`] + } + } + return { + action: 'allow', + overrideBrowserWindowOptions: options, + ...(adopted + ? { + createWindow: (childOptions: PopupChildWindowOptions) => { + const child = childOptions.webContents + if (child) { + this.record('createWindow', child, { + callbackRequest: request, + owner: guest.id, + optionsPreferences: childOptions.webPreferences + }) + } else { + this.events.push({ + event: 'createWindow-missing', + request, + owner: guest.id, + optionsPreferences: childOptions.webPreferences + }) + } + let contents: WebContents + if (child) { + contents = openPopupWithOriginBar( + { ...childOptions, show: false }, + details.url, + () => true + ).contentWebContents + } else { + const window = new BrowserWindow({ ...childOptions, show: false }) + this.unrelatedWindows.add(window) + contents = window.webContents + } + this.observe(contents, guest.id) + this.writeStatus() + return contents + } + } + : {}) + } + }) + // Observation only; native creation keeps Chromium's original navigation. + guest.prependListener('did-create-window', (window) => + this.observe(window.webContents, guest.id) + ) + } + + private observe(contents: WebContents, owner: number): void { + contents.setWebRTCIPHandlingPolicy('disable_non_proxied_udp') + this.observedContents.add(contents.id) + this.record('observer-attached', contents, { owner }) + contents.once('did-start-navigation', () => + this.record('first-navigation', contents, { owner }) + ) + contents.once('did-finish-load', () => this.record('loaded', contents, { owner })) + } +} diff --git a/src/main/browser/browser-popup-compatibility-cdp-fixture.ts b/src/main/browser/browser-popup-compatibility-cdp-fixture.ts index 96ac766eb04..998a29b8f2a 100644 --- a/src/main/browser/browser-popup-compatibility-cdp-fixture.ts +++ b/src/main/browser/browser-popup-compatibility-cdp-fixture.ts @@ -1,3 +1,4 @@ +import type { PopupCompatibilityStatus } from './browser-popup-compatibility-status' import { spawnProcess } from '@orca/process-host' import { createServer } from 'node:http' import { createServer as createPortServer } from 'node:net' @@ -43,40 +44,7 @@ export const artifactDir = process.env.ORCA_POPUP_FIXTURE_ARTIFACT_DIR export const snapshots: ReturnType[] = [] export const expectedSecurity = { sandbox: true, contextIsolation: true, nodeIntegration: false } -export function status(): { - revision: number - ready?: boolean - error?: string - windowsHidden: boolean - tabOpens: { url: string; activate: boolean }[] - blocked: number[] - inputs: string[] - childRequests: { - adopted: boolean - native: boolean - sessionMatches: boolean - security: typeof expectedSecurity - permission?: string - }[] - routeGranted: boolean - routeContents: { security: typeof expectedSecurity; webRtcPolicy: string }[] - native: { - presentationCalls: string[] - requests: { disposition: string; features: string; frameName: string; post: boolean }[] - creation: { - firstNavigationPolicy: string - firstNavigationOwner: number | null - firstNavigationTitle: string - }[] - windows: { - closed: boolean - title: string - fullscreen: boolean - focused: boolean - contentSize: number[] - }[] - } -} { +export function status(): PopupCompatibilityStatus { return JSON.parse(readFileSync(statusPath, 'utf8')) } @@ -116,7 +84,9 @@ const server = createServer((request, response) => { } response.setHeader('Content-Type', 'text/html') response.end( - ['/local', '/route', '/local-control', '/route-control'].includes(path) + ['/local', '/route', '/local-control', '/route-control', '/probe-one', '/probe-two'].includes( + path + ) ? popupFixtureHtml(origin) : `Hostile synthetic title${path}` ) @@ -194,7 +164,8 @@ beforeAll(async () => { ORCA_BACKGROUND_LAUNCH: '1', ORCA_POPUP_FIXTURE_ORIGIN: origin, ORCA_POPUP_FIXTURE_STATUS: statusPath, - ORCA_POPUP_FIXTURE_NATIVE_CONTROLS: nativeControls ? '1' : '0' + ORCA_POPUP_FIXTURE_NATIVE_CONTROLS: nativeControls ? '1' : '0', + ORCA_POPUP_FIXTURE_ADAPTER_PROBE: adapterProbe ? '1' : '0' }, stdio: ['ignore', 'pipe', 'pipe'] }) @@ -215,9 +186,13 @@ beforeAll(async () => { return current.ready === true }) expect(status()).toMatchObject({ ready: true, windowsHidden: true }) - for (const name of nativeControls - ? ['local', 'route', 'local-control', 'route-control'] - : ['local', 'route']) { + const names = [ + 'local', + 'route', + ...(nativeControls ? ['local-control', 'route-control'] : []), + ...(adapterProbe ? ['probe-one', 'probe-two'] : []) + ] + for (const name of names) { await waitForFixture(() => Boolean( browser @@ -306,6 +281,10 @@ export async function closeChildren(page: Page): Promise { let evidenceName = 'electron-evidence.json' let nativeControls = false +let adapterProbe = false +export function enablePopupAdapterProbe(): void { + adapterProbe = true +} export function enablePopupFixtureNativeControls(): void { nativeControls = true } diff --git a/src/main/browser/browser-popup-compatibility-electron-fixture.ts b/src/main/browser/browser-popup-compatibility-electron-fixture.ts index d81b21c5bfe..b6ba2bdb987 100644 --- a/src/main/browser/browser-popup-compatibility-electron-fixture.ts +++ b/src/main/browser/browser-popup-compatibility-electron-fixture.ts @@ -9,10 +9,9 @@ import { } from 'electron' import { renameSync, writeFileSync } from 'node:fs' import { applyBackgroundActivationPolicy } from '../window/foreground-activation-policy' -import { BrowserManager } from './browser-manager' +import { BrowserPopupCompatibilityManager } from './browser-popup-compatibility-manager-fixture' import { BrowserRouteWebContentsRegistry } from './browser-route-webcontents-registry' import { initializeBrowserProcessUserAgent } from './browser-process-user-agent' -import type { PopupChildWindowOptions } from './popup-origin-bar-window' import { BrowserPopupNativeFixture, popupFixtureContentSecurity as contentSecurity @@ -21,6 +20,7 @@ import type { BrowserRouteGuestLifecycleClaim, BrowserRoutePageGuestIdentity } from './browser-route-page-authority' +import { BrowserPopupAdapterProbeFixture } from './browser-popup-adapter-probe-fixture' const origin = process.env.ORCA_POPUP_FIXTURE_ORIGIN const statusPath = process.env.ORCA_POPUP_FIXTURE_STATUS @@ -32,54 +32,7 @@ const fixtureStatusPath = statusPath applyBackgroundActivationPolicy() initializeBrowserProcessUserAgent('clean') -class FixtureBrowserManager extends BrowserManager { - tabOpens: { url: string; activate: boolean | undefined }[] = [] - childRequests: { - adopted: boolean - native: boolean - sessionMatches: boolean - security: ReturnType - permission?: string - }[] = [] - protected override openLinkInOrcaTab(pageId: string, url: string, activate?: boolean): boolean { - this.tabOpens.push({ url, activate }) - writeStatus() - return super.openLinkInOrcaTab(pageId, url, activate) - } - protected override createPopupChildWindowWithOriginBar( - opener: WebContents, - targetUrl: string, - options: PopupChildWindowOptions - ): WebContents { - const contents = super.createPopupChildWindowWithOriginBar(opener, targetUrl, options) - const record = { - adopted: Boolean(options.webContents), - native: Boolean(options.nativeWindow), - sessionMatches: contents.session === opener.session, - security: contentSecurity(contents), - permission: 'pending' - } - this.childRequests.push(record) - if (!options.webContents) { - contents.once('did-finish-load', () => { - void contents - .executeJavaScript('Notification.requestPermission()') - .then((permission: unknown) => { - record.permission = typeof permission === 'string' ? permission : 'unknown' - writeStatus() - }) - .catch(() => { - record.permission = 'unavailable' - writeStatus() - }) - }) - } - writeStatus() - return contents - } -} - -const manager = new FixtureBrowserManager() +const manager = new BrowserPopupCompatibilityManager(() => writeStatus()) const guests = new Map() const hosts: BrowserWindow[] = [] const blocked: number[] = [] @@ -95,6 +48,10 @@ const authority = Symbol('fixture-route-authority') let fixtureAuthority: symbol | null = authority let fixtureAuthorityThrows = false let revision = 0 +const adapterProbe = + process.env.ORCA_POPUP_FIXTURE_ADAPTER_PROBE === '1' + ? new BrowserPopupAdapterProbeFixture(() => writeStatus()) + : null const native = new BrowserPopupNativeFixture({ routeSession: () => fixtureRouteSession, failAuthority: (throwOnRead) => { @@ -129,6 +86,8 @@ function writeStatus(error?: string): void { })), blocked, inputs, + electronVersion: process.versions.electron, + adapterProbe: adapterProbe?.events, windowsHidden: BaseWindow.getAllWindows().every((window) => !window.isVisible()), guestIds: Object.fromEntries([...guests].map(([name, guest]) => [name, guest.id])) }) @@ -164,7 +123,11 @@ async function createHost(name: string, partition: string, nativeControl = false writeStatus() }) if (nativeControl) { - native.installControlHandler(guest) + if (adapterProbe) { + adapterProbe.install(guest) + } else { + native.installControlHandler(guest) + } await guest.loadURL(`${fixtureOrigin}/${name}`) resolve() return @@ -298,6 +261,10 @@ async function run(): Promise { await createHost('local-control', localPartition, true) await createHost('route-control', routePartition, true) } + if (adapterProbe) { + await createHost('probe-one', localPartition, true) + await createHost('probe-two', localPartition, true) + } ready = true writeStatus() } diff --git a/src/main/browser/browser-popup-compatibility-manager-fixture.ts b/src/main/browser/browser-popup-compatibility-manager-fixture.ts new file mode 100644 index 00000000000..bffd2240819 --- /dev/null +++ b/src/main/browser/browser-popup-compatibility-manager-fixture.ts @@ -0,0 +1,55 @@ +import type { WebContents } from 'electron' +import { BrowserManager } from './browser-manager' +import type { PopupChildWindowOptions } from './popup-origin-bar-window' +import { popupFixtureContentSecurity as contentSecurity } from './browser-popup-native-electron-fixture' + +export class BrowserPopupCompatibilityManager extends BrowserManager { + constructor(private readonly writeStatus: () => void) { + super() + } + + tabOpens: { url: string; activate: boolean | undefined }[] = [] + childRequests: { + adopted: boolean + native: boolean + sessionMatches: boolean + security: ReturnType + permission?: string + }[] = [] + protected override openLinkInOrcaTab(pageId: string, url: string, activate?: boolean): boolean { + this.tabOpens.push({ url, activate }) + this.writeStatus() + return super.openLinkInOrcaTab(pageId, url, activate) + } + protected override createPopupChildWindowWithOriginBar( + opener: WebContents, + targetUrl: string, + options: PopupChildWindowOptions + ): WebContents { + const contents = super.createPopupChildWindowWithOriginBar(opener, targetUrl, options) + const record = { + adopted: Boolean(options.webContents), + native: Boolean(options.nativeWindow), + sessionMatches: contents.session === opener.session, + security: contentSecurity(contents), + permission: 'pending' + } + this.childRequests.push(record) + if (!options.webContents) { + contents.once('did-finish-load', () => { + void contents + .executeJavaScript('Notification.requestPermission()') + .then((permission: unknown) => { + record.permission = typeof permission === 'string' ? permission : 'unknown' + this.writeStatus() + }) + .catch(() => { + record.permission = 'unavailable' + this.writeStatus() + }) + }) + } + this.writeStatus() + return contents + } +} diff --git a/src/main/browser/browser-popup-compatibility-status.ts b/src/main/browser/browser-popup-compatibility-status.ts new file mode 100644 index 00000000000..ba8704ce854 --- /dev/null +++ b/src/main/browser/browser-popup-compatibility-status.ts @@ -0,0 +1,42 @@ +type PopupFixtureSecurity = { + sandbox: boolean + contextIsolation: boolean + nodeIntegration: boolean +} + +export type PopupCompatibilityStatus = { + electronVersion: string + adapterProbe?: Record[] + revision: number + ready?: boolean + error?: string + windowsHidden: boolean + tabOpens: { url: string; activate: boolean }[] + blocked: number[] + inputs: string[] + childRequests: { + adopted: boolean + native: boolean + sessionMatches: boolean + security: PopupFixtureSecurity + permission?: string + }[] + routeGranted: boolean + routeContents: { security: PopupFixtureSecurity; webRtcPolicy: string }[] + native: { + presentationCalls: string[] + requests: { disposition: string; features: string; frameName: string; post: boolean }[] + creation: { + firstNavigationPolicy: string + firstNavigationOwner: number | null + firstNavigationTitle: string + }[] + windows: { + closed: boolean + title: string + fullscreen: boolean + focused: boolean + contentSize: number[] + }[] + } +} diff --git a/src/main/browser/browser-popup-native-window.test.ts b/src/main/browser/browser-popup-native-window.test.ts index 9f9107445b4..6f143e0d814 100644 --- a/src/main/browser/browser-popup-native-window.test.ts +++ b/src/main/browser/browser-popup-native-window.test.ts @@ -1,18 +1,11 @@ import { EventEmitter } from 'node:events' import { afterEach, describe, expect, it, vi } from 'vitest' -const { events } = vi.hoisted(() => ({ events: new Map void>() })) -vi.mock('electron', () => ({ - app: { - prependOnceListener: (name: string, listener: (...args: unknown[]) => void) => - events.set(name, listener), - off: (name: string, listener: (...args: unknown[]) => void) => { - if (events.get(name) === listener) { - events.delete(name) - } - } - } -})) +const { events } = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events') + return { events: new EventEmitter() } +}) +vi.mock('electron', () => ({ app: events })) import { allowNativeBrowserPopup, @@ -21,7 +14,7 @@ import { } from './browser-popup-native-window' afterEach(() => { - events.clear() + events.removeAllListeners() vi.unstubAllEnvs() }) @@ -85,9 +78,9 @@ describe('native popup admission', () => { minHeight: 200, webPreferences: { session: opener.session } }) - expect(events.has('browser-window-created')).toBe(true) + expect(events.listenerCount('browser-window-created') > 0).toBe(true) await Promise.resolve() - expect(events.has('browser-window-created')).toBe(false) + expect(events.listenerCount('browser-window-created') > 0).toBe(false) expect(onCreated).not.toHaveBeenCalled() }) @@ -108,9 +101,6 @@ describe('native popup admission', () => { options: {}, onCreated }) - const superseded = vi.fn(() => scripted.browserWindow.webContents) - admit(superseded) - await Promise.resolve() admit(onScripted) await Promise.resolve() // An OpenURL child admitted during its own turn must not consume the pending script admission. @@ -121,7 +111,7 @@ describe('native popup admission', () => { options: {}, onCreated: onLinked }) - events.get('browser-window-created')?.({}, linked.browserWindow) + events.emit('browser-window-created', {}, linked.browserWindow) await Promise.resolve() opener.contents.emit('did-create-window', linked.browserWindow) expect(onLinked).toHaveBeenCalledOnce() @@ -130,7 +120,6 @@ describe('native popup admission', () => { expect(onScripted).toHaveBeenCalledWith( expect.objectContaining({ nativeWindow: scripted.browserWindow }) ) - expect(superseded).not.toHaveBeenCalled() const preventDefault = vi.fn() scripted.window.emit('page-title-updated', { preventDefault }, 'Spoofed title') expect(preventDefault).toHaveBeenCalledOnce() @@ -159,6 +148,117 @@ describe('native popup admission', () => { expect(first.window.destroy).not.toHaveBeenCalled() }) + it.fails('KNOWN BLOCKER: a later scripted admission must not supersede the earlier request', async () => { + const opener = nativeWindow() + const first = nativeWindow() + const second = nativeWindow() + first.contents.session = opener.contents.session + second.contents.session = opener.contents.session + const earlier = vi.fn(() => first.browserWindow.webContents) + const later = vi.fn(() => second.browserWindow.webContents) + for (const onCreated of [earlier, later]) { + allowNativeBrowserPopup({ + opener: opener.browserWindow.webContents, + targetUrl: 'https://example.com/identical', + options: { show: false }, + onCreated + }) + await Promise.resolve() + } + opener.contents.emit('did-create-window', second.browserWindow) + opener.contents.emit('did-create-window', first.browserWindow) + expect(later).toHaveBeenCalledWith( + expect.objectContaining({ nativeWindow: second.browserWindow }) + ) + expect(earlier).toHaveBeenCalledWith( + expect.objectContaining({ nativeWindow: first.browserWindow }) + ) + }) + + it.fails('KNOWN BLOCKER: canceled A must not transfer B to a late child of A', async () => { + const opener = nativeWindow() + const lateA = nativeWindow() + lateA.contents.session = opener.contents.session + const onA = vi.fn(() => lateA.browserWindow.webContents) + const onB = vi.fn(() => lateA.browserWindow.webContents) + for (const onCreated of [onA, onB]) { + allowNativeBrowserPopup({ + opener: opener.browserWindow.webContents, + targetUrl: 'https://example.com/identical', + options: { show: false }, + onCreated + }) + await Promise.resolve() + } + opener.contents.emit('did-create-window', lateA.browserWindow) + expect(onB).not.toHaveBeenCalled() + }) + + it.fails('KNOWN BLOCKER: shared-session parents must not both prepare the same app notification', () => { + const first = nativeWindow() + const second = nativeWindow() + const child = nativeWindow() + second.contents.session = first.contents.session + child.contents.session = first.contents.session + const onFirst = vi.fn(() => child.browserWindow.webContents) + const onSecond = vi.fn(() => child.browserWindow.webContents) + for (const [opener, onCreated] of [ + [first.browserWindow.webContents, onFirst], + [second.browserWindow.webContents, onSecond] + ] as const) { + allowNativeBrowserPopup({ + opener, + targetUrl: 'https://example.com/identical', + options: { show: false }, + onCreated + }) + } + events.emit('browser-window-created', {}, child.browserWindow) + expect(onSecond).toHaveBeenCalledOnce() + expect(onFirst).not.toHaveBeenCalled() + }) + + it.fails.each([true, false])( + 'KNOWN BLOCKER: an unrelated app window receives neither policy nor disposal (same session: %s)', + async (sameSession) => { + const opener = nativeWindow() + const unrelated = nativeWindow() + if (sameSession) { + unrelated.contents.session = opener.contents.session + } + const onCreated = vi.fn(() => unrelated.browserWindow.webContents) + allowNativeBrowserPopup({ + opener: opener.browserWindow.webContents, + targetUrl: 'https://example.com/identical', + options: { show: false }, + onCreated + }) + events.emit('browser-window-created', {}, unrelated.browserWindow) + await Promise.resolve() + expect(onCreated).not.toHaveBeenCalled() + expect(unrelated.contents.close).not.toHaveBeenCalled() + expect(unrelated.window.destroy).not.toHaveBeenCalled() + } + ) + + it('handles duplicate did-create-window delivery idempotently for an already marked child', async () => { + const opener = nativeWindow() + const child = nativeWindow() + child.contents.session = opener.contents.session + const onCreated = vi.fn(() => child.browserWindow.webContents) + allowNativeBrowserPopup({ + opener: opener.browserWindow.webContents, + targetUrl: 'https://example.com/identical', + options: { show: false }, + onCreated + }) + await Promise.resolve() + opener.contents.emit('did-create-window', child.browserWindow) + opener.contents.emit('did-create-window', child.browserWindow) + expect(onCreated).toHaveBeenCalledOnce() + expect(child.contents.close).not.toHaveBeenCalled() + }) + it.each([ 'ready', 'background-launch', @@ -197,7 +297,7 @@ describe('native popup admission', () => { onCreated: prepare }) expect(response.overrideBrowserWindowOptions?.show).toBe(false) - events.get('browser-window-created')?.({}, browserWindow) + events.emit('browser-window-created', {}, browserWindow) expect(prepare).toHaveBeenCalledOnce() expect(window.showInactive).not.toHaveBeenCalled() if (state === 'closed') {