Files
orca/tests/e2e/helpers/terminal-pty-write-spy.ts
Neil eff317939a fix(terminal): mount one surface per workspace id in the workbench (STA-4846) (#17432)
* fix(terminal): mount one surface per workspace id in the workbench (STA-4846)

* test(terminal): pin the workbench projection against under-selecting

Losing a surface unmounts live terminals, which is worse than the
duplicate mount STA-4846 fixes, so cover every catalog shape that reaches
the workbench: local-only rows that name no host, an unqualified row
colliding with a host-qualified one, two SSH hosts on one id, folder rows
across three hosts, folder ids alongside git worktree ids, and a
whole-catalog assertion that the emitted id set equals the distinct input
id set. Also pin the `useAllWorktrees` -> `useWorktreeMap` swap: both read
the same WeakMap-cached snapshot, so the zustand compare is unchanged.

Harden the folder tie-break to require the row to name its own host.
`getCatalogOwnerHostId` defaults an unstamped row to `local`, which would
let a row that never named a host win the `local` tie and mount another
host's path; it now keeps first-wins instead of guessing.

* fix(terminal): surface the unresolvable folder-surface collision

When two hosts publish the same folder-workspace id and the active workspace's
host cannot be resolved, the projection drops one row's folderPath first-wins.
That path is the PTY cwd for any tab without a startupCwd, so the drop was
silent. Warn on it, and pin the two tie-break branches the unit tests missed:
a colliding row that is not the active workspace, and the same collision with
the rows in swapped order (a host reconnect re-appends its rows, flipping which
row is first mid-session).

* test(e2e): ride out Playwright's spurious main-process evaluate rejection

`e2e / changed e2e specs` failed on `pr11346-selected-runtime-add.spec.ts`
with "Execution context was destroyed, most likely because of a navigation"
from the paired client's first `app.evaluate` — the isolated-HOME assert that
runs one millisecond after `electron.launch()` resolves, which is before the
app is `ready`. Nothing navigates there: Playwright raises that message for
any main-process CDP failure that is neither a JS error nor a closed session,
and `ElectronApplication.evaluate` is unreliable on Electron 27+
(microsoft/playwright#33737). Reproduced locally, and a plain re-run of the
same commit went green.

Extract the retry `installTerminalPtyWriteSpy` already carried for this exact
message into `retryTransientMainEvaluate`, and use it for the launch-time home
read in all three launchers. The read is idempotent and a real boundary escape
still throws on the first successful read.

Also forward the paired client's process logs before the assert instead of
after: this failure reached CI with none of the client's own output, because
forwarding had not started yet.

* test(e2e): wait on the owning group before asserting a Cmd-J browser tab is active

`changed e2e specs` then failed at the remote browser-page step: the store poll
had already seen `activeBrowserTabId` land on the mirrored workspace, but
`[data-tab-id=...][data-active="true"]` never appeared. `data-active` on a
`BrowserTab` is the strip's active tab, which comes from the owning group's
`activeTabId` — not from `activeBrowserTabId` — so the DOM assert was racing an
activation the poll never waited for. The simulator rows in the same spec
already poll the group; the two browser-page rows did not.

Poll the same triple for them, so a genuinely stuck group fails with the ids it
ended on instead of a bare "element(s) not found".
2026-08-31 18:45:52 -07:00

82 lines
3.1 KiB
TypeScript

import type { ElectronApplication } from '@stablyai/playwright-test'
import { retryTransientMainEvaluate } from './electron-main-evaluate-retry'
export type PtyWriteLogEntry = { id: string; data: string }
export async function installTerminalPtyWriteSpy(app: ElectronApplication): Promise<void> {
await retryTransientMainEvaluate(() =>
app.evaluate(({ ipcMain }) => {
const global = globalThis as unknown as {
__terminalPtyWriteLog?: PtyWriteLogEntry[]
__terminalPtyWriteSpyInstalled?: boolean
__terminalPtyWriteAcceptedSpyInstalled?: boolean
__terminalPtyWriteDelayMs?: number
}
if (global.__terminalPtyWriteSpyInstalled) {
return
}
global.__terminalPtyWriteLog = []
global.__terminalPtyWriteSpyInstalled = true
ipcMain.prependListener('pty:write', (_event: unknown, args: PtyWriteLogEntry) => {
global.__terminalPtyWriteLog!.push({ id: args.id, data: args.data })
})
// Playwright cannot observe ipcRenderer.invoke payloads, so this e2e spy wraps main's handler.
const invokeHandlers = (
ipcMain as unknown as {
_invokeHandlers?: Map<string, (event: unknown, args: PtyWriteLogEntry) => unknown>
}
)._invokeHandlers
const writeAcceptedHandler = invokeHandlers?.get('pty:writeAccepted')
if (!writeAcceptedHandler || global.__terminalPtyWriteAcceptedSpyInstalled) {
return
}
global.__terminalPtyWriteAcceptedSpyInstalled = true
invokeHandlers?.set('pty:writeAccepted', async (event, args) => {
global.__terminalPtyWriteLog!.push({ id: args.id, data: args.data })
const delayMs = Math.max(0, global.__terminalPtyWriteDelayMs ?? 0)
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
return writeAcceptedHandler(event, args)
})
})
)
}
export async function clearTerminalPtyWriteLog(app: ElectronApplication): Promise<void> {
await app.evaluate(() => {
const global = globalThis as unknown as { __terminalPtyWriteLog?: PtyWriteLogEntry[] }
if (global.__terminalPtyWriteLog) {
global.__terminalPtyWriteLog.length = 0
}
})
}
export async function readTerminalPtyWrites(app: ElectronApplication): Promise<string[]> {
return app.evaluate(() => {
const global = globalThis as unknown as { __terminalPtyWriteLog?: PtyWriteLogEntry[] }
return (global.__terminalPtyWriteLog ?? []).map((entry) => entry.data)
})
}
export async function readTerminalPtyWriteEntries(
app: ElectronApplication
): Promise<PtyWriteLogEntry[]> {
return app.evaluate(() => {
const global = globalThis as unknown as { __terminalPtyWriteLog?: PtyWriteLogEntry[] }
return [...(global.__terminalPtyWriteLog ?? [])]
})
}
export async function setTerminalPtyWriteDelay(
app: ElectronApplication,
delayMs: number
): Promise<void> {
await app.evaluate((nextDelayMs) => {
const global = globalThis as unknown as { __terminalPtyWriteDelayMs?: number }
global.__terminalPtyWriteDelayMs = Math.max(0, nextDelayMs)
}, delayMs)
}