Files
orca/tests/e2e/helpers/browser-split-fixture.ts
T
Jinwoo Hong dfff3915c4 fix(browser): scope back/forward/reload/zoom/grab shortcuts to the originating split (#22340)
* fix(browser): scope back/forward/reload/zoom/grab shortcuts to the originating split

With two browser panes visible in a split, Back, Forward, Reload, Hard
Reload, page zoom and Focus Address Bar fired in every visible pane. Main
forwarded these guest chords without the page id, and each split's active
pane subscribed. The renderer-side listeners for the same chords were also
window-wide per pane, so a key pressed in the toolbar (or in a terminal in
another split) reached every active browser pane.

Guest-forwarded chords now carry the originating browserPageId; preload
admits only well-formed payloads and each pane ignores ids that aren't its
own. Toolbar-path listeners use the same focused-split scope Find already
uses. The streamed remote pane's history chord moves onto that scoped hook.

Cmd/Ctrl+C grab (STA-3319) gets the same scope and no longer arms while a
text selection exists outside the browser pane, so copying from the native
chat transcript works again.

* refactor(browser): simplify split shortcut scoping per review

Drop the preload payload admission (main and preload ship together), fold
the three inline scope checks into browserChromeShortcutOwnsEvent, and
replace the outside-overlay selection check with a plain live-selection
rule so Cmd+C copies from surfaces that do not move split focus.

* refactor(browser): share one zoom command type and tidy shortcut comments

BrowserPageZoomEventDetail and BrowserPageZoomCommand were the same shape;
keep one in shared/browser-page-zoom.ts and route guest and local zoom
through a single handler.

* refactor(browser): narrow the zoom event with instanceof instead of a cast

* test(e2e): pin split-scoped browser shortcuts

Two browser splits (and a terminal beside a browser) now prove that Back,
Forward, Reload, Hard Reload, page zoom, Focus Address Bar, and the element
grab chord act only on the split that sent them, from both the guest page and
the browser toolbar. A native chat selection proves Cmd/Ctrl+C copies instead
of arming grab. Split fixtures move to a shared helper so both specs reuse them.
2026-09-22 20:14:19 -04:00

244 lines
7.7 KiB
TypeScript

/**
* Split-layout fixtures for browser shortcut E2E specs: a terminal beside a browser, or two
* browsers side by side, plus helpers that press real chords inside a registered guest.
*/
import type { Page } from '@stablyai/playwright-test'
import { expect } from '@stablyai/playwright-test'
export const shortcutModifier = process.platform === 'darwin' ? 'Meta' : 'Control'
export const guestModifier: 'meta' | 'control' = process.platform === 'darwin' ? 'meta' : 'control'
export type GuestModifier = 'meta' | 'control' | 'alt' | 'shift'
export type TerminalBrowserSplitFixture = {
browserGroupId: string
browserPageId: string
browserTabId: string
terminalGroupId: string
}
export type BrowserSplitFixture = {
firstBrowserGroupId: string
firstBrowserPageId: string
firstBrowserTabId: string
secondBrowserGroupId: string
secondBrowserPageId: string
secondBrowserTabId: string
}
export async function createTerminalBrowserSplit(
page: Page,
url = 'about:blank'
): Promise<TerminalBrowserSplitFixture> {
return page.evaluate((initialUrl) => {
const store = window.__store
if (!store) {
throw new Error('Store unavailable')
}
const state = store.getState()
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
throw new Error('Active worktree unavailable')
}
const terminalTabId = state.activeTabIdByWorktree[worktreeId] ?? state.activeTabId ?? undefined
if (
!terminalTabId ||
!(state.tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === terminalTabId)
) {
throw new Error('Active terminal tab unavailable')
}
const terminalGroupId = state.ensureWorktreeRootGroup(worktreeId)
const browserGroupId = state.createEmptySplitGroup(worktreeId, terminalGroupId, 'right')
if (!browserGroupId) {
throw new Error('Browser split unavailable')
}
const browserTab = state.createBrowserTab(worktreeId, initialUrl, {
activate: true,
focusAddressBar: false,
targetGroupId: browserGroupId
})
const browserPageId = browserTab.activePageId
if (!browserPageId) {
throw new Error('Active browser page unavailable')
}
return {
browserGroupId,
browserPageId,
browserTabId: browserTab.id,
terminalGroupId
}
}, url)
}
export async function createBrowserSplit(
page: Page,
urls: { first: string; second: string } = { first: 'about:blank', second: 'about:blank' }
): Promise<BrowserSplitFixture> {
return page.evaluate((initialUrls) => {
const store = window.__store
if (!store) {
throw new Error('Store unavailable')
}
const state = store.getState()
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
throw new Error('Active worktree unavailable')
}
const terminalGroupId = state.ensureWorktreeRootGroup(worktreeId)
const firstBrowserGroupId = state.createEmptySplitGroup(worktreeId, terminalGroupId, 'right')
if (!firstBrowserGroupId) {
throw new Error('First browser split unavailable')
}
const firstBrowserTab = state.createBrowserTab(worktreeId, initialUrls.first, {
activate: true,
focusAddressBar: false,
targetGroupId: firstBrowserGroupId
})
const secondBrowserGroupId = state.createEmptySplitGroup(
worktreeId,
firstBrowserGroupId,
'right'
)
if (!secondBrowserGroupId) {
throw new Error('Second browser split unavailable')
}
const secondBrowserTab = state.createBrowserTab(worktreeId, initialUrls.second, {
activate: true,
focusAddressBar: false,
targetGroupId: secondBrowserGroupId
})
const firstBrowserPageId = firstBrowserTab.activePageId
const secondBrowserPageId = secondBrowserTab.activePageId
if (!firstBrowserPageId || !secondBrowserPageId) {
throw new Error('Active browser page unavailable')
}
return {
firstBrowserGroupId,
firstBrowserPageId,
firstBrowserTabId: firstBrowserTab.id,
secondBrowserGroupId,
secondBrowserPageId,
secondBrowserTabId: secondBrowserTab.id
}
}, urls)
}
export function browserOverlay(page: Page, browserTabId: string) {
return page.locator(`[data-browser-overlay-tab-id="${browserTabId}"]`)
}
export function browserAddressBar(page: Page, browserTabId: string) {
return browserOverlay(page, browserTabId).locator('[data-orca-browser-address-bar="true"]')
}
export async function focusBrowserAddressBar(page: Page, browserTabId: string): Promise<void> {
const addressBar = browserAddressBar(page, browserTabId)
const addressBarForm = browserOverlay(page, browserTabId).locator(
'form:has(> [data-orca-browser-address-bar="true"])'
)
await expect(addressBarForm).toBeVisible()
await addressBar.focus()
await expect(addressBar).toBeFocused()
}
export async function waitForBrowserGuestRegistration(
page: Page,
browserTabId: string,
browserPageId: string
): Promise<void> {
await expect
.poll(() =>
page.evaluate(
async ({ targetBrowserPageId, targetBrowserTabId }) => {
const webview = document.querySelector<Electron.WebviewTag>(
`[data-browser-overlay-tab-id="${targetBrowserTabId}"] webview`
)
try {
if (!webview) {
return false
}
const webContentsId = webview.getWebContentsId()
const registered = await window.api.browser.isGuestRegistered({
browserPageId: targetBrowserPageId,
webContentsId
})
if (!registered) {
return false
}
return true
} catch {
return false
}
},
{
targetBrowserPageId: browserPageId,
targetBrowserTabId: browserTabId
}
)
)
.toBe(true)
}
/** Types a chord into the guest's own WebContents, the path main forwards back to the renderer. */
export async function pressKeyInBrowserGuest(
page: Page,
browserTabId: string,
browserPageId: string,
keyCode: string,
modifiers: GuestModifier[]
): Promise<void> {
await waitForBrowserGuestRegistration(page, browserTabId, browserPageId)
await page.evaluate(
async ({ targetBrowserTabId, inputKeyCode, inputModifiers }) => {
const webview = document.querySelector<Electron.WebviewTag>(
`[data-browser-overlay-tab-id="${targetBrowserTabId}"] webview`
)
if (!webview) {
throw new Error('Registered browser guest unavailable')
}
webview.focus()
await webview.sendInputEvent({
type: 'keyDown',
keyCode: inputKeyCode,
modifiers: inputModifiers
})
await webview.sendInputEvent({
type: 'keyUp',
keyCode: inputKeyCode,
modifiers: inputModifiers
})
},
{ targetBrowserTabId: browserTabId, inputKeyCode: keyCode, inputModifiers: modifiers }
)
}
export async function waitForFocusedGroup(page: Page, groupId: string): Promise<void> {
await expect
.poll(() =>
page.evaluate(() => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
return worktreeId ? state.activeGroupIdByWorktree[worktreeId] : null
})
)
.toBe(groupId)
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
})
)
}
export async function focusBrowserGroup(page: Page, groupId: string): Promise<void> {
await page.evaluate((targetGroupId) => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
if (state && worktreeId) {
state.focusGroup(worktreeId, targetGroupId)
}
}, groupId)
await waitForFocusedGroup(page, groupId)
}