/** * 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, sendToTerminal, countVisibleTerminalPanes, waitForActiveTerminalManager, waitForTerminalOutput, waitForPaneCount, getTerminalContent, waitForActivePanePtyId, focusActiveTerminalInput } from './helpers/terminal' import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' import { clearTerminalPtyWriteLog as clearPtyWriteLog, installTerminalPtyWriteSpy as installMainProcessPtyWriteSpy, readTerminalPtyWrites as getPtyWrites } from './helpers/terminal-pty-write-spy' async function setActivePaneForegroundAgent( page: Page, agent: 'droid' | 'antigravity' | null ): Promise { return page.evaluate((agent) => { 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 (!state || !tabId || !pane) { throw new Error('No active terminal pane for foreground-agent setup') } const paneKey = `${tabId}:${pane.leafId}` state.setPaneForegroundAgent(paneKey, { agent, shellForeground: false, // The shortcut only emits CSI-u for a process identity confirmed to // belong to this PTY; keep the fixture aligned with that trust gate. routingTrusted: agent === 'droid' }) return paneKey }, agent) } async function dispatchCtrlCToActiveTerminalTextarea( page: Page, options: { keyupCtrlKey?: boolean } = {} ): Promise<{ keydownDefaultPrevented: boolean keyupDefaultPrevented: boolean }> { return page.evaluate((dispatchOptions) => { 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 const textarea = pane?.container.querySelector( '.xterm-helper-textarea' ) as HTMLTextAreaElement | null if (!pane || !textarea) { throw new Error('No active terminal textarea for Ctrl+C dispatch') } pane.terminal.clearSelection() pane.terminal.focus() textarea.focus() const createEvent = (type: 'keydown' | 'keyup', ctrlKey: boolean): KeyboardEvent => { const event = new KeyboardEvent(type, { key: 'c', code: 'KeyC', ctrlKey, bubbles: true, cancelable: true }) Object.defineProperty(event, 'keyCode', { get: () => 67 }) Object.defineProperty(event, 'which', { get: () => 67 }) return event } // Why: Electron headless consumes real Ctrl+C before xterm in automation; // synthetic DOM events still exercise Orca's installed xterm boundary. const keydown = createEvent('keydown', true) textarea.dispatchEvent(keydown) const keyup = createEvent('keyup', dispatchOptions.keyupCtrlKey !== false) textarea.dispatchEvent(keyup) return { keydownDefaultPrevented: keydown.defaultPrevented, keyupDefaultPrevented: keyup.defaultPrevented } }, options) } 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 getActiveTerminalViewport( page: Page ): Promise<{ viewportY: number; baseY: number }> { 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 const buffer = pane?.terminal.buffer.active if (!buffer) { throw new Error('No active terminal buffer') } return { viewportY: buffer.viewportY, baseY: buffer.baseY } }) } 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 getKittyKeyboardFlags(page: Page): Promise { 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 const terminal = pane?.terminal as | { core?: { coreService?: { kittyKeyboard?: { flags?: number } } } _core?: { coreService?: { kittyKeyboard?: { flags?: number } } } } | undefined return ( terminal?.core?.coreService?.kittyKeyboard?.flags ?? terminal?._core?.coreService?.kittyKeyboard?.flags ?? null ) }) } 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 } }) } async function pressAndExpectWrite( page: Page, app: ElectronApplication, chord: string, expectedData: string, repetitions = 1 ): Promise { await clearPtyWriteLog(app) await focusActiveTerminalInput(page) for (let index = 0; index < repetitions; index++) { 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)).filter((write) => write === expectedData).length, { timeout: 5_000, message: `Expected chord "${chord}" to write ${JSON.stringify(expectedData)}` }) .toBeGreaterThanOrEqual(repetitions) } 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: a freshly split pane can transiently still report a running child, so // poll for the confirm dialog and pane-count settling instead of a fixed wait. async function closeActivePaneAndSettle(page: Page, expectedCount: number): Promise { // Why: split panes own multiple textareas; the shared helper focuses the // PaneManager's active terminal instead of whichever appears first in the DOM. await focusActiveTerminalInput(page) await page.keyboard.press(`${mod}+w`) // The "Stop running command?" confirm surfaces a "Stop and Close" action when // the pane still reports a running child. const confirmButton = page.getByRole('button', { name: /Stop and Close/i }) await expect .poll( async () => { if (await confirmButton.isVisible().catch(() => false)) { // Why: surface a click failure so a real actionability/strict-mode // error isn't hidden behind the generic pane-count timeout. await confirmButton.click().catch((err) => { console.warn('closeActivePaneAndSettle: confirm click failed', err) }) } return countVisibleTerminalPanes(page) }, { timeout: 10_000, message: `Expected ${expectedCount} visible terminal panes after close` } ) .toBe(expectedCount) } // 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 follows the pane Kitty keyboard state', async ({ orcaPage, electronApp }) => { await installMainProcessPtyWriteSpy(electronApp) const ptyId = await waitForActivePanePtyId(orcaPage) await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b\r') if (process.platform === 'win32') { return } // Why: exercise the production PTY-output tracker, not xterm's renderer- // local flag state, so the test covers the bytes the shortcut policy sees. await execInTerminal(orcaPage, ptyId, "printf '\\033[>1u'") await expect.poll(() => getKittyKeyboardFlags(orcaPage)).toBe(1) await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b[13;2u') // Clear the shell's unconsumed CSI-u line before resetting flags in a settled // command; otherwise its line editor can swallow the reset bytes. await sendToTerminal(orcaPage, ptyId, '\x15\x03') await execInTerminal(orcaPage, ptyId, "printf '\\033[=0u'") await expect.poll(() => getKittyKeyboardFlags(orcaPage)).toBe(0) await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b\r') }) test('Droid gets CSI-u Shift+Enter on Windows without changing Antigravity', async ({ orcaPage, electronApp }) => { test.skip(process.platform !== 'win32', 'Windows ConPTY encoding contract') await installMainProcessPtyWriteSpy(electronApp) await waitForActivePanePtyId(orcaPage) const paneKey = await setActivePaneForegroundAgent(orcaPage, 'droid') try { await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b[13;2u', 2) await setActivePaneForegroundAgent(orcaPage, 'antigravity') await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b\r') } finally { await orcaPage.evaluate( (key) => window.__store?.getState().clearPaneForegroundAgent(key), paneKey ) } }) test('Windows forwards genuine Ctrl+Alt text chords to the PTY', async ({ orcaPage, electronApp }) => { test.skip(process.platform !== 'win32', 'Windows xterm AltGr classification regression') await installMainProcessPtyWriteSpy(electronApp) await waitForActivePanePtyId(orcaPage) await pressAndExpectWrite(orcaPage, electronApp, 'Control+Alt+u', '\x1b\x15') await pressAndExpectWrite(orcaPage, electronApp, 'Control+Alt+2', '\x1b2') await pressAndExpectWrite(orcaPage, electronApp, 'Control+Alt+;', '\x1b;') }) test('Ctrl+Enter protects local ConPTY shells without breaking trusted TUI chords', async ({ orcaPage, electronApp }) => { await installMainProcessPtyWriteSpy(electronApp) await waitForActivePanePtyId(orcaPage) if (process.platform === 'win32') { await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\r') const paneKey = await setActivePaneForegroundAgent(orcaPage, 'droid') try { // Droid queries CSI-u without activating live flags; trusted process evidence preserves cue/queue. await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\x1b[13;5u') } finally { await orcaPage.evaluate( (key) => window.__store?.getState().clearPaneForegroundAgent(key), paneKey ) } return } // Preserve the established query-only Droid/Grok contract outside local ConPTY. await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\x1b[13;5u') }) test('plain Ctrl+C sends ETX under kitty keyboard reporting', async ({ orcaPage, electronApp }) => { await installMainProcessPtyWriteSpy(electronApp) await waitForActivePanePtyId(orcaPage) await enableKittyKeyboardReporting(orcaPage, 31) await clearPtyWriteLog(electronApp) await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.down('Control') await orcaPage.keyboard.up('Control') expect((await getPtyWrites(electronApp)).join('')).toBe('') await clearPtyWriteLog(electronApp) expect(await dispatchCtrlCToActiveTerminalTextarea(orcaPage, { keyupCtrlKey: false })).toEqual({ keydownDefaultPrevented: false, keyupDefaultPrevented: false }) await expect .poll(async () => (await getPtyWrites(electronApp)).some((write) => write.includes('\x03')), { timeout: 5_000, message: 'Ctrl+C did not reach the PTY as ETX' }) .toBe(true) const writes = (await getPtyWrites(electronApp)).join('') expect(writes).not.toContain('\x1b[99;5u') expect(writes).not.toContain('\x1b[99') await expect .poll(async () => await getKittyKeyboardFlags(orcaPage), { timeout: 5_000, message: 'Ctrl+C did not clear stale Kitty keyboard flags' }) .toBe(0) await clearPtyWriteLog(electronApp) await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.type('x') await expect .poll(async () => (await getPtyWrites(electronApp)).some((write) => write === 'x'), { timeout: 5_000, message: 'Post-interrupt keyboard input stayed in Kitty CSI-u mode' }) .toBe(true) const postInterruptWrites = (await getPtyWrites(electronApp)).join('') expect(postInterruptWrites).not.toContain('\x1b[') await orcaPage.keyboard.press('Backspace') }) test('@headful Codex-like background output stays visible without disabling WebGL in auto mode', async ({ orcaPage }) => { const hasPane = await orcaPage.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 manager?.setTerminalGpuAcceleration('auto') const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null return Boolean(pane) }) test.skip(!hasPane, 'No active terminal pane for renderer validation') const webglActive = await orcaPage .waitForFunction( () => { 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 return Boolean(pane?.webglAddon) }, null, { timeout: 5_000 } ) .then(() => true) .catch(() => false) test.skip(!webglActive, 'WebGL was not active in this headful environment') const ptyId = await waitForActivePanePtyId(orcaPage) const marker = `CODEX_BG_${Date.now()}` await execInTerminal(orcaPage, ptyId, `printf '\\033[48;2;52;52;52m ${marker} \\033[0m\\n'`) await waitForTerminalOutput(orcaPage, marker) await expect .poll( () => orcaPage.evaluate((expectedMarker) => { 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 const terminalText = pane?.terminal.buffer.active .translateBufferLineToString(pane.terminal.buffer.active.cursorY, true) .trim() const visibleText = pane?.container.textContent ?? '' return { markerVisible: visibleText.includes(expectedMarker) || terminalText === expectedMarker, hasComplexScriptOutput: pane?.hasComplexScriptOutput === true, hasWebgl: Boolean(pane?.webglAddon) } }, marker), { timeout: 5_000, message: 'Background SGR output did not stay visible on the auto renderer' } ) .toEqual({ markerVisible: true, hasComplexScriptOutput: false, hasWebgl: true }) }) 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). // macOS reserves Ctrl+Arrow; Windows ConPTY leaves it to PSReadLine. if (!isMac && process.platform !== 'win32') { 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') // The shell has not enabled KKP, so Shift+Enter must not leak CSI-u text. await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b\r') // --- 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 focusActiveTerminalInput(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 focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.press(splitVerticalChord) await waitForPaneCount(orcaPage, panesBeforeSplit + 1) // Why: ensure the new split pane's PTY is actually bound before we later // close it, so the close cycle can't race an in-progress split. await waitForActivePanePtyId(orcaPage) // Cmd/Ctrl+] and Cmd/Ctrl+[ cycle focus (no pane-count change). await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.press(`${mod}+BracketRight`) await focusActiveTerminalInput(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 focusActiveTerminalInput(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 focusActiveTerminalInput(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). await closeActivePaneAndSettle(orcaPage, panesBeforeSplit) // Split horizontally (chord varies by platform — see splitHorizontalChord). const panesBeforeHSplit = await countVisibleTerminalPanes(orcaPage) await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.press(splitHorizontalChord) await waitForPaneCount(orcaPage, panesBeforeHSplit + 1) await waitForActivePanePtyId(orcaPage) await closeActivePaneAndSettle(orcaPage, panesBeforeHSplit) // Cmd/Ctrl+F toggles the search overlay. await focusActiveTerminalInput(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('Cmd+Up/Down scrolls terminal viewport without writing to the PTY on macOS', async ({ orcaPage, electronApp }) => { test.skip(!isMac, 'Cmd+Up/Down terminal scroll navigation is macOS-only') await installMainProcessPtyWriteSpy(electronApp) const ptyId = await waitForActivePanePtyId(orcaPage) const marker = `CMD_ARROW_SCROLL_${Date.now()}` await execInTerminal(orcaPage, ptyId, `for i in {1..120}; do echo ${marker}_$i; done`) await waitForTerminalOutput(orcaPage, `${marker}_120`) await expect .poll( async () => { const viewport = await getActiveTerminalViewport(orcaPage) return viewport.baseY > 0 && viewport.viewportY === viewport.baseY }, { timeout: 5_000, message: 'terminal did not settle at the bottom with scrollback for Cmd+Up/Down repro' } ) .toBe(true) await clearPtyWriteLog(electronApp) await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.press('Meta+ArrowUp') await expect .poll(async () => getActiveTerminalViewport(orcaPage), { timeout: 5_000, message: 'Cmd+Up did not scroll the terminal viewport to the top' }) .toMatchObject({ viewportY: 0 }) expect(await getPtyWrites(electronApp)).toEqual([]) await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.press('Meta+ArrowDown') await expect .poll( async () => { const viewport = await getActiveTerminalViewport(orcaPage) return viewport.viewportY === viewport.baseY }, { timeout: 5_000, message: 'Cmd+Down did not scroll the terminal viewport to the bottom' } ) .toBe(true) expect(await getPtyWrites(electronApp)).toEqual([]) }) 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') }) })