diff --git a/src/main/browser/guest-lifecycle/manager/popups/browser-manager-guest-popup-policy.ts b/src/main/browser/guest-lifecycle/manager/popups/browser-manager-guest-popup-policy.ts index 57a4252c0ef..bb23cd8463a 100644 --- a/src/main/browser/guest-lifecycle/manager/popups/browser-manager-guest-popup-policy.ts +++ b/src/main/browser/guest-lifecycle/manager/popups/browser-manager-guest-popup-policy.ts @@ -3,11 +3,15 @@ import { normalizeExternalBrowserUrl, redactKagiSessionToken } from '../../../../../shared/browser-url' -import { trackBrowserGuestInputGesture } from '../../input/browser-guest-input-gesture' +import { + trackBrowserGuestInputGesture, + type ObservedBrowserGuestGesture +} from '../../input/browser-guest-input-gesture' import { isNewBrowserTabPopupIntent, isBrowserTabPopupRequest, - normalizeBrowserPopupUrl + normalizeBrowserPopupUrl, + type NewBrowserTabPopupRequest } from '../../popups/browser-popup-new-tab-intent' import { SAFE_POPUP_WINDOW_OPTIONS } from '../../popups/presentation/browser-popup-window-policy' import { safeOrigin } from '../../browser-event-origin' @@ -37,45 +41,16 @@ export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavig guest.setWindowOpenHandler(({ url, frameName, disposition, postBody, features, referrer }) => { // Spend activation even on popups so it cannot fund a later tab. const observed = gesture.consume() + if (this.openNewTabPopup(guest, { url, frameName, disposition, postBody }, observed)) { + return { action: 'deny' } + } const clicked = observed !== null // Only the desktop user's own click may bring a popup forward; agents and late opens wait. const showWhenAppActive = observed?.byAgent !== false const activateTab = disposition !== 'background-tab' const agentOpened = observed?.byAgent !== false && this.isAgentUsingGuest(guest.id) - const ownerContext = this.resolvePopupOwnerContext(guest.id) - const browserTabId = ownerContext?.browserTabId ?? null + const browserTabId = this.resolvePopupOwnerContext(guest.id)?.browserTabId ?? null const externalUrl = normalizeExternalBrowserUrl(url) - const newTabUrl = externalUrl ?? (isLocalFileLinkFromLocalPage(guest, url) ? url : null) - // Opener-dependent requests retain a child window instead of becoming tabs. - if ( - ownerContext && - newTabUrl && - isNewBrowserTabPopupIntent({ url, frameName, disposition, postBody }) - ) { - // Scripted opens persist as tabs, so opens without observed input consume a budget. - if (!clicked && !this.tryConsumePageInitiatedTab(ownerContext.rootGuestWebContentsId)) { - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(newTabUrl), - action: 'blocked' - }) - return { action: 'deny' } - } - // A new tab's download would miss the agent's `download` claim, so save the link here. - if (this.isAwaitingDownloadFrom(guest.id, ownerContext.rootGuestWebContentsId)) { - guest.downloadURL(newTabUrl) - return { action: 'deny' } - } - if ( - this.openLinkInOrcaTab(ownerContext.browserTabId, newTabUrl, activateTab, agentOpened) - ) { - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(newTabUrl), - action: 'opened-in-orca' - }) - } - // A vanished renderer must not turn new-tab intent into a native popup. - return { action: 'deny' } - } // Native popups must not target local files. const canOpenAsChild = normalizeBrowserPopupUrl(url) !== null @@ -148,6 +123,47 @@ export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavig } } + /** Answers a new-tab request as an Orca tab; false leaves opener-dependent requests to a child window. */ + openNewTabPopup( + guest: Electron.WebContents, + request: NewBrowserTabPopupRequest, + observed: ObservedBrowserGuestGesture | null + ): boolean { + const ownerContext = this.resolvePopupOwnerContext(guest.id) + const newTabUrl = + normalizeExternalBrowserUrl(request.url) ?? + (isLocalFileLinkFromLocalPage(guest, request.url) ? request.url : null) + if (!ownerContext || !newTabUrl || !isNewBrowserTabPopupIntent(request)) { + return false + } + // Scripted opens persist as tabs, so opens without observed input consume a budget. + if (!observed && !this.tryConsumePageInitiatedTab(ownerContext.rootGuestWebContentsId)) { + this.forwardOrQueuePopupEvent(guest.id, { origin: safeOrigin(newTabUrl), action: 'blocked' }) + return true + } + // A new tab's download would miss the agent's `download` claim, so save the link here. + if (this.isAwaitingDownloadFrom(guest.id, ownerContext.rootGuestWebContentsId)) { + guest.downloadURL(newTabUrl) + return true + } + const agentOpened = observed?.byAgent !== false && this.isAgentUsingGuest(guest.id) + if ( + this.openLinkInOrcaTab( + ownerContext.browserTabId, + newTabUrl, + request.disposition !== 'background-tab', + agentOpened + ) + ) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(newTabUrl), + action: 'opened-in-orca' + }) + } + // A vanished renderer must not turn new-tab intent into a native popup. + return true + } + requestExternalAppLaunch( guest: Electron.WebContents, rawUrl: string | undefined, diff --git a/src/main/browser/guest-lifecycle/popups/browser-popup-new-tab-intent.ts b/src/main/browser/guest-lifecycle/popups/browser-popup-new-tab-intent.ts index c22e66e4092..dbc7bcee9d1 100644 --- a/src/main/browser/guest-lifecycle/popups/browser-popup-new-tab-intent.ts +++ b/src/main/browser/guest-lifecycle/popups/browser-popup-new-tab-intent.ts @@ -1,10 +1,12 @@ -// Keep named and OAuth popups attached to their opener; tab conversion returns null to the page. -export function isNewBrowserTabPopupIntent(details: { +export type NewBrowserTabPopupRequest = { url: string frameName: string disposition: string postBody?: Electron.PostBody | null -}): boolean { +} + +// Keep named and OAuth popups attached to their opener; tab conversion returns null to the page. +export function isNewBrowserTabPopupIntent(details: NewBrowserTabPopupRequest): boolean { return ( isBrowserTabPopupRequest(details) && !isBlankBrowserPopupUrl(details.url) && diff --git a/src/main/browser/routing/guest-authority/browser-route-guest-popups.ts b/src/main/browser/routing/guest-authority/browser-route-guest-popups.ts index 2c48482178e..ebe0199e4bd 100644 --- a/src/main/browser/routing/guest-authority/browser-route-guest-popups.ts +++ b/src/main/browser/routing/guest-authority/browser-route-guest-popups.ts @@ -3,11 +3,15 @@ import '../../../../shared/constants' import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/browser/browser-blank-page' import { isBlankBrowserPopupUrl, - normalizeBrowserPopupUrl + normalizeBrowserPopupUrl, + type NewBrowserTabPopupRequest } from '../../guest-lifecycle/popups/browser-popup-new-tab-intent' import { enforceBrowserRouteWebRtcPolicy } from '../../network/egress/browser-route-webrtc-policy' import { trackBrowserGuestInputGesture } from '../../guest-lifecycle/input/browser-guest-input-gesture' -import type { BrowserGuestInputGesture } from '../../guest-lifecycle/input/browser-guest-input-gesture' +import type { + BrowserGuestInputGesture, + ObservedBrowserGuestGesture +} from '../../guest-lifecycle/input/browser-guest-input-gesture' import { registerBrowserRouteGuestPopup, releaseBrowserRouteGuestPopup @@ -28,6 +32,12 @@ export type BrowserRouteGuestPopupDependencies = { getPartitionForSession: (session: Session) => string | null /** Route guests replace the manager's popup handler, so a denial is otherwise entirely silent. */ reportBlockedPopup?: (input: { openerWebContentsId: number; url: string }) => void + /** The manager's new-tab policy; true when it answered the request. */ + openNewTabPopup?: ( + source: WebContents, + request: NewBrowserTabPopupRequest, + observed: ObservedBrowserGuestGesture | null + ) => boolean openPopupWindow?: ( options: PopupChildWindowOptions, targetUrl: string, @@ -172,13 +182,23 @@ export function createBrowserRouteGuestPopupController(input: { return (details) => { const observed = gesture.consume() const normalized = normalizeBrowserPopupUrl(details?.url) + const permitted = !disposed && normalized !== null && navigationAllowed(normalized) if ( - disposed || - !normalized || - popups.size >= MAX_BROWSER_ROUTE_GUEST_POPUPS || - !navigationAllowed(normalized) || - !observed + permitted && + input.dependencies.openNewTabPopup?.( + source, + { + url: details?.url ?? normalized, + frameName: details?.frameName ?? '', + disposition: details?.disposition ?? '', + postBody: details?.postBody + }, + observed + ) ) { + return { action: 'deny' } + } + if (!permitted || popups.size >= MAX_BROWSER_ROUTE_GUEST_POPUPS || !observed) { if (!disposed) { reportBlockedPopup(details?.url) } diff --git a/src/main/browser/routing/guest-authority/browser-route-webcontents-registry-dependencies.ts b/src/main/browser/routing/guest-authority/browser-route-webcontents-registry-dependencies.ts index 43d0ab555cf..c0c3be335b0 100644 --- a/src/main/browser/routing/guest-authority/browser-route-webcontents-registry-dependencies.ts +++ b/src/main/browser/routing/guest-authority/browser-route-webcontents-registry-dependencies.ts @@ -4,6 +4,7 @@ import type { BrowserRoutePageOwnerIdentity } from './browser-route-page-authority' import type { BrowserRouteSessionRekey } from '../sessions/browser-route-session-state' +import type { BrowserRouteGuestPopupDependencies } from './browser-route-guest-popups' export type BrowserRouteWebContentsRegistryDependencies = { getPartitionForSession(session: Session): string | null @@ -16,5 +17,6 @@ export type BrowserRouteWebContentsRegistryDependencies = { retirePreparedPagesOwnedByRenderer(rendererWebContentsId: number): number /** Route-guest popup denials need an explicit notice on the owning page. */ reportBlockedPopup?(input: { openerWebContentsId: number; url: string }): void + openNewTabPopup?: BrowserRouteGuestPopupDependencies['openNewTabPopup'] maxGuests?: number } diff --git a/src/main/browser/routing/guest-authority/browser-route-webcontents-registry.ts b/src/main/browser/routing/guest-authority/browser-route-webcontents-registry.ts index 044ac5e099b..4aeded40a01 100644 --- a/src/main/browser/routing/guest-authority/browser-route-webcontents-registry.ts +++ b/src/main/browser/routing/guest-authority/browser-route-webcontents-registry.ts @@ -73,6 +73,7 @@ export class BrowserRouteWebContentsRegistry { getPartitionForSession: (routeSession) => this.dependencies.getPartitionForSession(routeSession), reportBlockedPopup: (blocked) => this.dependencies.reportBlockedPopup?.(blocked), + openNewTabPopup: this.dependencies.openNewTabPopup, isPopupNavigationAllowed: (url) => this.navigationAllowed(state, url, false) }) } catch { diff --git a/src/main/browser/routing/sessions/browser-route-session-runtime.ts b/src/main/browser/routing/sessions/browser-route-session-runtime.ts index 9ec15ac14fc..34b376641a3 100644 --- a/src/main/browser/routing/sessions/browser-route-session-runtime.ts +++ b/src/main/browser/routing/sessions/browser-route-session-runtime.ts @@ -88,7 +88,9 @@ export const browserRouteWebContentsRegistry = new BrowserRouteWebContentsRegist retirePreparedPage: (page) => browserRouteSessionRegistry.retirePreparedPage(page), retirePreparedPagesOwnedByRenderer: (rendererWebContentsId) => browserRouteSessionRegistry.retirePreparedPagesOwnedByRenderer(rendererWebContentsId), - reportBlockedPopup: (blocked) => browserManager.reportRouteGuestPopupBlocked(blocked) + reportBlockedPopup: (blocked) => browserManager.reportRouteGuestPopupBlocked(blocked), + openNewTabPopup: (source, request, observed) => + browserManager.openNewTabPopup(source, request, observed) }) routeWebContentsRegistryRef.current = browserRouteWebContentsRegistry diff --git a/src/renderer/src/components/browser-pane/browser-client-page-retained-elements.ts b/src/renderer/src/components/browser-pane/browser-client-page-retained-elements.ts index 059c739d1e0..2b1d9db25f5 100644 --- a/src/renderer/src/components/browser-pane/browser-client-page-retained-elements.ts +++ b/src/renderer/src/components/browser-pane/browser-client-page-retained-elements.ts @@ -66,6 +66,7 @@ export function createBrowserClientPageWebview(options: { const webview = options.createWebview?.() ?? (options.document.createElement('webview') as Electron.WebviewTag) webview.setAttribute('partition', options.identity.partition) + webview.setAttribute('allowpopups', '') webview.setAttribute('webpreferences', ORCA_BROWSER_PAGE_GUEST_WEB_PREFERENCES_ATTRIBUTE) webview.setAttribute('src', 'about:blank') Object.assign(webview.style, { display: 'flex', width: '100%', height: '100%' }) diff --git a/src/renderer/src/hooks/ipc-events/browser-state-open-link-client-hosted.test.ts b/src/renderer/src/hooks/ipc-events/browser-state-open-link-client-hosted.test.ts deleted file mode 100644 index 912fa7affa6..00000000000 --- a/src/renderer/src/hooks/ipc-events/browser-state-open-link-client-hosted.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - createBrowserTabMock, - createWebRuntimeSessionBrowserTabMock, - dispatchEventMock, - runtimeOwner, - storeState -} = vi.hoisted(() => ({ - createBrowserTabMock: vi.fn(), - createWebRuntimeSessionBrowserTabMock: vi.fn(), - dispatchEventMock: vi.fn(), - runtimeOwner: { value: null as string | null }, - storeState: { value: {} as Record } -})) - -vi.mock('../../store', () => ({ - useAppStore: { getState: () => storeState.value } -})) -vi.mock('@/store/index', () => ({ - useAppStore: { getState: () => storeState.value } -})) -vi.mock('@/lib/workspace/ownership/worktree-runtime-owner', () => ({ - runtimeEnvironmentIdOrLocalUnlessTwin: () => runtimeOwner.value -})) -vi.mock('@/runtime/browser/creation/web-runtime-browser-creation', () => ({ - createWebRuntimeSessionBrowserTab: createWebRuntimeSessionBrowserTabMock -})) -vi.mock('@/lib/client-creation-action-error', () => ({ - showClientCreationActionError: vi.fn() -})) -vi.mock('@/components/browser-pane/describe-page/live-browser-url-registry', () => ({ - rememberLiveBrowserUrl: vi.fn() -})) -vi.mock('./browser-automation-bootstrap-lease', () => ({ - acquireBrowserAutomationBootstrapLease: vi.fn() -})) - -import { registerBrowserStateIpcBridge } from './browser-state-ipc-bridge' - -const noopUnsubscribe = (): void => {} - -function captureOpenLinkHandler(): (event: { - browserPageId: string - url: string - activate?: boolean -}) => void { - let handler: - | ((event: { browserPageId: string; url: string; activate?: boolean }) => void) - | null = null - const browserApi = new Proxy( - { - onOpenLinkInOrcaTab: ( - callback: (event: { browserPageId: string; url: string; activate?: boolean }) => void - ) => { - handler = callback - return noopUnsubscribe - } - } as Record, - { - get: (target, property) => - property in target ? target[property as string] : () => noopUnsubscribe - } - ) - const api = new Proxy({ browser: browserApi } as Record, { - get: (target, property) => - property in target - ? target[property as string] - : new Proxy({}, { get: () => () => noopUnsubscribe }) - }) - vi.stubGlobal('window', { api, dispatchEvent: dispatchEventMock }) - registerBrowserStateIpcBridge([], () => false) - if (!handler) { - throw new Error('Expected the bridge to subscribe to browser:open-link-in-orca-tab') - } - return handler -} - -function clientHostedStore(handle: Record): void { - storeState.value = { - browserPagesByWorkspace: { - opener: [ - { - id: 'page-1', - workspaceId: 'opener', - worktreeId: 'worktree-1' - } - ] - }, - browserTabsByWorktree: { - 'worktree-1': [{ id: 'opener', sessionProfileId: 'profile-a', sessionPartition: 'persist:a' }] - }, - unifiedTabsByWorktree: { - 'worktree-1': [ - { id: 'unified-browser', groupId: 'source', contentType: 'browser', entityId: 'opener' } - ] - }, - groupsByWorktree: { - 'worktree-1': [{ id: 'source', tabOrder: ['unified-browser'] }] - }, - remoteBrowserPageHandlesByPageId: { 'page-1': handle }, - createBrowserTab: createBrowserTabMock, - focusGroup: vi.fn() - } -} - -describe('client-hosted link opens on a paired server', () => { - beforeEach(() => { - createBrowserTabMock.mockReset() - createWebRuntimeSessionBrowserTabMock.mockReset() - createWebRuntimeSessionBrowserTabMock.mockResolvedValue(true) - dispatchEventMock.mockReset() - runtimeOwner.value = 'env-paired' - }) - - it('opens a This device page link through the paired creator instead of dropping it', () => { - clientHostedStore({ - environmentId: 'env-paired', - remotePageId: 'remote-1', - placement: { kind: 'client', browserHostClientId: 'this-desktop' } - }) - captureOpenLinkHandler()({ - browserPageId: 'page-1', - url: 'https://docs.example.com/next', - activate: true - }) - expect(createBrowserTabMock).not.toHaveBeenCalled() - expect(createWebRuntimeSessionBrowserTabMock).toHaveBeenCalledWith( - expect.objectContaining({ - worktreeId: 'worktree-1', - environmentId: 'env-paired', - url: 'https://docs.example.com/next', - profileId: 'profile-a', - clientAfterTabId: 'unified-browser', - focusOnCreate: true, - selectWorktree: false - }) - ) - expect(createWebRuntimeSessionBrowserTabMock.mock.calls[0]?.[0]).not.toHaveProperty( - 'placementPreference' - ) - }) - - it('still drops a streamed remote page instead of creating a local tab', () => { - clientHostedStore({ - environmentId: 'env-paired', - remotePageId: 'remote-1', - placement: { kind: 'server' } - }) - captureOpenLinkHandler()({ - browserPageId: 'page-1', - url: 'https://docs.example.com/streamed' - }) - expect(createBrowserTabMock).not.toHaveBeenCalled() - expect(createWebRuntimeSessionBrowserTabMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/lib/browser/palette/open-client-hosted-link-beside-page.test.ts b/src/renderer/src/lib/browser/palette/open-client-hosted-link-beside-page.test.ts deleted file mode 100644 index 9cd31e75337..00000000000 --- a/src/renderer/src/lib/browser/palette/open-client-hosted-link-beside-page.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { createBrowserTabMock, createWebRuntimeSessionBrowserTabMock, runtimeOwner, storeState } = - vi.hoisted(() => ({ - createBrowserTabMock: vi.fn(), - createWebRuntimeSessionBrowserTabMock: vi.fn(), - runtimeOwner: { value: null as string | null }, - storeState: { value: {} as Record } - })) - -vi.mock('@/store/index', () => ({ - useAppStore: { getState: () => storeState.value } -})) -vi.mock('@/lib/workspace/ownership/worktree-runtime-owner', () => ({ - runtimeEnvironmentIdOrLocalUnlessTwin: () => runtimeOwner.value -})) -vi.mock('@/runtime/browser/creation/web-runtime-browser-creation', () => ({ - createWebRuntimeSessionBrowserTab: createWebRuntimeSessionBrowserTabMock -})) -vi.mock('@/lib/client-creation-action-error', () => ({ - showClientCreationActionError: vi.fn() -})) - -import { openLinkInNewBrowserTab } from './open-client-hosted-link-beside-page' - -describe('openLinkInNewBrowserTab', () => { - beforeEach(() => { - createBrowserTabMock.mockReset() - createBrowserTabMock.mockReturnValue({ id: 'new-tab', activePageId: null }) - createWebRuntimeSessionBrowserTabMock.mockReset() - createWebRuntimeSessionBrowserTabMock.mockResolvedValue(true) - runtimeOwner.value = null - }) - - it('opens a restored This device page in the background through the paired creator', () => { - runtimeOwner.value = 'env-paired' - storeState.value = { - browserPagesByWorkspace: { - opener: [{ id: 'page-1', workspaceId: 'opener', worktreeId: 'worktree-1' }] - }, - browserTabsByWorktree: { - 'worktree-1': [{ id: 'opener', sessionProfileId: 'profile-a' }] - }, - unifiedTabsByWorktree: { - 'worktree-1': [ - { id: 'unified-browser', groupId: 'source', contentType: 'browser', entityId: 'opener' } - ] - }, - groupsByWorktree: { - 'worktree-1': [{ id: 'source', tabOrder: ['unified-browser'] }] - }, - remoteBrowserPageHandlesByPageId: { - 'page-1': { - environmentId: 'env-paired', - remotePageId: 'remote-1', - restoredClientHosted: true - } - }, - createBrowserTab: createBrowserTabMock - } - openLinkInNewBrowserTab({ - sourcePageId: 'page-1', - worktreeId: 'worktree-1', - url: 'https://docs.example.com/menu', - activate: false - }) - expect(createBrowserTabMock).not.toHaveBeenCalled() - expect(createWebRuntimeSessionBrowserTabMock).toHaveBeenCalledWith( - expect.objectContaining({ - worktreeId: 'worktree-1', - environmentId: 'env-paired', - url: 'https://docs.example.com/menu', - profileId: 'profile-a', - clientAfterTabId: 'unified-browser', - focusOnCreate: false, - selectWorktree: false - }) - ) - expect(createWebRuntimeSessionBrowserTabMock.mock.calls[0]?.[0]).not.toHaveProperty( - 'placementPreference' - ) - }) - - it('keeps a local page on createBrowserTab', () => { - storeState.value = { - browserPagesByWorkspace: { - opener: [{ id: 'page-1', workspaceId: 'opener', worktreeId: 'worktree-1' }] - }, - browserTabsByWorktree: { - 'worktree-1': [{ id: 'opener', sessionProfileId: 'profile-a' }] - }, - unifiedTabsByWorktree: {}, - groupsByWorktree: {}, - remoteBrowserPageHandlesByPageId: {}, - createBrowserTab: createBrowserTabMock - } - openLinkInNewBrowserTab({ - sourcePageId: 'page-1', - worktreeId: 'worktree-1', - url: 'https://docs.example.com/local', - activate: false - }) - expect(createWebRuntimeSessionBrowserTabMock).not.toHaveBeenCalled() - expect(createBrowserTabMock).toHaveBeenCalledWith( - 'worktree-1', - 'https://docs.example.com/local', - expect.objectContaining({ - activate: false, - sessionProfileId: 'profile-a' - }) - ) - }) -}) diff --git a/src/renderer/src/lib/browser/palette/open-client-hosted-link-beside-page.ts b/src/renderer/src/lib/browser/palette/open-client-hosted-link-beside-page.ts index 2fb375337c1..3ea68d1b3e9 100644 --- a/src/renderer/src/lib/browser/palette/open-client-hosted-link-beside-page.ts +++ b/src/renderer/src/lib/browser/palette/open-client-hosted-link-beside-page.ts @@ -1,4 +1,4 @@ -import { showClientCreationActionError } from '@/lib/client-creation-action-error' +import { showClientCreationActionError } from '@/lib/workspace/creation/surface-policy/client-creation-action-error' import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/workspace/ownership/worktree-runtime-owner' import { createWebRuntimeSessionBrowserTab } from '@/runtime/browser/creation/web-runtime-browser-creation' import { useAppStore } from '@/store/index' diff --git a/tests/e2e/paired-client-hosted-browser-popups.spec.ts b/tests/e2e/paired-client-hosted-browser-popups.spec.ts new file mode 100644 index 00000000000..94821701e41 --- /dev/null +++ b/tests/e2e/paired-client-hosted-browser-popups.spec.ts @@ -0,0 +1,178 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import { + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' +import { waitForPairedClientWorktree } from './helpers/paired-client-host-session' +import { readHostBrowserPageUrls } from './helpers/host-session-tabs' + +// A This-device page opens target=_blank links as This-device tabs and keeps OAuth popups attached to their opener. + +const OPENER_HTML = `Opener + +blank link + +` + +const CALLBACK_HTML = `Callback` + +async function startFixture(): Promise<{ server: Server; origin: string }> { + const server = createServer((request, response) => { + const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + if (path === '/opener') { + response.end(OPENER_HTML) + } else if (path === '/authorize') { + response.end(``) + } else if (path === '/callback') { + response.end(CALLBACK_HTML) + } else { + response.end('Destination') + } + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + return { server, origin: `http://127.0.0.1:${(server.address() as AddressInfo).port}` } +} + +function readPagePlacements(page: Page, worktreeId: string): Promise> { + return page.evaluate((worktreeId) => { + const state = window.__store!.getState() + const placements: Record = {} + for (const workspace of state.browserTabsByWorktree[worktreeId] ?? []) { + for (const browserPage of state.browserPagesByWorkspace[workspace.id] ?? []) { + const kind = state.remoteBrowserPageHandlesByPageId[browserPage.id]?.placement?.kind + placements[new URL(browserPage.url).pathname] = kind ?? 'none' + } + } + return placements + }, worktreeId) +} + +async function showPage(page: Page, worktreeId: string, path: string): Promise { + await page.evaluate( + ({ worktreeId, path }) => { + const state = window.__store!.getState() + for (const workspace of state.browserTabsByWorktree[worktreeId] ?? []) { + for (const browserPage of state.browserPagesByWorkspace[workspace.id] ?? []) { + if (new URL(browserPage.url).pathname === path) { + state.focusBrowserTabInWorktree(worktreeId, browserPage.id, { surfacePane: true }) + } + } + } + }, + { worktreeId, path } + ) +} + +function guestEval(client: PairedElectronClient, url: string, code: string): Promise { + return client.app.evaluate( + ({ webContents }, { url, code }) => + webContents + .getAllWebContents() + .find((contents) => contents.getType() === 'webview' && contents.getURL() === url) + ?.executeJavaScript(code) ?? null, + { url, code } + ) +} + +// A real mouse click, so main sees the user's gesture as it would on a desktop. +async function clickInGuest( + client: PairedElectronClient, + url: string, + selector: string +): Promise { + await expect + .poll(() => guestEval(client, url, 'innerWidth > 320 && innerHeight > 200'), { + timeout: 30_000 + }) + .toBe(true) + await client.app.evaluate( + async ({ webContents }, { url, selector }) => { + const guest = webContents + .getAllWebContents() + .find((contents) => contents.getType() === 'webview' && contents.getURL() === url)! + const { x, y } = (await guest.executeJavaScript( + `(() => { const r = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) } })()` + )) as { x: number; y: number } + guest.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 }) + guest.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 }) + }, + { url, selector } + ) +} + +test('a This-device page opens new tabs on this device and keeps OAuth popups working', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + const { server, origin } = await startFixture() + const host = await launchHeadlessPairedRuntimeHost() + let client: PairedElectronClient | null = null + try { + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + client = await launchPairedElectronClient(host.offer, testInfo, 'This-device popups') + const page = client.page + const worktreeId = await waitForPairedClientWorktree(page) + await page.evaluate( + ({ environmentId, worktreeId }) => + window.__store!.getState().setActiveWorktree(worktreeId, `runtime:${environmentId}`), + { environmentId: client.environmentId, worktreeId } + ) + const openerUrl = `${origin}/opener` + await page.evaluate(async (url) => { + const state = window.__store!.getState() + state.setBrowserDefaultUrl(url) + await state.openNewBrowserTabInActiveWorkspace( + state.activeGroupIdByWorktree[state.activeWorktreeId!]! + ) + }, openerUrl) + await expect + .poll(() => readPagePlacements(page, worktreeId), { timeout: 60_000 }) + .toEqual({ '/opener': 'client' }) + await expect + .poll(() => guestEval(client!, openerUrl, 'document.readyState'), { timeout: 60_000 }) + .toBe('complete') + + await showPage(page, worktreeId, '/opener') + await clickInGuest(client, openerUrl, '#blank') + await expect + .poll(() => readPagePlacements(page, worktreeId), { + timeout: 30_000, + message: 'the target=_blank link did not open a This-device tab' + }) + .toEqual({ '/opener': 'client', '/destination': 'client' }) + await expect + .poll(() => readHostBrowserPageUrls(host.client, `path:${testRepoPath}`), { timeout: 30_000 }) + .toContain(`${origin}/destination`) + + await showPage(page, worktreeId, '/opener') + await clickInGuest(client, openerUrl, '#oauth') + await expect + .poll(() => guestEval(client!, openerUrl, 'window.__oauthMessages.join()'), { + timeout: 30_000, + message: 'the OAuth popup never reached its opener' + }) + .toBe('code=abc') + await expect + .poll(() => guestEval(client!, openerUrl, 'window.__oauthPopup.closed'), { timeout: 10_000 }) + .toBe(true) + } finally { + await client?.dispose() + await host.dispose() + server.close() + } +})