/* eslint-disable max-lines -- Terminal shortcut E2E keeps platform keyboard paths beside their shared PTY assertions. */ /** * E2E test for terminal keyboard shortcuts. * * Verifies every chord resolved by resolveTerminalShortcutAction end-to-end: * real DOM keydown → window capture handler → policy → transport → IPC. * * sendInput chords are verified by intercepting pty:write in the Electron main * process so the test proves the bytes actually leave the renderer, without * depending on the shell's readline behaving identically across OSes. Action * chords (split, close, search, clear) are verified via their user-visible * side effect (pane count, search overlay, terminal buffer). * * Platform-specific chords (Cmd+Arrow, Cmd+Backspace on macOS only) are * skipped on the other platform since they'd never fire there at runtime. */ import { test, expect } from './helpers/orca-app' import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../src/shared/constants' import { execInTerminal, countVisibleTerminalPanes, waitForActiveTerminalManager, waitForTerminalOutput, waitForPaneCount, getTerminalContent, waitForActivePanePtyId } from './helpers/terminal' import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' // Why: contextBridge freezes window.api so the renderer cannot spy on // pty.write directly. Intercept in the main process instead — pty:write is an // ipcMain.on listener, so prepending a listener lets us capture every call // without disturbing the real handler. async function installMainProcessPtyWriteSpy(app: ElectronApplication): Promise { await app.evaluate(({ ipcMain }) => { const g = globalThis as unknown as { __ptyWriteLog?: { id: string; data: string }[] __ptyWriteSpyInstalled?: boolean } if (g.__ptyWriteSpyInstalled) { return } g.__ptyWriteLog = [] g.__ptyWriteSpyInstalled = true ipcMain.prependListener('pty:write', (_event: unknown, args: { id: string; data: string }) => { g.__ptyWriteLog!.push({ id: args.id, data: args.data }) }) }) } async function clearPtyWriteLog(app: ElectronApplication): Promise { await app.evaluate(() => { const g = globalThis as unknown as { __ptyWriteLog?: { id: string; data: string }[] } if (g.__ptyWriteLog) { g.__ptyWriteLog.length = 0 } }) } async function getPtyWrites(app: ElectronApplication): Promise { return app.evaluate(() => { const g = globalThis as unknown as { __ptyWriteLog?: { id: string; data: string }[] } return (g.__ptyWriteLog ?? []).map((e) => e.data) }) } // Why: the window-level keydown handler is gated on non-editable targets; the // xterm helper textarea is treated as non-editable on purpose. Focusing it // guarantees each chord reaches the shortcut policy through the real DOM path. async function focusActiveTerminal(page: Page): Promise { await page.evaluate(() => { const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement | null textarea?.focus() }) } async function focusFloatingTerminal(page: Page): Promise { await page .locator( `[data-floating-terminal-panel][aria-hidden="false"] [data-terminal-tab-id] .xterm-helper-textarea` ) .first() .focus() } async function seedFloatingTerminalTabSwitchScenario(page: Page): Promise<{ backgroundFirstTabId: string floatingFirstTabId: string floatingSecondTabId: string }> { return page.evaluate((floatingWorktreeId) => { const store = window.__store if (!store) { throw new Error('Store unavailable') } const state = store.getState() const backgroundWorktreeId = state.activeWorktreeId if (!backgroundWorktreeId) { throw new Error('No active background worktree') } const backgroundFirst = state.tabsByWorktree[backgroundWorktreeId]?.find( (tab) => tab.id === state.activeTabIdByWorktree[backgroundWorktreeId] ) ?? state.tabsByWorktree[backgroundWorktreeId]?.find((tab) => tab.id === state.activeTabId) ?? state.createTab(backgroundWorktreeId) state.createTab(backgroundWorktreeId) state.setActiveTab(backgroundFirst.id) state.setActiveTabType('terminal') const floatingFirst = state.createTab(floatingWorktreeId, undefined, undefined, { activate: false }) state.activateTab(floatingFirst.id) const floatingGroupId = state.activeGroupIdByWorktree[floatingWorktreeId] ?? state.groupsByWorktree[floatingWorktreeId]?.[0]?.id const floatingSecond = state.createTab(floatingWorktreeId, floatingGroupId, undefined, { activate: false }) state.activateTab(floatingFirst.id) return { backgroundFirstTabId: backgroundFirst.id, floatingFirstTabId: floatingFirst.id, floatingSecondTabId: floatingSecond.id } }, FLOATING_TERMINAL_WORKTREE_ID) } async function getActiveFloatingTerminalTabId(page: Page): Promise { return page.evaluate((floatingWorktreeId) => { const state = window.__store?.getState() if (!state) { return null } const groupId = state.activeGroupIdByWorktree[floatingWorktreeId] const group = (groupId ? state.groupsByWorktree[floatingWorktreeId]?.find((candidate) => candidate.id === groupId) : null) ?? state.groupsByWorktree[floatingWorktreeId]?.find((candidate) => candidate.activeTabId) ?? null const activeTab = group?.activeTabId ? state.unifiedTabsByWorktree[floatingWorktreeId]?.find((tab) => tab.id === group.activeTabId) : null return activeTab?.contentType === 'terminal' ? activeTab.entityId : null }, FLOATING_TERMINAL_WORKTREE_ID) } async function getActiveBackgroundTerminalTabId(page: Page): Promise { return page.evaluate(() => { const state = window.__store?.getState() const worktreeId = state?.activeWorktreeId return worktreeId ? (state.activeTabIdByWorktree[worktreeId] ?? state.activeTabId) : null }) } async function enableKittyKeyboardReporting(page: Page, flags: number): Promise { await page.evaluate(async (flags) => { const state = window.__store?.getState() const worktreeId = state?.activeWorktreeId const tabId = state?.activeTabType === 'terminal' ? state.activeTabId : worktreeId ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) : null const manager = tabId ? window.__paneManagers?.get(tabId) : null const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null if (!pane) { throw new Error('No active terminal pane for kitty keyboard setup') } await new Promise((resolve) => { pane.terminal.write(`\x1b[=${flags}u`, resolve) }) }, flags) } async function pressShiftedRussianLayoutKey(page: Page): Promise<{ keydownDefaultPrevented: boolean keypressSent: boolean inputSent: boolean terminalInputSent: boolean keyupSent: boolean }> { return page.evaluate(() => { const state = window.__store?.getState() const worktreeId = state?.activeWorktreeId const tabId = state?.activeTabType === 'terminal' ? state.activeTabId : worktreeId ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) : null const manager = tabId ? window.__paneManagers?.get(tabId) : null const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null pane?.terminal.focus() const textarea = pane?.container.querySelector( '.xterm-helper-textarea' ) as HTMLTextAreaElement | null if (!textarea) { throw new Error('No xterm helper textarea to receive keyboard input') } textarea.focus() const keydown = new KeyboardEvent('keydown', { key: 'Ф', code: 'KeyA', shiftKey: true, bubbles: true, cancelable: true }) Object.defineProperty(keydown, 'keyCode', { get: () => 65 }) Object.defineProperty(keydown, 'which', { get: () => 65 }) textarea.dispatchEvent(keydown) if (keydown.defaultPrevented) { return { keydownDefaultPrevented: true, keypressSent: false, inputSent: false, terminalInputSent: false, keyupSent: false } } const keypress = new KeyboardEvent('keypress', { key: 'Ф', code: 'KeyA', shiftKey: true, bubbles: true, cancelable: true }) Object.defineProperty(keypress, 'keyCode', { get: () => 1060 }) Object.defineProperty(keypress, 'charCode', { get: () => 1060 }) Object.defineProperty(keypress, 'which', { get: () => 1060 }) textarea.dispatchEvent(keypress) const makeTextInputEvent = (): InputEvent => { const input = new InputEvent('input', { data: 'Ф', inputType: 'insertText', bubbles: true, cancelable: false, composed: false }) // Why: older Linux Chromium builds can ignore InputEventInit fields on // synthetic events; xterm's input fallback reads these exact properties. Object.defineProperties(input, { data: { get: () => 'Ф' }, inputType: { get: () => 'insertText' }, composed: { get: () => false } }) return input } // Why: Chromium on Linux can surface layout text through the `input` event // even when an untrusted synthetic keypress does not carry a usable charCode. const input = makeTextInputEvent() textarea.dispatchEvent(input) const keyup = new KeyboardEvent('keyup', { key: 'Ф', code: 'KeyA', shiftKey: true, bubbles: true, cancelable: true }) Object.defineProperty(keyup, 'keyCode', { get: () => 65 }) Object.defineProperty(keyup, 'which', { get: () => 65 }) textarea.dispatchEvent(keyup) // Why: real Chromium feeds xterm through trusted text-input events, but // Linux CI drops the data path for untrusted synthetic InputEvents. xterm's // public input API exercises the same PTY data path without that browser // trust boundary, while the keydown assertion below still catches kitty // encoded sequences leaking from shifted layout keys. pane.terminal.input('Ф') return { keydownDefaultPrevented: false, keypressSent: true, inputSent: true, terminalInputSent: true, keyupSent: true } }) } // Why: handleRequestClosePane pops a "Close Terminal?" dialog when the pane // reports a running child process. Under E2E, a freshly split pane's // proc.process is briefly unset so the check returns true spuriously. Click // Close when the dialog appears so the test's chord-routing assertion stays // deterministic; no-op when it doesn't. async function confirmCloseDialogIfShown(page: Page): Promise { const confirmButton = page.getByRole('button', { name: 'Close', exact: true }) try { await confirmButton.waitFor({ state: 'visible', timeout: 500 }) await confirmButton.click() } catch { // Dialog did not appear — pane closed directly. } } async function pressAndExpectWrite( page: Page, app: ElectronApplication, chord: string, expectedData: string ): Promise { await clearPtyWriteLog(app) await focusActiveTerminal(page) await page.keyboard.press(chord) // Why: assert exact equality, not substring match. Short control codes like // \x01 (Ctrl+A) and \x05 (Ctrl+E) are single bytes that can appear inside // unrelated writes (shell prompt redraws, bracketed-paste sequences), so a // substring match would produce false positives. await expect .poll(async () => (await getPtyWrites(app)).some((w) => w === expectedData), { timeout: 5_000, message: `Expected chord "${chord}" to write ${JSON.stringify(expectedData)}` }) .toBe(true) } const isMac = process.platform === 'darwin' const mod = isMac ? 'Meta' : 'Control' // Why: split chords differ by platform. On macOS Cmd+D splits vertically and // Cmd+Shift+D horizontally. On Linux/Windows Ctrl+D is reserved for EOF // (see terminal-shortcut-policy.ts and #586), so vertical is Ctrl+Shift+D // and horizontal is Alt+Shift+D (Windows Terminal convention). const splitVerticalChord = isMac ? `${mod}+d` : `${mod}+Shift+d` const splitHorizontalChord = isMac ? `${mod}+Shift+d` : 'Alt+Shift+d' // Why: serial mode is load-bearing. Tests mutate shared Electron app state // (pane layout, terminal buffer, expand toggle) and the pty:write spy log is // a single main-process singleton. Parallel execution would interleave chord // effects and corrupt assertions. test.describe.configure({ mode: 'serial' }) test.describe('Terminal Shortcuts', () => { test.beforeEach(async ({ orcaPage }) => { await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) await ensureTerminalVisible(orcaPage) const hasPaneManager = await waitForActiveTerminalManager(orcaPage, 30_000) .then(() => true) .catch(() => false) test.skip( !hasPaneManager, 'Electron automation in this environment never mounts the live TerminalPane manager.' ) await waitForPaneCount(orcaPage, 1, 30_000) }) test('Shift+Enter writes the platform newline chord for terminal TUIs', async ({ orcaPage, electronApp }) => { await installMainProcessPtyWriteSpy(electronApp) await waitForActivePanePtyId(orcaPage) await pressAndExpectWrite( orcaPage, electronApp, 'Shift+Enter', process.platform === 'win32' ? '\x1b\r' : '\x1b[13;2u' ) }) test('floating terminal owns tab switch shortcuts while focused', async ({ orcaPage }) => { const scenario = await seedFloatingTerminalTabSwitchScenario(orcaPage) await orcaPage.evaluate(async () => { const state = window.__store?.getState() if (state?.settings?.floatingTerminalEnabled !== true) { await state?.updateSettings({ floatingTerminalEnabled: true }) } if (!document.querySelector('[data-floating-terminal-panel][aria-hidden="false"]')) { window.dispatchEvent(new CustomEvent('orca-toggle-floating-terminal')) } }) await expect( orcaPage.locator('[data-floating-terminal-panel][aria-hidden="false"]') ).toBeVisible() await focusFloatingTerminal(orcaPage) await orcaPage.keyboard.press(`${mod}+Shift+BracketRight`) await expect .poll(() => getActiveFloatingTerminalTabId(orcaPage), { timeout: 5_000, message: 'floating terminal did not switch to the next tab' }) .toBe(scenario.floatingSecondTabId) await expect .poll(() => getActiveBackgroundTerminalTabId(orcaPage), { timeout: 1_000, message: 'background terminal tab changed while floating terminal was focused' }) .toBe(scenario.backgroundFirstTabId) await focusFloatingTerminal(orcaPage) await orcaPage.keyboard.press(`${mod}+Shift+BracketLeft`) await expect .poll(() => getActiveFloatingTerminalTabId(orcaPage), { timeout: 5_000, message: 'floating terminal did not switch back to the previous tab' }) .toBe(scenario.floatingFirstTabId) await expect(getActiveBackgroundTerminalTabId(orcaPage)).resolves.toBe( scenario.backgroundFirstTabId ) }) test('all terminal chords reach the PTY or fire their action', async ({ orcaPage, electronApp }) => { await installMainProcessPtyWriteSpy(electronApp) // Seed the buffer so Cmd+K has something to clear. const ptyId = await waitForActivePanePtyId(orcaPage) const marker = `SHORTCUT_TEST_${Date.now()}` await execInTerminal(orcaPage, ptyId, `echo ${marker}`) await waitForTerminalOutput(orcaPage, marker) // --- send-input chords (platform-agnostic) --- // Alt+←/→ → readline backward-word / forward-word (\eb / \ef). await pressAndExpectWrite(orcaPage, electronApp, 'Alt+ArrowLeft', '\x1bb') await pressAndExpectWrite(orcaPage, electronApp, 'Alt+ArrowRight', '\x1bf') // Ctrl+←/→ on non-mac → readline backward-word / forward-word (\eb / \ef). // Mac-gated: Ctrl+Arrow on macOS is reserved for Mission Control / Spaces. if (!isMac) { await pressAndExpectWrite(orcaPage, electronApp, 'Control+ArrowLeft', '\x1bb') await pressAndExpectWrite(orcaPage, electronApp, 'Control+ArrowRight', '\x1bf') } // Alt+Backspace → Esc+DEL (readline backward-kill-word). await pressAndExpectWrite(orcaPage, electronApp, 'Alt+Backspace', '\x1b\x7f') // Ctrl+Backspace → \x17 (unix-word-rubout). await pressAndExpectWrite(orcaPage, electronApp, 'Control+Backspace', '\x17') // Shift+Enter → a modified Enter byte path so agents can distinguish it // from plain Enter. Windows uses Esc+CR because Codex ignores CSI-u there. await pressAndExpectWrite( orcaPage, electronApp, 'Shift+Enter', process.platform === 'win32' ? '\x1b\r' : '\x1b[13;2u' ) // --- send-input chords (macOS-only) --- if (isMac) { // Cmd+←/→ → Ctrl+A / Ctrl+E (beginning/end of line). await pressAndExpectWrite(orcaPage, electronApp, 'Meta+ArrowLeft', '\x01') await pressAndExpectWrite(orcaPage, electronApp, 'Meta+ArrowRight', '\x05') // Cmd+Backspace → Ctrl+U (kill line). Cmd+Delete → Ctrl+K (kill to EOL). await pressAndExpectWrite(orcaPage, electronApp, 'Meta+Backspace', '\x15') await pressAndExpectWrite(orcaPage, electronApp, 'Meta+Delete', '\x0b') } // --- action chords (no PTY byte; assert via visible effect) --- // Cmd/Ctrl+K clears the pane. await focusActiveTerminal(orcaPage) await orcaPage.keyboard.press(`${mod}+k`) await expect .poll(async () => (await getTerminalContent(orcaPage)).includes(marker), { timeout: 5_000, message: 'Cmd+K did not clear the terminal buffer' }) .toBe(false) // Split vertically (chord varies by platform — see splitVerticalChord). const panesBeforeSplit = await countVisibleTerminalPanes(orcaPage) await focusActiveTerminal(orcaPage) await orcaPage.keyboard.press(splitVerticalChord) await waitForPaneCount(orcaPage, panesBeforeSplit + 1) // Cmd/Ctrl+] and Cmd/Ctrl+[ cycle focus (no pane-count change). await focusActiveTerminal(orcaPage) await orcaPage.keyboard.press(`${mod}+BracketRight`) await focusActiveTerminal(orcaPage) await orcaPage.keyboard.press(`${mod}+BracketLeft`) expect(await countVisibleTerminalPanes(orcaPage)).toBe(panesBeforeSplit + 1) // Cmd/Ctrl+Shift+Enter toggles expand on the active pane. Requires >1 pane, // so it runs while the vertical split from above is still open. const readExpanded = async (): Promise => orcaPage.evaluate(() => { const state = window.__store?.getState() const tabId = state?.activeTabId if (!state || !tabId) { return false } return state.expandedPaneByTabId[tabId] === true }) expect(await readExpanded()).toBe(false) await focusActiveTerminal(orcaPage) await orcaPage.keyboard.press(`${mod}+Shift+Enter`) await expect .poll(readExpanded, { timeout: 3_000, message: 'Cmd+Shift+Enter did not expand pane' }) .toBe(true) await focusActiveTerminal(orcaPage) await orcaPage.keyboard.press(`${mod}+Shift+Enter`) await expect .poll(readExpanded, { timeout: 3_000, message: 'Cmd+Shift+Enter did not collapse pane' }) .toBe(false) // Cmd/Ctrl+W closes the active split pane (not the whole tab: >1 pane). // Why: the close handler checks hasChildProcesses async; a freshly // spawned pane can transiently report a running child (node-pty's // proc.process lags the spawn), which surfaces a confirmation dialog // instead of closing immediately. Confirm it if it appears — the test // only needs to prove the chord routed to the close handler. await focusActiveTerminal(orcaPage) await orcaPage.keyboard.press(`${mod}+w`) await confirmCloseDialogIfShown(orcaPage) await waitForPaneCount(orcaPage, panesBeforeSplit) // Split horizontally (chord varies by platform — see splitHorizontalChord). const panesBeforeHSplit = await countVisibleTerminalPanes(orcaPage) await focusActiveTerminal(orcaPage) await orcaPage.keyboard.press(splitHorizontalChord) await waitForPaneCount(orcaPage, panesBeforeHSplit + 1) await focusActiveTerminal(orcaPage) await orcaPage.keyboard.press(`${mod}+w`) await confirmCloseDialogIfShown(orcaPage) await waitForPaneCount(orcaPage, panesBeforeHSplit) // Cmd/Ctrl+F toggles the search overlay. await focusActiveTerminal(orcaPage) await orcaPage.keyboard.press(`${mod}+f`) const searchInput = orcaPage.locator('[data-terminal-search-root] input').first() // Why: Escape is handled by TerminalSearch's React onKeyDown, which only // fires when focus is inside the overlay. The overlay auto-focuses its // input via a useEffect, but Playwright can press Escape before that // effect runs and the keystroke goes to the xterm textarea instead. // Wait for the input to actually be focused before pressing Escape. await expect(searchInput).toBeFocused({ timeout: 3_000 }) await orcaPage.keyboard.press('Escape') await expect(orcaPage.locator('[data-terminal-search-root]').first()).toBeHidden({ timeout: 3_000 }) }) test('Shift with Russian layout text reaches the PTY as Cyrillic under kitty keyboard reporting', async ({ orcaPage, electronApp }) => { await installMainProcessPtyWriteSpy(electronApp) // Why: CI can mount the xterm surface before the pane transport has a // live PTY. Probe first so xterm onData cannot race a disconnected // sendInput path, then clear the probe writes before the layout assertion. await waitForActivePanePtyId(orcaPage) await enableKittyKeyboardReporting(orcaPage, 31) await clearPtyWriteLog(electronApp) const dispatch = await pressShiftedRussianLayoutKey(orcaPage) expect(dispatch).toEqual({ keydownDefaultPrevented: false, keypressSent: true, inputSent: true, terminalInputSent: true, keyupSent: true }) await expect .poll(async () => (await getPtyWrites(electronApp)).some((write) => write.includes('Ф')), { timeout: 5_000, message: 'Shift+Russian layout text did not reach the PTY as Cyrillic' }) .toBe(true) const writes = await getPtyWrites(electronApp) const joinedWrites = writes.join('') expect(joinedWrites).not.toContain('\x1b[97:1060;2;1060u') expect(joinedWrites).not.toContain('\x1b[97:1060;2:3u') }) })