orca/tests/e2e/terminal-reattach-mouse-mod...

261 lines
12 KiB
TypeScript

/**
* E2E regression test for the reattach mouse-mode leak fixed alongside #7329.
*
* Why this suite exists:
* A TUI that armed mouse tracking (?1000/1002/1003 + SGR 1006/1016) and died
* uncleanly never emits the disable sequence. The daemon's private-mode
* tracker keeps the mode and buildRehydrateSequences re-arms it on every
* reattach, but POST_REPLAY_REATTACH_RESET used to clear cursor/focus/kitty
* state and NOT mouse modes. A plain shell in the reattached pane then echoed
* every pointer-motion report (`<35;col;rowM`) as literal text.
*
* What it covers (full stack, no mocks):
* - First launch arms the leak for real: a fixture writes the enable sequence
* to stdout and exits, and the live pane is asserted to enter mouse-reporting
* mode (the byte flow the daemon's tracker also records and re-arms).
* - After a warm reattach, the renderer terminal ends with mouse reporting
* DISARMED: mouseTrackingMode is 'none', the enable-mouse-events class is
* gone, and real pointer motion produces zero mouse reports.
* - A positive control re-arms the same modes and confirms the motion probe
* does observe reports, so the "zero reports" assertion cannot pass vacuously.
*
* What it does NOT cover:
* - That the daemon's buildRehydrateSequences re-arms the mode on reattach —
* locked by repro-7329-remote-snapshot-corruption.test.ts against the real
* serializer + xterm. This suite proves the reset half end to end.
* - Live-agent panes keeping mouse via POST_REPLAY_LIVE_AGENT_REATTACH_RESET
* (unit-tested in pty-connection.test.ts).
*/
import { existsSync, readFileSync } from 'node:fs'
import type { ElectronApplication } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { TEST_REPO_PATH_FILE } from './global-setup'
import {
discoverActivePtyId,
execInTerminal,
waitForActiveTerminalManager,
waitForPaneCount,
waitForTerminalOutput
} from './helpers/terminal'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
// Why: quit→relaunch against the same userDataDir relies on the daemon
// surviving the first close; serial keeps the shared profile from competing
// with other Electron instances for the same lock.
test.describe.configure({ mode: 'serial' })
test.describe('reattach mouse-mode leak', () => {
test('warm reattach disarms mouse modes a killed TUI left armed', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
{}, testInfo) => {
// Why: arms the leak with a POSIX `printf` builtin; the reset logic under
// test is platform-agnostic, so skipping Windows here loses no coverage.
test.skip(process.platform === 'win32', 'Uses a POSIX printf to arm mouse reporting')
const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
if (!repoPath || !existsSync(repoPath)) {
test.skip(true, 'Global setup did not produce a seeded test repo')
return
}
const session = createRestartSession(testInfo)
let firstApp: ElectronApplication | null = null
let secondApp: ElectronApplication | null = null
try {
// ── First launch: arm the leak through the real daemon ─────────────
const firstLaunch = await session.launch()
firstApp = firstLaunch.app
await attachRepoAndOpenTerminal(firstLaunch.page, repoPath)
await waitForSessionReady(firstLaunch.page)
await waitForActiveWorktree(firstLaunch.page)
await ensureTerminalVisible(firstLaunch.page)
const hasPaneManager = await waitForActiveTerminalManager(firstLaunch.page, 30_000)
.then(() => true)
.catch(() => false)
test.skip(
!hasPaneManager,
'Electron automation in this environment never mounts the TerminalPane manager.'
)
await waitForPaneCount(firstLaunch.page, 1, 30_000)
const ptyId = await discoverActivePtyId(firstLaunch.page)
// Gate on real command execution before arming: the `$((21+21))` result
// only appears if the shell evaluated it (the echoed command text shows
// the expression, not `42`). Some sandboxed CI/dev runners spawn a PTY
// that echoes input but never execs a shell; skip there rather than fail,
// matching the pane-manager guard above — there is nothing to arm.
await execInTerminal(firstLaunch.page, ptyId, 'echo ORCA_MOUSE_READY_$((21+21))')
const shellExecutes = await waitForTerminalOutput(
firstLaunch.page,
'ORCA_MOUSE_READY_42',
15_000
)
.then(() => true)
.catch(() => false)
test.skip(!shellExecutes, 'PTY shell does not execute commands in this environment')
// Arm mouse tracking exactly as a TUI would, then let the shell foreground
// return — the disable is never sent, so the daemon's tracker keeps the
// mode and re-arms it on reattach. printf is a shell builtin (no external
// binary), and its typed argument is literal backslashes, so only real
// execution emits the ESC bytes that arm xterm below.
await execInTerminal(firstLaunch.page, ptyId, `printf '\\033[?1003h\\033[?1006h'`)
// Precondition: the printf armed real mouse reporting in the live
// pane (proving the leak's byte flow reached the terminal). The daemon's
// tracker sees the same output stream and re-arms this mode on every
// reattach — the rehydrate half is locked by the repro-7329 unit test; this
// suite proves the reattach reset disarms it end to end.
await expect
.poll(
async () =>
firstLaunch.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
return pane?.terminal.modes.mouseTrackingMode ?? null
}),
{
timeout: 15_000,
message:
'printf never armed mouse reporting in the live pane; leak precondition not met'
}
)
.toBe('any')
// Why: app.close flushes beforeunload, but the daemon is a detached fork
// so the PTY (and its armed mouse mode) survives for the warm reattach.
await session.close(firstApp)
firstApp = null
// ── Second launch: reattach must disarm the leaked modes ────────────
const secondLaunch = await session.launch()
secondApp = secondLaunch.app
await waitForSessionReady(secondLaunch.page)
await waitForActiveWorktree(secondLaunch.page)
await ensureTerminalVisible(secondLaunch.page)
await waitForActiveTerminalManager(secondLaunch.page, 30_000)
await waitForPaneCount(secondLaunch.page, 1, 30_000)
// The reattach replay re-arms mouse via rehydrate, then the reset must
// clear it. Poll until it settles to 'none' (times out if the reset
// regresses to not touching mouse modes).
await expect
.poll(
async () =>
secondLaunch.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
return pane?.terminal.modes.mouseTrackingMode ?? null
}),
{
timeout: 15_000,
message: 'Reattached pane never disarmed the leaked mouse-tracking mode'
}
)
.toBe('none')
// Behavioral proof + positive control: real pointer motion must produce
// no reports post-reattach, but the same probe DOES observe reports once
// mouse mode is re-armed — so the "zero reports" result is not vacuous.
const probe = await secondLaunch.page.evaluate(async () => {
// An SGR mouse report is `ESC [ < params (M|m)`; the `ESC [ <` prefix is
// unambiguous, so substring matching avoids a control-char regex.
const isMouseReport = (data: string): boolean => data.includes('\x1b[<')
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?.terminal.element) {
throw new Error('Active terminal pane unavailable')
}
const screen = pane.terminal.element.querySelector<HTMLElement>('.xterm-screen')
if (!screen) {
throw new Error('Active terminal screen unavailable')
}
const reports: string[] = []
const disposable = pane.terminal.onData((data) => reports.push(data))
const dispatchMotion = async (): Promise<void> => {
const rect = screen.getBoundingClientRect()
for (const fraction of [0.15, 0.3, 0.45, 0.6, 0.75, 0.9]) {
screen.dispatchEvent(
new MouseEvent('mousemove', {
bubbles: true,
cancelable: true,
clientX: rect.left + rect.width * fraction,
clientY: rect.top + rect.height * 0.5
})
)
await new Promise((resolve) => setTimeout(resolve, 15))
}
}
const motionReports = (): string[] => reports.filter(isMouseReport)
try {
// Phase A: post-reattach, mouse must be disarmed → no reports.
await dispatchMotion()
const afterReattach = {
mode: pane.terminal.modes.mouseTrackingMode,
hasEnableMouseClass: pane.terminal.element.classList.contains('enable-mouse-events'),
reports: motionReports().length
}
// Phase B: positive control — re-arm and confirm the probe sees reports.
reports.length = 0
await new Promise<void>((resolve) =>
pane.terminal.write('\x1b[?1003h\x1b[?1006h', () => resolve())
)
await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)))
const classAfterArm = pane.terminal.element.classList.contains('enable-mouse-events')
await dispatchMotion()
return { afterReattach, classAfterArm, armedReports: motionReports().length }
} finally {
disposable.dispose()
}
})
// Post-reattach the pane is fully disarmed.
expect(probe.afterReattach.mode).toBe('none')
expect(probe.afterReattach.hasEnableMouseClass).toBe(false)
expect(probe.afterReattach.reports).toBe(0)
// Positive control proves the motion probe genuinely detects reports.
expect(probe.classAfterArm).toBe(true)
expect(probe.armedReports).toBeGreaterThan(0)
} finally {
if (secondApp) {
await session.close(secondApp)
}
if (firstApp) {
await session.close(firstApp)
}
await session.dispose()
}
})
})