diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 02239421a..39f923cb2 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -206,6 +206,8 @@ export const electronViteConfig: UserConfig = { external: isExternalMainModule, input: { index: resolve('src/main/index.ts'), + // Why: sandboxed webview preloads cannot load Rollup helper chunks. + 'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'), 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'), 'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'), 'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'), diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 286f172a6..b93a03585 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -1512,6 +1512,38 @@ describe('browserManager', () => { expect(browserManager.getGuestWebContentsId('browser-destroyed-before-register')).toBeNull() }) + it('removes a destroyed primary guest from its tab registration maps', () => { + const guest = { + id: 306, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-destroyed-after-register', + webContentsId: guest.id, + rendererWebContentsId + }) + + const destroyedHandler = guestOnMock.mock.calls.find( + ([event]) => event === 'destroyed' + )?.[1] as (() => void) | undefined + destroyedHandler?.() + + expect(browserManager.getGuestWebContentsId('browser-destroyed-after-register')).toBeNull() + const managerState = browserManager as unknown as { + tabIdByWebContentsId: Map + } + expect(managerState.tabIdByWebContentsId.has(guest.id)).toBe(false) + }) + it('fully unregisters stale guests discovered during authorization', () => { const guest = { id: 305, diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 7dafca617..66f6ac653 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -640,7 +640,6 @@ export class BrowserManager { // Why: bot detectors probe APIs that differ in Electron webviews; inject overrides each load so manual browsing passes. const disposeAntiDetection = this.injectAntiDetection(guest) - // Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty. guest.setBackgroundThrottling(false) const installClickedLinkRouting = (): void => { @@ -940,11 +939,15 @@ export class BrowserManager { private retireStaleGuestWebContents(previousWebContentsId: number): void { // Why: after a renderer-process swap, stop the dead guest id resolving to the live page so stale callbacks don't hit the wrong session. this.cleanupGuestPolicyAttachment(previousWebContentsId) - this.tabIdByWebContentsId.delete(previousWebContentsId) } private cleanupGuestPolicyAttachment(guestWebContentsId: number): void { - const isPrimaryGuest = this.tabIdByWebContentsId.has(guestWebContentsId) + const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) + const isPrimaryGuest = browserTabId !== undefined + if (browserTabId && this.webContentsIdByTabId.get(browserTabId) === guestWebContentsId) { + this.webContentsIdByTabId.delete(browserTabId) + } + this.tabIdByWebContentsId.delete(guestWebContentsId) this.certificateTrustController?.onGuestRetired(guestWebContentsId) const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) if (policyCleanup) { diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index fc38ca95e..8a61f1575 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -74,6 +74,8 @@ import { } from './createMainWindow' import { ipcMain } from 'electron' import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification' +import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../shared/browser-window-close-policy' +import { fileURLToPath } from 'node:url' function withPlatform(platform: NodeJS.Platform, run: () => T): T { const original = process.platform @@ -248,6 +250,7 @@ describe('createMainWindow', () => { expect(allowBlankPrefs).toMatchObject({ disableHtmlFullscreenWindowResize: true, partition: 'persist:orca-browser', + preload: expect.stringMatching(/browser-window-close-preload\.js$/), sandbox: true }) @@ -262,6 +265,36 @@ describe('createMainWindow', () => { const guest = { marker: 'guest' } windowHandlers['did-attach-webview']({} as never, guest as never) expect(attachGuestPoliciesMock).toHaveBeenCalledWith(guest) + + const allowWindowCloseEvent = { preventDefault: vi.fn() } + const allowWindowCloseParams = { + src: 'data:text/html,', + preload: BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD + } + const allowWindowClosePrefs = { partition: 'persist:orca-browser' } + windowHandlers['will-attach-webview']( + allowWindowCloseEvent as never, + allowWindowClosePrefs as never, + allowWindowCloseParams as never + ) + expect(allowWindowCloseEvent.preventDefault).not.toHaveBeenCalled() + expect(allowWindowCloseParams.preload).toBeUndefined() + expect(allowWindowClosePrefs).not.toHaveProperty('preload') + + const allowWindowClosePathPrefs = { + partition: 'persist:orca-browser', + preload: fileURLToPath(BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD) + } + windowHandlers['will-attach-webview']( + { preventDefault: vi.fn() } as never, + allowWindowClosePathPrefs as never, + { src: 'data:text/html,', preload: '' } as never + ) + expect(allowWindowClosePathPrefs).not.toHaveProperty('preload') + + const cliGuest = { marker: 'cli-guest' } + windowHandlers['did-attach-webview']({} as never, cliGuest as never) + expect(attachGuestPoliciesMock).toHaveBeenLastCalledWith(cliGuest) }) it('sets platform-specific titlebar and frame options for every desktop platform', () => { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 519e18170..49c3eb4c1 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -10,6 +10,7 @@ import { screen } from 'electron' import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { is } from '@electron-toolkit/utils' import type { Store } from '../persistence' import { getAppIconPath } from '../app-icon' @@ -18,6 +19,7 @@ import { browserSessionRegistry } from '../browser/browser-session-registry' import { translateMain } from '../i18n/main-i18n' import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences' +import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../shared/browser-window-close-policy' import { isCrashReportReason } from '../../shared/crash-reporting' import { DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES, @@ -441,6 +443,8 @@ export function createMainWindow( // so register it with the window's other navigation policy. registerPluginPanelNavigationGuard(mainWindow.webContents) + const browserWindowClosePreload = join(__dirname, 'browser-window-close-preload.js') + const browserWindowCloseAllowedPreloadPath = fileURLToPath(BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD) mainWindow.webContents.on('will-attach-webview', (event, webPreferences, params) => { const src = typeof params.src === 'string' ? params.src : '' const normalizedSrc = normalizeBrowserNavigationUrl(src) @@ -452,7 +456,18 @@ export function createMainWindow( return } - delete webPreferences.preload + const allowWindowClose = [params.preload, webPreferences.preload].some( + (preload) => + preload === BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD || + preload === browserWindowCloseAllowedPreloadPath + ) + delete params.preload + if (allowWindowClose) { + delete webPreferences.preload + } else { + // Why: preload runs in the page's main world before inline scripts can call window.close(). + webPreferences.preload = browserWindowClosePreload + } // Why: older Electron builds expose preloadURL alongside preload; delete both so the guest can't inherit the main preload bridge. delete (webPreferences as Record).preloadURL webPreferences.nodeIntegration = false diff --git a/src/preload/browser-window-close-installation.ts b/src/preload/browser-window-close-installation.ts new file mode 100644 index 000000000..adf2fdb09 --- /dev/null +++ b/src/preload/browser-window-close-installation.ts @@ -0,0 +1,15 @@ +export function installBrowserWindowCloseGuard(): void { + const ignoreWindowClose = (): void => {} + try { + Object.defineProperty(window, 'close', { + configurable: false, + enumerable: false, + writable: false, + value: ignoreWindowClose + }) + } catch { + try { + window.close = ignoreWindowClose + } catch {} + } +} diff --git a/src/preload/browser-window-close.test.ts b/src/preload/browser-window-close.test.ts new file mode 100644 index 000000000..1b35a45f8 --- /dev/null +++ b/src/preload/browser-window-close.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { installBrowserWindowCloseGuard } from './browser-window-close-installation' + +describe('browser window close preload', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('installs a non-replaceable window.close no-op in the page world', () => { + const nativeClose = vi.fn() + vi.stubGlobal('window', { close: nativeClose }) + + installBrowserWindowCloseGuard() + + expect(window.close()).toBeUndefined() + expect(window.close).not.toBe(nativeClose) + expect(Reflect.set(window, 'close', nativeClose)).toBe(false) + }) +}) diff --git a/src/preload/browser-window-close.ts b/src/preload/browser-window-close.ts new file mode 100644 index 000000000..7fb820137 --- /dev/null +++ b/src/preload/browser-window-close.ts @@ -0,0 +1,7 @@ +import { installBrowserWindowCloseGuard } from './browser-window-close-installation' +import type { ContextBridge } from 'electron' + +// Why: raw require keeps the sandboxed preload standalone in the main-process CJS build. +const { contextBridge } = require('electron') as { contextBridge: ContextBridge } + +contextBridge.executeInMainWorld({ func: installBrowserWindowCloseGuard }) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 1a2c02795..60cc98d8d 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -3693,6 +3693,7 @@ function BrowserPagePane({ container, inputLocked: inputLockedRef.current, webviewPartition, + allowWindowClose: browserTab.allowWindowClose === true, resolveContainer: () => ensureBrowserPageViewport(browserTab.id, workspaceId)?.container ?? null }) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts b/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts index 203fce6fd..496b23ec4 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts +++ b/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences' +import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../../../shared/browser-window-close-policy' const registryMocks = vi.hoisted(() => ({ destroyPersistentWebview: vi.fn(), @@ -103,4 +104,22 @@ describe('BrowserPane webview preferences', () => { expect(ensuredWebview?.webview.style.pointerEvents).toBe('none') expect(refreshedContainer.lastElementChild).toBe(ensuredWebview?.webview as unknown as Element) }) + + it('marks explicitly allowed CLI pages before the guest attaches', () => { + const container = createContainer('cli-page') + + const ensuredWebview = ensureBrowserPageWebview({ + browserTabId: 'browser-page-cli', + container, + inputLocked: false, + webviewPartition: 'persist:orca-browser', + allowWindowClose: true, + resolveContainer: () => container + }) + + expect(ensuredWebview?.webview.getAttribute('preload')).toBe( + BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD + ) + expect(BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD).toMatch(/^file:/) + }) }) diff --git a/src/renderer/src/components/browser-pane/browser-page-webview.ts b/src/renderer/src/components/browser-pane/browser-page-webview.ts index 1179d0da6..b8c44d849 100644 --- a/src/renderer/src/components/browser-pane/browser-page-webview.ts +++ b/src/renderer/src/components/browser-pane/browser-page-webview.ts @@ -1,4 +1,5 @@ import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences' +import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../../../shared/browser-window-close-policy' import { destroyPersistentWebview, registerPersistentWebview, @@ -10,12 +11,14 @@ export function ensureBrowserPageWebview({ container, inputLocked, webviewPartition, + allowWindowClose, resolveContainer }: { browserTabId: string container: HTMLDivElement inputLocked: boolean webviewPartition: string + allowWindowClose?: boolean resolveContainer: () => HTMLDivElement | null }): { container: HTMLDivElement; created: boolean; webview: Electron.WebviewTag } | null { let webview = webviewRegistry.get(browserTabId) @@ -46,6 +49,10 @@ export function ensureBrowserPageWebview({ webview = document.createElement('webview') as Electron.WebviewTag webview.setAttribute('partition', webviewPartition) + if (allowWindowClose) { + // Why: main consumes and removes this marker in will-attach-webview before the guest sees any preload. + webview.setAttribute('preload', BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD) + } webview.setAttribute('allowpopups', '') // Why: Electron spreads the webpreferences keys verbatim, so the shared // camelCase attribute must stay intact for fullscreen containment to work. diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 1d956f716..0b7ea6ad0 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1077,6 +1077,11 @@ describe('useIpcEvents browser tab create routing', () => { expect(acquireBrowserAutomationVisibility).toHaveBeenCalledWith('page-new') expect(acquireBrowserAutomationVisibility).not.toHaveBeenCalledWith('page-active') + expect(state.createBrowserTab).toHaveBeenCalledWith( + 'wt-1', + 'https://example.com', + expect.objectContaining({ allowWindowClose: true }) + ) expect(replyTabCreate).toHaveBeenCalledWith({ requestId: 'req-create', browserPageId: 'page-new' diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 3c8b19068..2a72aae49 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2266,6 +2266,7 @@ export function useIpcEvents(): void { // Agent/automation opens stay in the background (activate:false) in the active browser group. const workspace = store.createBrowserTab(worktreeId, data.url, { title: data.url, + allowWindowClose: true, targetGroupId: data.activate ? undefined : activeBrowserUnifiedTab?.groupId, sessionProfileId: data.sessionProfileId, sessionPartition: data.sessionPartition, diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 60a71a3eb..42d0ddbce 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -57,6 +57,7 @@ import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktr type CreateBrowserTabOptions = { activate?: boolean title?: string + allowWindowClose?: boolean sessionProfileId?: string | null sessionPartition?: string | null // Place the new tab in a specific group (e.g. "Open Preview to the Side"); defaults to the worktree's active group. @@ -69,6 +70,7 @@ type CreateBrowserTabOptions = { type CreateBrowserPageOptions = { activate?: boolean title?: string + allowWindowClose?: boolean browserRuntimeEnvironmentId?: string | null } @@ -341,7 +343,8 @@ function buildBrowserPage( worktreeId: string, url: string, title?: string, - browserRuntimeEnvironmentId?: string | null + browserRuntimeEnvironmentId?: string | null, + allowWindowClose?: boolean ): BrowserPage { const normalizedUrl = normalizeUrl(url) return { @@ -357,6 +360,7 @@ function buildBrowserPage( canGoForward: false, loadError: null, createdAt: Date.now(), + ...(allowWindowClose !== undefined ? { allowWindowClose } : {}), ...(browserRuntimeEnvironmentId !== undefined ? { browserRuntimeEnvironmentId } : {}) } } @@ -554,7 +558,8 @@ export const createBrowserSlice: StateCreator = worktreeId, url, options?.title, - options?.browserRuntimeEnvironmentId + options?.browserRuntimeEnvironmentId, + options?.allowWindowClose ) // Why: with no explicit profile, inherit the user's default so a Settings preference applies to new tabs. const sessionProfileId = @@ -902,13 +907,15 @@ export const createBrowserSlice: StateCreator = activate: true, sessionProfileId, sessionPartition, - browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId + browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId, + allowWindowClose: firstPage.allowWindowClose }) for (const p of restPages) { get().createBrowserPage(restored.id, p.url, { activate: false, title: p.title, + allowWindowClose: p.allowWindowClose, browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId }) } @@ -987,7 +994,8 @@ export const createBrowserSlice: StateCreator = workspace.worktreeId, url, options?.title, - options?.browserRuntimeEnvironmentId + options?.browserRuntimeEnvironmentId, + options?.allowWindowClose ) set((s) => { @@ -1169,6 +1177,7 @@ export const createBrowserSlice: StateCreator = return get().createBrowserPage(workspaceId, pageToRestore.url, { title: pageToRestore.title, activate: true, + allowWindowClose: pageToRestore.allowWindowClose, browserRuntimeEnvironmentId: pageToRestore.browserRuntimeEnvironmentId }) }, diff --git a/src/shared/browser-window-close-policy.ts b/src/shared/browser-window-close-policy.ts new file mode 100644 index 000000000..2ea28169e --- /dev/null +++ b/src/shared/browser-window-close-policy.ts @@ -0,0 +1,2 @@ +// Why: this marker carries the CLI's explicit close policy across attach before the guest exists. +export const BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD = 'file:///__orca_window_close_allowed__' diff --git a/src/shared/types.ts b/src/shared/types.ts index 475470dd4..a03c24209 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -982,6 +982,8 @@ export type BrowserPage = { canGoForward: boolean loadError: BrowserLoadError | null createdAt: number + // Why: CLI-created pages retain Chromium's native window.close behavior; ordinary embedded pages are guarded. + allowWindowClose?: boolean // Why: remote-owned worktrees can still host client-local fallback browser // pages until headless remote runtimes support real browser panes. browserRuntimeEnvironmentId?: string | null diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index da5fe4ef0..f5d849d52 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -225,6 +225,7 @@ const browserPageSchema = z.object({ canGoForward: z.boolean(), loadError: browserLoadErrorSchema.nullable(), createdAt: z.number(), + allowWindowClose: z.boolean().optional(), // Why: explicit null marks a browser page as client-local even when its // worktree is remote-owned; older sessions omit it and keep inferred runtime. browserRuntimeEnvironmentId: z.string().nullable().optional(), diff --git a/tests/e2e/browser-tab.spec.ts b/tests/e2e/browser-tab.spec.ts index 7ac105580..bc8dd51e9 100644 --- a/tests/e2e/browser-tab.spec.ts +++ b/tests/e2e/browser-tab.spec.ts @@ -29,10 +29,11 @@ async function createBrowserTab( page: Parameters[0], worktreeId: string, url?: string, - title = 'New Browser Tab' + title = 'New Browser Tab', + allowWindowClose?: boolean ): Promise { return page.evaluate( - ({ targetWorktreeId, targetUrl, targetTitle }) => { + ({ targetWorktreeId, targetUrl, targetTitle, allowClose }) => { const store = window.__store if (!store) { return null @@ -44,12 +45,18 @@ async function createBrowserTab( targetUrl ?? state.browserDefaultUrl ?? 'about:blank', { title: targetTitle, - activate: true + activate: true, + allowWindowClose: allowClose } ) return { id: tab.id, pageId: tab.activePageId ?? null } }, - { targetWorktreeId: worktreeId, targetUrl: url, targetTitle: title } + { + targetWorktreeId: worktreeId, + targetUrl: url, + targetTitle: title, + allowClose: allowWindowClose + } ) } @@ -199,6 +206,71 @@ async function startBrowserLinkServer(): Promise<{ } } +async function startBrowserWindowCloseServer(): Promise<{ + url: string + sourceUrl: string + close: () => Promise +}> { + const server = createServer((request, response) => { + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + const pathname = new URL(request.url ?? '/', origin).pathname + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + if (pathname === '/source') { + response.end(` + + + Close link source + Open close page + + `) + return + } + response.end(` + + + Window close repro + +

Attempting close…

+ + + + `) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + return { + url: `http://127.0.0.1:${port}/window-close`, + sourceUrl: `http://127.0.0.1:${port}/source`, + close: () => closeServer(server) + } +} + +async function readBrowserWindowCloseStatus( + page: Parameters[0], + browserTabId: string +): Promise { + return page.evaluate(async (targetBrowserTabId) => { + const slot = document.querySelector(`[data-browser-overlay-tab-id="${targetBrowserTabId}"]`) + const webview = slot?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + return 'webview missing' + } + try { + return (await webview.executeJavaScript( + 'document.querySelector("#s")?.textContent ?? "status missing"' + )) as string + } catch { + return 'guest lost' + } + }, browserTabId) +} + async function closeServer(server: Server): Promise { await new Promise((resolve, reject) => server.close((error) => { @@ -689,6 +761,88 @@ test.describe('Browser Tab', () => { } }) + test('blocked window.close in a link-created tab does not break tab switching', async ({ + orcaPage + }) => { + const closeServer = await startBrowserWindowCloseServer() + try { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + const neighboringTab = await createBrowserTab( + orcaPage, + worktreeId, + 'about:blank', + 'Neighboring tab' + ) + const sourceTab = await createBrowserTab( + orcaPage, + worktreeId, + closeServer.sourceUrl, + 'Close link source' + ) + expect(neighboringTab?.id).toBeTruthy() + expect(sourceTab?.id).toBeTruthy() + + await clickBrowserLink(orcaPage, sourceTab!.id, '#window-close-link') + let closeTabId: string | null = null + await expect + .poll(async () => { + const tabs = await getBrowserTabs(orcaPage, worktreeId) + closeTabId = tabs.find((tab) => tab.url === closeServer.url)?.id ?? null + return closeTabId + }) + .not.toBeNull() + + await orcaPage.locator(`[data-tab-id="${neighboringTab!.id}"]`).click() + await expect.poll(async () => getActiveTabType(orcaPage), { timeout: 5_000 }).toBe('browser') + await expect + .poll(() => readBrowserWindowCloseStatus(orcaPage, closeTabId!), { timeout: 5_000 }) + .toContain('window.close() was blocked') + } finally { + await closeServer.close() + } + }) + + test('explicitly allowed browser tabs retain replaceable window.close', async ({ orcaPage }) => { + const closeServer = await startBrowserWindowCloseServer() + try { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + const allowedTab = await createBrowserTab( + orcaPage, + worktreeId, + closeServer.sourceUrl, + 'Allowed close tab', + true + ) + expect(allowedTab?.id).toBeTruthy() + + await expect + .poll( + () => + orcaPage.evaluate(async (targetBrowserTabId) => { + const slot = document.querySelector( + `[data-browser-overlay-tab-id="${targetBrowserTabId}"]` + ) + const webview = slot?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + return 'webview missing' + } + try { + return (await webview.executeJavaScript(`(() => { + window.close = () => 'replacement-called' + return window.close() === 'replacement-called' ? 'replacement-called' : 'blocked' + })()`)) as string + } catch { + return 'guest unavailable' + } + }, allowedTab!.id), + { timeout: 5_000 } + ) + .toBe('replacement-called') + } finally { + await closeServer.close() + } + }) + /** * User Prompt: * - Browser works and also retains state when switching tabs etc.