orca/tests/e2e/helpers/terminal-input-probes.ts

291 lines
9.1 KiB
TypeScript

/**
* Layer-discriminating input probes for frozen-terminal repro specs.
*
* The field failure (Discord #performance / GitHub #2836 family) is a pane
* that shows content while keystrokes silently vanish. Both drop layers are
* silent today:
* - renderer: transport.sendInput returns false when `!connected || !ptyId`
* (pty-transport.ts)
* - main: pty:write drops when `ptyOwnership` misses the id or the provider
* lookup fails (src/main/ipc/pty.ts writePtyInput)
*
* Direct `window.api.pty.write` bypasses the renderer transport, so:
* direct dead → MAIN-side drop (ownership/provider routing)
* direct alive, transport dead → RENDERER transport unbound
* The ownership-rebuild probe invokes pty:listSessions, which repopulates
* `ptyOwnership` as a side effect — input reviving after it is a smoking gun
* for the missing-ownership drop path.
*
* Two probe families:
* - Page-based (Playwright CDP): for specs whose renderer never crashes.
* - Main-process-based (webContents.executeJavaScript): for post-crash
* phases — a crashed target severs Playwright's CDP session even though
* the app recovers, so the harness must drive the renderer from main.
*/
import { expect, type ElectronApplication, type Page } from '@stablyai/playwright-test'
import { sendToTerminal, waitForTerminalOutput } from './terminal'
// ─── Page-based probes (healthy CDP session) ────────────────────────
export async function probeDirectWrite(
page: Page,
ptyId: string,
marker: string,
timeoutMs = 10_000
): Promise<boolean> {
// \x03\x15 = ETX+NAK (interrupt + kill-line) so a TUI or half-typed line on
// the shell doesn't swallow the probe — same trick discoverActivePtyId uses.
await sendToTerminal(page, ptyId, `\x03\x15echo ${marker}\r`)
try {
await waitForTerminalOutput(page, marker, timeoutMs)
return true
} catch {
return false
}
}
/** Probe the full chain: focus the visible xterm and type through the keyboard. */
export async function probeKeyboardType(
page: Page,
marker: string,
timeoutMs = 10_000
): Promise<boolean> {
await page.locator('.xterm:visible').first().click()
await page.keyboard.type(`echo ${marker}`, { delay: 20 })
await page.keyboard.press('Enter')
try {
// Any appearance of the marker proves the roundtrip: xterm does not local-
// echo, so typed characters only render after the PTY echoes them back.
await waitForTerminalOutput(page, marker, timeoutMs)
return true
} catch {
return false
}
}
export async function probeOwnershipRebuildRevival(
page: Page,
ptyId: string,
marker: string
): Promise<boolean> {
await page.evaluate(async () => {
await window.api.pty.listSessions()
})
return probeDirectWrite(page, ptyId, marker)
}
export async function getStorePtyIds(page: Page): Promise<string[]> {
return page.evaluate(() => {
const store = window.__store
if (!store) {
return []
}
return Object.values(store.getState().ptyIdsByTabId).flat()
})
}
// ─── Main-process-based probes (post-renderer-crash) ────────────────
async function mainRendererEval<T>(
electronApp: ElectronApplication,
expression: string
): Promise<T> {
return electronApp.evaluate(async ({ BrowserWindow }, expr) => {
const win = BrowserWindow.getAllWindows()[0]
if (!win || win.isDestroyed() || win.webContents.isDestroyed()) {
throw new Error('no live window for executeJavaScript probe')
}
return (await win.webContents.executeJavaScript(expr, true)) as T
}, expression) as Promise<T>
}
export async function mainRendererStoreReady(electronApp: ElectronApplication): Promise<boolean> {
try {
return await mainRendererEval<boolean>(
electronApp,
`Boolean(window.__store && window.__store.getState().workspaceSessionReady === true)`
)
} catch {
// executeJavaScript rejects while the document is loading or the window
// is mid-recovery; callers poll, so a false here is just "not yet".
return false
}
}
export async function mainGetStorePtyIds(electronApp: ElectronApplication): Promise<string[]> {
try {
return await mainRendererEval<string[]>(
electronApp,
`(() => {
const store = window.__store
if (!store) { return [] }
return Object.values(store.getState().ptyIdsByTabId).flat()
})()`
)
} catch {
return []
}
}
/** Serialize every mounted pane's buffer; marker search doesn't need per-pane precision. */
export async function mainGetAllTerminalContent(electronApp: ElectronApplication): Promise<string> {
try {
return await mainRendererEval<string>(
electronApp,
`(() => {
const managers = window.__paneManagers
if (!managers) { return '' }
let combined = ''
for (const manager of managers.values()) {
for (const pane of manager.getPanes?.() ?? []) {
combined += '\\n' + (pane.serializeAddon?.serialize?.() ?? '')
}
}
return combined.slice(-8000)
})()`
)
} catch {
return ''
}
}
export async function mainWaitForPaneMounted(
electronApp: ElectronApplication,
timeoutMs = 30_000
): Promise<void> {
await expect
.poll(
async () => {
try {
return await mainRendererEval<number>(
electronApp,
`(() => {
const managers = window.__paneManagers
if (!managers) { return 0 }
let count = 0
for (const manager of managers.values()) {
count += (manager.getPanes?.() ?? []).length
}
return count
})()`
)
} catch {
return 0
}
},
{ timeout: timeoutMs, message: 'no terminal pane mounted after renderer recovery' }
)
.toBeGreaterThan(0)
}
async function mainWaitForMarker(
electronApp: ElectronApplication,
marker: string,
timeoutMs: number
): Promise<boolean> {
try {
await expect
.poll(async () => (await mainGetAllTerminalContent(electronApp)).includes(marker), {
timeout: timeoutMs
})
.toBe(true)
return true
} catch {
return false
}
}
/**
* Full-chain probe without CDP: xterm's input() feeds terminal.onData →
* transport.sendInput → pty:write, the identical path keystrokes take past
* the DOM keyboard layer (which the pre-crash Playwright baseline covers).
* Why input() and not paste(): bracketed paste mode would wrap the payload
* and make the shell insert the control chars literally instead of executing.
*/
export async function mainProbeTransportPaste(
electronApp: ElectronApplication,
marker: string,
timeoutMs = 10_000
): Promise<boolean> {
try {
const fed = await mainRendererEval<boolean>(
electronApp,
`(() => {
const managers = window.__paneManagers
if (!managers) { return false }
for (const manager of managers.values()) {
const pane = manager.getActivePane?.() ?? (manager.getPanes?.() ?? [])[0]
if (pane?.terminal?.input) {
pane.terminal.input('\\x03\\x15echo ${marker}\\r', true)
return true
}
}
return false
})()`
)
if (!fed) {
return false
}
} catch {
return false
}
return mainWaitForMarker(electronApp, marker, timeoutMs)
}
export async function mainProbeDirectWrite(
electronApp: ElectronApplication,
ptyId: string,
marker: string,
timeoutMs = 10_000
): Promise<boolean> {
try {
await mainRendererEval<void>(
electronApp,
`window.api.pty.write(${JSON.stringify(ptyId)}, ${JSON.stringify(`\x03\x15echo ${marker}\r`)})`
)
} catch {
return false
}
return mainWaitForMarker(electronApp, marker, timeoutMs)
}
export async function mainProbeOwnershipRebuildRevival(
electronApp: ElectronApplication,
ptyId: string,
marker: string
): Promise<boolean> {
try {
await mainRendererEval<void>(electronApp, `window.api.pty.listSessions()`)
} catch {
return false
}
return mainProbeDirectWrite(electronApp, ptyId, marker)
}
// ─── Failure report ─────────────────────────────────────────────────
/**
* Assemble the failure report for a reproduced frozen pane. Kept in one place
* so every repro spec reports the same layer discrimination.
*/
export function buildFrozenPaneReport(
context: string,
probes: {
directAlive: boolean
transportAlive: boolean
revivedByOwnershipRebuild: boolean
ptyIds: string[]
terminalTail: string
}
): string {
return [
`REPRODUCED frozen terminal (${context}):`,
` direct pty:write probe alive: ${probes.directAlive} (false ⇒ MAIN-side drop: ptyOwnership/provider)`,
` transport (onData→sendInput) probe alive: ${probes.transportAlive} (false with direct alive ⇒ RENDERER transport unbound)`,
` revived by pty:listSessions ownership rebuild: ${probes.revivedByOwnershipRebuild}`,
` pane ptyIds: ${JSON.stringify(probes.ptyIds)}`,
` terminal tail:\n${probes.terminalTail.slice(-600)}`
].join('\n')
}