mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
The active terminal refits a frame after the browser splits mount and can refocus xterm after the spec focused the Reload button, so Ctrl+C reached the shell instead of arming grab. Refocus until the toolbar keeps focus. Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
// STA-3319 / STA-8147: Cmd/Ctrl+C arms element grab only in the focused split, and never
|
|
// while text is selected elsewhere in the window (that press is a copy).
|
|
|
|
import { randomUUID } from 'node:crypto'
|
|
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
|
|
import { expect, test } from './helpers/orca-app'
|
|
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
|
import {
|
|
waitForActivePaneHookDescriptor,
|
|
waitForActivePanePtyId,
|
|
waitForActiveTerminalManager,
|
|
waitForPaneIdentitySnapshot
|
|
} from './helpers/terminal'
|
|
import { waitForPtyShellEcho } from './terminal-pty-readiness'
|
|
import {
|
|
browserOverlay,
|
|
createBrowserSplit,
|
|
createTerminalBrowserSplit,
|
|
guestModifier,
|
|
pressKeyInBrowserGuest,
|
|
shortcutModifier,
|
|
waitForFocusedGroup,
|
|
type GuestModifier
|
|
} from './helpers/browser-split-fixture'
|
|
import { waitForGuestUrl } from './helpers/browser-split-guest-probes'
|
|
import {
|
|
startBrowserSplitPageServer,
|
|
type BrowserSplitPageServer
|
|
} from './helpers/browser-split-page-server'
|
|
|
|
const copyChord = `${shortcutModifier}+c`
|
|
// Default annotate chord: Cmd+Shift+C on macOS, Alt+Shift+N on Linux and Windows.
|
|
const annotateGuestChord: { keyCode: string; modifiers: GuestModifier[] } =
|
|
process.platform === 'darwin'
|
|
? { keyCode: 'C', modifiers: [guestModifier, 'shift'] }
|
|
: { keyCode: 'N', modifiers: ['alt', 'shift'] }
|
|
|
|
function grabBanner(page: Page, browserTabId: string) {
|
|
return browserOverlay(page, browserTabId).getByText(/Click or hover an element|Grab failed/)
|
|
}
|
|
|
|
async function readGuestGrab(page: Page, browserTabId: string, expression: string) {
|
|
return page.evaluate(
|
|
async ({ tabId, script }) => {
|
|
const webview = document.querySelector<Electron.WebviewTag>(
|
|
`[data-browser-overlay-tab-id="${tabId}"] webview`
|
|
)
|
|
return webview ? Boolean(await webview.executeJavaScript(script)) : false
|
|
},
|
|
{ tabId: browserTabId, script: expression }
|
|
)
|
|
}
|
|
|
|
// Hover like a user: the banner shows before the picker arms, which records hover a frame later.
|
|
async function hoverGuestGrabTarget(page: Page, browserTabId: string, x: number, y: number) {
|
|
await expect
|
|
.poll(() => readGuestGrab(page, browserTabId, 'Boolean(window.__orcaGrab?.onPick)'))
|
|
.toBe(true)
|
|
await page.evaluate(
|
|
async ({ tabId, x, y }) => {
|
|
const webview = document.querySelector<Electron.WebviewTag>(
|
|
`[data-browser-overlay-tab-id="${tabId}"] webview`
|
|
)
|
|
await webview?.sendInputEvent({ type: 'mouseMove', x, y })
|
|
},
|
|
{ tabId: browserTabId, x, y }
|
|
)
|
|
await expect
|
|
.poll(() =>
|
|
readGuestGrab(page, browserTabId, 'Boolean(window.__orcaGrab?.getCurrentElement())')
|
|
)
|
|
.toBe(true)
|
|
}
|
|
|
|
function annotateBanner(page: Page, browserTabId: string) {
|
|
return browserOverlay(page, browserTabId).getByText(
|
|
'Click an element to add feedback for the agent.'
|
|
)
|
|
}
|
|
|
|
function reloadButton(page: Page, browserTabId: string) {
|
|
return browserOverlay(page, browserTabId).getByRole('button', { name: 'Reload', exact: true })
|
|
}
|
|
|
|
async function readClipboard(app: ElectronApplication): Promise<string> {
|
|
return app.evaluate(({ clipboard }) => clipboard.readText())
|
|
}
|
|
|
|
async function writeClipboard(app: ElectronApplication, text: string): Promise<void> {
|
|
await app.evaluate(({ clipboard }, value) => clipboard.writeText(value), text)
|
|
}
|
|
|
|
async function clearSelection(page: Page): Promise<void> {
|
|
await page.evaluate(() => window.getSelection()?.removeAllRanges())
|
|
}
|
|
|
|
/** Arms grab from the pane's toolbar: the presence check that makes a missing banner meaningful. */
|
|
async function expectToolbarCopyChordArmsGrab(page: Page, browserTabId: string): Promise<void> {
|
|
await clearSelection(page)
|
|
await reloadButton(page, browserTabId).focus()
|
|
await page.keyboard.press(copyChord)
|
|
await expect(grabBanner(page, browserTabId)).toBeVisible()
|
|
}
|
|
|
|
async function enableNativeChat(page: Page): Promise<void> {
|
|
await page.evaluate(async () => {
|
|
const nextSettings = await window.api.settings.set({
|
|
nativeChatProviders: { claude: true, codex: true }
|
|
})
|
|
window.__store?.setState({ settings: nextSettings })
|
|
})
|
|
}
|
|
|
|
function claudeTranscript(sessionId: string, reply: string): string {
|
|
const rows = [
|
|
{ type: 'user', text: 'Explain the split shortcut fix.' },
|
|
{ type: 'assistant', text: reply }
|
|
]
|
|
return `${rows
|
|
.map((row, index) =>
|
|
JSON.stringify({
|
|
sessionId,
|
|
uuid: `${sessionId}-${index}`,
|
|
timestamp: new Date(Date.now() - (rows.length - index) * 1_000).toISOString(),
|
|
type: row.type,
|
|
message: {
|
|
role: row.type,
|
|
model: 'claude-opus-4',
|
|
content: [{ type: 'text', text: row.text }]
|
|
}
|
|
})
|
|
)
|
|
.join('\n')}\n`
|
|
}
|
|
|
|
/** Turns the terminal split into a native chat view over a seeded Claude transcript. */
|
|
async function openNativeChatTranscript(
|
|
page: Page,
|
|
args: { paneKey: string; worktreeId: string; reply: string }
|
|
): Promise<void> {
|
|
const sessionId = `e2e-split-grab-${randomUUID()}`
|
|
const transcriptPath = path.join(
|
|
mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-split-grab-')),
|
|
`${sessionId}.jsonl`
|
|
)
|
|
writeFileSync(transcriptPath, claudeTranscript(sessionId, args.reply))
|
|
await enableNativeChat(page)
|
|
await page.evaluate(
|
|
({ paneKey, worktreeId, transcriptSessionId, transcriptFile }) => {
|
|
const state = window.__store?.getState()
|
|
if (!state) {
|
|
throw new Error('Store unavailable')
|
|
}
|
|
state.setAgentStatus(
|
|
paneKey,
|
|
{ state: 'working', prompt: 'e2e split grab probe', agentType: 'claude' },
|
|
'Claude',
|
|
undefined,
|
|
{ worktreeId },
|
|
{
|
|
providerSession: {
|
|
key: 'session_id',
|
|
id: transcriptSessionId,
|
|
transcriptPath: transcriptFile
|
|
}
|
|
}
|
|
)
|
|
const [tabId] = paneKey.split(':')
|
|
const unifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find(
|
|
(tab) => tab.contentType === 'terminal' && tab.entityId === tabId
|
|
)
|
|
if (!unifiedTab) {
|
|
throw new Error('Unified terminal tab not found for chat toggle')
|
|
}
|
|
state.toggleTabViewMode(unifiedTab.id)
|
|
},
|
|
{
|
|
paneKey: args.paneKey,
|
|
worktreeId: args.worktreeId,
|
|
transcriptSessionId: sessionId,
|
|
transcriptFile: transcriptPath
|
|
}
|
|
)
|
|
await expect(page.locator('[data-native-chat-window]')).toBeVisible({ timeout: 30_000 })
|
|
}
|
|
|
|
test.describe('browser split grab shortcut', () => {
|
|
// Why: these tests share the system clipboard, so keep them on one worker.
|
|
test.describe.configure({ mode: 'default' })
|
|
|
|
let server: BrowserSplitPageServer
|
|
let savedClipboard: string | null = null
|
|
|
|
test.beforeEach(async ({ electronApp, orcaPage }) => {
|
|
server = await startBrowserSplitPageServer()
|
|
// Why: the copy assertions read the real system clipboard; put the user's text back after.
|
|
savedClipboard = await readClipboard(electronApp)
|
|
await waitForSessionReady(orcaPage)
|
|
await waitForActiveWorktree(orcaPage)
|
|
await ensureTerminalVisible(orcaPage)
|
|
})
|
|
|
|
test.afterEach(async ({ electronApp }) => {
|
|
if (savedClipboard !== null) {
|
|
await writeClipboard(electronApp, savedClipboard)
|
|
}
|
|
await server.close()
|
|
})
|
|
|
|
test('copy chord from one split toolbar arms grab only in that split', async ({ orcaPage }) => {
|
|
const fixture = await createBrowserSplit(orcaPage, {
|
|
first: server.pageUrl('a', 1),
|
|
second: server.pageUrl('b', 1, 'localhost')
|
|
})
|
|
await waitForGuestUrl(orcaPage, fixture.firstBrowserTabId, server.pageUrl('a', 1))
|
|
await waitForGuestUrl(orcaPage, fixture.secondBrowserTabId, server.pageUrl('b', 1, 'localhost'))
|
|
|
|
// Why: the active terminal refits a frame after the splits mount and can take focus back.
|
|
const firstReloadButton = reloadButton(orcaPage, fixture.firstBrowserTabId)
|
|
await expect(async () => {
|
|
await firstReloadButton.focus()
|
|
await waitForFocusedGroup(orcaPage, fixture.firstBrowserGroupId)
|
|
await expect(firstReloadButton).toBeFocused({ timeout: 1_000 })
|
|
}).toPass({ timeout: 30_000 })
|
|
await orcaPage.keyboard.press(copyChord)
|
|
|
|
await expect(grabBanner(orcaPage, fixture.firstBrowserTabId)).toBeVisible()
|
|
await expect(grabBanner(orcaPage, fixture.secondBrowserTabId)).toBeHidden()
|
|
})
|
|
|
|
test('annotate chord typed in one guest arms annotate only in that split', async ({
|
|
orcaPage
|
|
}) => {
|
|
const fixture = await createBrowserSplit(orcaPage, {
|
|
first: server.pageUrl('a', 1),
|
|
second: server.pageUrl('b', 1, 'localhost')
|
|
})
|
|
await waitForGuestUrl(orcaPage, fixture.firstBrowserTabId, server.pageUrl('a', 1))
|
|
await waitForGuestUrl(orcaPage, fixture.secondBrowserTabId, server.pageUrl('b', 1, 'localhost'))
|
|
|
|
await pressKeyInBrowserGuest(
|
|
orcaPage,
|
|
fixture.firstBrowserTabId,
|
|
fixture.firstBrowserPageId,
|
|
annotateGuestChord.keyCode,
|
|
annotateGuestChord.modifiers
|
|
)
|
|
|
|
await expect(annotateBanner(orcaPage, fixture.firstBrowserTabId)).toBeVisible()
|
|
await expect(annotateBanner(orcaPage, fixture.secondBrowserTabId)).toBeHidden()
|
|
})
|
|
|
|
test('escape typed in the page exits grab, the copy toast and find', async ({ orcaPage }) => {
|
|
const fixture = await createBrowserSplit(orcaPage, {
|
|
first: server.pageUrl('a', 1),
|
|
second: server.pageUrl('b', 1, 'localhost')
|
|
})
|
|
await waitForGuestUrl(orcaPage, fixture.firstBrowserTabId, server.pageUrl('a', 1))
|
|
const overlay = browserOverlay(orcaPage, fixture.firstBrowserTabId)
|
|
const pressInGuest = (keyCode: string, modifiers: GuestModifier[] = []): Promise<void> =>
|
|
pressKeyInBrowserGuest(
|
|
orcaPage,
|
|
fixture.firstBrowserTabId,
|
|
fixture.firstBrowserPageId,
|
|
keyCode,
|
|
modifiers
|
|
)
|
|
|
|
await pressInGuest('C', [guestModifier])
|
|
await expect(grabBanner(orcaPage, fixture.firstBrowserTabId)).toBeVisible()
|
|
await pressInGuest('Escape')
|
|
await expect(grabBanner(orcaPage, fixture.firstBrowserTabId)).toBeHidden()
|
|
|
|
// Why: a click-copy rearms the picker when its toast closes; outlasting the toast proves Escape ended it.
|
|
await pressInGuest('C', [guestModifier])
|
|
await expect(grabBanner(orcaPage, fixture.firstBrowserTabId)).toBeVisible()
|
|
await hoverGuestGrabTarget(orcaPage, fixture.firstBrowserTabId, 60, 30)
|
|
await orcaPage.evaluate(async (browserTabId) => {
|
|
const webview = document.querySelector<Electron.WebviewTag>(
|
|
`[data-browser-overlay-tab-id="${browserTabId}"] webview`
|
|
)
|
|
await webview?.sendInputEvent({
|
|
type: 'mouseDown',
|
|
x: 60,
|
|
y: 30,
|
|
button: 'left',
|
|
clickCount: 1
|
|
})
|
|
await webview?.sendInputEvent({
|
|
type: 'mouseUp',
|
|
x: 60,
|
|
y: 30,
|
|
button: 'left',
|
|
clickCount: 1
|
|
})
|
|
}, fixture.firstBrowserTabId)
|
|
await expect(overlay.getByText('Copied').first()).toBeVisible()
|
|
await pressInGuest('Escape')
|
|
await expect(overlay.getByText('Copied').first()).toBeHidden()
|
|
await expect(grabBanner(orcaPage, fixture.firstBrowserTabId)).toBeHidden()
|
|
|
|
await pressInGuest('F', [guestModifier])
|
|
const findInput = overlay.getByPlaceholder('Find in page...')
|
|
await expect(findInput).toBeVisible()
|
|
await pressInGuest('Escape')
|
|
await expect(findInput).toBeHidden()
|
|
})
|
|
|
|
test('copy chord with native chat text selected copies instead of arming grab', async ({
|
|
electronApp,
|
|
orcaPage
|
|
}) => {
|
|
await waitForActiveTerminalManager(orcaPage, 30_000)
|
|
const boundPane = await waitForPaneIdentitySnapshot(orcaPage, 1)
|
|
const ptyId = await waitForActivePanePtyId(orcaPage)
|
|
await waitForPtyShellEcho(orcaPage, ptyId, 15_000)
|
|
const descriptor = await waitForActivePaneHookDescriptor(orcaPage)
|
|
expect(descriptor.paneKey).toBe(`${boundPane.tabId}:${boundPane.activeLeafId}`)
|
|
const fixture = await createTerminalBrowserSplit(orcaPage, server.pageUrl('a', 1))
|
|
await waitForGuestUrl(orcaPage, fixture.browserTabId, server.pageUrl('a', 1))
|
|
const reply = `Selectable transcript reply ${randomUUID()}`
|
|
await openNativeChatTranscript(orcaPage, {
|
|
paneKey: descriptor.paneKey,
|
|
worktreeId: descriptor.worktreeId,
|
|
reply
|
|
})
|
|
await writeClipboard(electronApp, 'clipboard before copy')
|
|
|
|
const replyText = orcaPage.locator('[data-native-chat-window]').getByText(reply)
|
|
await replyText.click({ clickCount: 3 })
|
|
await expect
|
|
.poll(() => orcaPage.evaluate(() => window.getSelection()?.toString()))
|
|
.toContain(reply)
|
|
await orcaPage.keyboard.press(copyChord)
|
|
|
|
await expect.poll(() => readClipboard(electronApp)).toContain(reply)
|
|
await expect(grabBanner(orcaPage, fixture.browserTabId)).toBeHidden()
|
|
|
|
// Why: with the browser split focused, only the live-selection check can tell this is a copy.
|
|
await writeClipboard(electronApp, 'clipboard before copy')
|
|
await reloadButton(orcaPage, fixture.browserTabId).focus()
|
|
await waitForFocusedGroup(orcaPage, fixture.browserGroupId)
|
|
expect(await orcaPage.evaluate(() => window.getSelection()?.toString())).toContain(reply)
|
|
await orcaPage.keyboard.press(copyChord)
|
|
await expect.poll(() => readClipboard(electronApp)).toContain(reply)
|
|
await expect(grabBanner(orcaPage, fixture.browserTabId)).toBeHidden()
|
|
|
|
await expectToolbarCopyChordArmsGrab(orcaPage, fixture.browserTabId)
|
|
const finalPane = await waitForPaneIdentitySnapshot(orcaPage, 1)
|
|
expect(finalPane.tabId).toBe(boundPane.tabId)
|
|
expect(finalPane.activeLeafId).toBe(boundPane.activeLeafId)
|
|
expect(finalPane.ptyIdsByLeafId).toEqual(boundPane.ptyIdsByLeafId)
|
|
await expect(orcaPage.locator('[data-native-chat-root="true"]')).toBeVisible()
|
|
})
|
|
})
|