Files
orca/tests/e2e/browser-split-navigation-shortcuts.spec.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

224 lines
9.2 KiB
TypeScript

// STA-8147: history, reload, zoom, and address-bar chords act on the split that sent them.
import type { Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { focusActiveTerminalInput } from './helpers/terminal'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
browserAddressBar,
browserOverlay,
createBrowserSplit,
createTerminalBrowserSplit,
focusBrowserGroup,
guestModifier,
pressKeyInBrowserGuest,
shortcutModifier,
waitForFocusedGroup,
type BrowserSplitFixture,
type GuestModifier
} from './helpers/browser-split-fixture'
import {
guestLoadStarts,
guestUrl,
guestZoomLevel,
navigateGuest,
recordGuestLoadStarts,
waitForGuestIdle,
waitForGuestUrl
} from './helpers/browser-split-guest-probes'
import {
startBrowserSplitPageServer,
type BrowserSplitPageServer
} from './helpers/browser-split-page-server'
type Chord = { renderer: string; guestKeyCode: string; guestModifiers: GuestModifier[] }
const isMac = process.platform === 'darwin'
// Default bindings for browser.back / browser.forward / browser.reload / browser.hardReload.
const backChord: Chord = isMac
? { renderer: 'Meta+BracketLeft', guestKeyCode: '[', guestModifiers: ['meta'] }
: { renderer: 'Alt+ArrowLeft', guestKeyCode: 'Left', guestModifiers: ['alt'] }
const forwardChord: Chord = isMac
? { renderer: 'Meta+BracketRight', guestKeyCode: ']', guestModifiers: ['meta'] }
: { renderer: 'Alt+ArrowRight', guestKeyCode: 'Right', guestModifiers: ['alt'] }
const reloadChord: Chord = {
renderer: `${shortcutModifier}+r`,
guestKeyCode: 'R',
guestModifiers: [guestModifier]
}
const hardReloadChord: Chord = {
renderer: `${shortcutModifier}+Shift+R`,
guestKeyCode: 'R',
guestModifiers: [guestModifier, 'shift']
}
async function pressInGuest(
page: Page,
fixture: BrowserSplitFixture,
chord: Pick<Chord, 'guestKeyCode' | 'guestModifiers'>
): Promise<void> {
await pressKeyInBrowserGuest(
page,
fixture.firstBrowserTabId,
fixture.firstBrowserPageId,
chord.guestKeyCode,
chord.guestModifiers
)
}
/** Two browser splits, each on its second page so both have somewhere to go back to. */
async function createBrowserSplitWithHistory(
page: Page,
server: BrowserSplitPageServer
): Promise<BrowserSplitFixture> {
const fixture = await createBrowserSplit(page, {
first: server.pageUrl('a', 1),
second: server.pageUrl('b', 1, 'localhost')
})
await waitForGuestUrl(page, fixture.firstBrowserTabId, server.pageUrl('a', 1))
await waitForGuestUrl(page, fixture.secondBrowserTabId, server.pageUrl('b', 1, 'localhost'))
await navigateGuest(page, fixture.firstBrowserTabId, server.pageUrl('a', 2))
await navigateGuest(page, fixture.secondBrowserTabId, server.pageUrl('b', 2, 'localhost'))
await waitForGuestIdle(page, fixture.firstBrowserTabId)
await waitForGuestIdle(page, fixture.secondBrowserTabId)
await recordGuestLoadStarts(page, [fixture.firstBrowserTabId, fixture.secondBrowserTabId])
return fixture
}
async function expectSecondSplitUntouched(
page: Page,
fixture: BrowserSplitFixture,
server: BrowserSplitPageServer
): Promise<void> {
await waitForGuestIdle(page, fixture.firstBrowserTabId)
expect(await guestLoadStarts(page, fixture.secondBrowserTabId)).toBe(0)
expect(await guestUrl(page, fixture.secondBrowserTabId)).toBe(server.pageUrl('b', 2, 'localhost'))
}
async function expectFirstSplitReloaded(page: Page, fixture: BrowserSplitFixture): Promise<void> {
await expect.poll(() => guestLoadStarts(page, fixture.firstBrowserTabId)).toBeGreaterThan(0)
}
test.describe('browser split navigation shortcuts', () => {
let server: BrowserSplitPageServer
test.beforeEach(async ({ orcaPage }) => {
server = await startBrowserSplitPageServer()
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
})
test.afterEach(async () => {
await server.close()
})
test('Back and Forward typed in one guest move only that split', async ({ orcaPage }) => {
const fixture = await createBrowserSplitWithHistory(orcaPage, server)
await pressInGuest(orcaPage, fixture, backChord)
await waitForGuestUrl(orcaPage, fixture.firstBrowserTabId, server.pageUrl('a', 1))
await expectSecondSplitUntouched(orcaPage, fixture, server)
await pressInGuest(orcaPage, fixture, forwardChord)
await waitForGuestUrl(orcaPage, fixture.firstBrowserTabId, server.pageUrl('a', 2))
await expectSecondSplitUntouched(orcaPage, fixture, server)
})
test('Reload and Hard Reload typed in one guest reload only that split', async ({ orcaPage }) => {
const fixture = await createBrowserSplitWithHistory(orcaPage, server)
await pressInGuest(orcaPage, fixture, reloadChord)
await expectFirstSplitReloaded(orcaPage, fixture)
await expectSecondSplitUntouched(orcaPage, fixture, server)
await recordGuestLoadStarts(orcaPage, [fixture.firstBrowserTabId])
await pressInGuest(orcaPage, fixture, hardReloadChord)
await expectFirstSplitReloaded(orcaPage, fixture)
await expectSecondSplitUntouched(orcaPage, fixture, server)
expect(await guestUrl(orcaPage, fixture.firstBrowserTabId)).toBe(server.pageUrl('a', 2))
})
// Ctrl+wheel is not covered: before-mouse-event only fires for OS input, not sendInputEvent.
test('page zoom typed in one guest zooms only that split', async ({ orcaPage }) => {
const fixture = await createBrowserSplitWithHistory(orcaPage, server)
const firstZoom = (): Promise<number | null> =>
guestZoomLevel(orcaPage, fixture.firstBrowserTabId)
const secondZoom = (): Promise<number | null> =>
guestZoomLevel(orcaPage, fixture.secondBrowserTabId)
const initialFirstZoom = await firstZoom()
const initialSecondZoom = await secondZoom()
expect(initialFirstZoom).not.toBeNull()
await pressInGuest(orcaPage, fixture, { guestKeyCode: '=', guestModifiers: [guestModifier] })
await expect.poll(firstZoom).toBeGreaterThan(initialFirstZoom ?? 0)
expect(await secondZoom()).toBe(initialSecondZoom)
await pressInGuest(orcaPage, fixture, { guestKeyCode: '-', guestModifiers: [guestModifier] })
await pressInGuest(orcaPage, fixture, { guestKeyCode: '-', guestModifiers: [guestModifier] })
await expect.poll(firstZoom).toBeLessThan(initialFirstZoom ?? 0)
expect(await secondZoom()).toBe(initialSecondZoom)
})
test('Focus Address Bar typed in one guest focuses only that split', async ({ orcaPage }) => {
const fixture = await createBrowserSplitWithHistory(orcaPage, server)
await pressInGuest(orcaPage, fixture, { guestKeyCode: 'L', guestModifiers: [guestModifier] })
await expect(browserAddressBar(orcaPage, fixture.firstBrowserTabId)).toBeFocused()
await expect(browserAddressBar(orcaPage, fixture.secondBrowserTabId)).not.toBeFocused()
})
test('Back and Reload pressed from one split toolbar act on only that split', async ({
orcaPage
}) => {
const fixture = await createBrowserSplitWithHistory(orcaPage, server)
const reloadButton = browserOverlay(orcaPage, fixture.firstBrowserTabId).getByRole('button', {
name: 'Reload',
exact: true
})
await reloadButton.focus()
await waitForFocusedGroup(orcaPage, fixture.firstBrowserGroupId)
await orcaPage.keyboard.press(backChord.renderer)
await waitForGuestUrl(orcaPage, fixture.firstBrowserTabId, server.pageUrl('a', 1))
await expectSecondSplitUntouched(orcaPage, fixture, server)
await recordGuestLoadStarts(orcaPage, [fixture.firstBrowserTabId])
await reloadButton.focus()
await orcaPage.keyboard.press(reloadChord.renderer)
await expectFirstSplitReloaded(orcaPage, fixture)
await expectSecondSplitUntouched(orcaPage, fixture, server)
})
test('Back and Reload pressed in a focused terminal leave the browser split alone', async ({
orcaPage
}) => {
const fixture = await createTerminalBrowserSplit(orcaPage, server.pageUrl('a', 1))
await waitForGuestUrl(orcaPage, fixture.browserTabId, server.pageUrl('a', 1))
await navigateGuest(orcaPage, fixture.browserTabId, server.pageUrl('a', 2))
await waitForGuestIdle(orcaPage, fixture.browserTabId)
await recordGuestLoadStarts(orcaPage, [fixture.browserTabId])
await focusBrowserGroup(orcaPage, fixture.terminalGroupId)
await focusActiveTerminalInput(orcaPage)
await waitForFocusedGroup(orcaPage, fixture.terminalGroupId)
await orcaPage.keyboard.press(backChord.renderer)
await orcaPage.keyboard.press(reloadChord.renderer)
await orcaPage.keyboard.press(hardReloadChord.renderer)
// Why: the next toolbar press proves these chords reach the pane in this app; it only has to lose here.
const reloadButton = browserOverlay(orcaPage, fixture.browserTabId).getByRole('button', {
name: 'Reload',
exact: true
})
await waitForGuestIdle(orcaPage, fixture.browserTabId)
expect(await guestLoadStarts(orcaPage, fixture.browserTabId)).toBe(0)
expect(await guestUrl(orcaPage, fixture.browserTabId)).toBe(server.pageUrl('a', 2))
await reloadButton.focus()
await waitForFocusedGroup(orcaPage, fixture.browserGroupId)
await orcaPage.keyboard.press(backChord.renderer)
await waitForGuestUrl(orcaPage, fixture.browserTabId, server.pageUrl('a', 1))
})
})