mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Merge remote-tracking branch 'origin/test/garble-differential-fuzz' into orca-performance
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
# Garble differential fuzz — divergence log
|
||||
|
||||
Findings from the HeadlessEmulator-vs-renderer-twin differential fuzz
|
||||
(`src/main/daemon/headless-emulator-fidelity.fuzz.test.ts`). Each divergence is
|
||||
a case where restoring a hidden terminal from its main-side snapshot
|
||||
(`serialize → replay`, exactly as `applyMainBufferSnapshot` does on reveal)
|
||||
produces a screen that differs from an always-visible renderer terminal fed the
|
||||
same bytes. Any such diff is a user-visible garble on reveal.
|
||||
|
||||
## Method
|
||||
|
||||
- Corpus: seeded agent-TUI byte streams (`buildAgentTuiStreamOps`), 3 pane
|
||||
sizes, PTY-style random chunk splitting.
|
||||
- Differential: production `HeadlessEmulator` snapshot replayed into a fresh
|
||||
renderer-parity terminal, compared cell-by-cell (text, per-cell style,
|
||||
cursor, modes, scrollback) against an always-visible renderer-parity twin.
|
||||
- Parity confirmed: `createRendererParityTerminal` mirrors the renderer pane's
|
||||
buffer-affecting options exactly — `scrollback: 5000`, `allowProposedApi`,
|
||||
`vtExtensions.kittyKeyboard`, `Unicode11Addon`, Orca ZWJ provider (verified
|
||||
against `buildDefaultTerminalOptions` in
|
||||
`src/renderer/src/lib/pane-manager/pane-terminal-options.ts` and
|
||||
`pane-dom-creation.ts`). Render-only options (`minimumContrastRatio`,
|
||||
`drawBoldTextInBrightColors`, font, cursor, scrollbar) do not alter stored
|
||||
cell attributes, so their omission is not a source of false diffs.
|
||||
`windowsMode` is unset in both (matches renderer). Addon versions:
|
||||
`@xterm/addon-serialize` / `@xterm/headless` / `@xterm/addon-unicode11` all
|
||||
`*-beta.287` (headless `6.1.0-beta.287`).
|
||||
- Scan: seeds 1..2000. Every divergence is either the known serialize-wrap bug
|
||||
(predicate `bufferHasSerializeHostileWrappedRow`, tolerated + counted) or is
|
||||
listed below.
|
||||
|
||||
## Inventory
|
||||
|
||||
| bug | found by | seeds | classification |
|
||||
| --- | --- | --- | --- |
|
||||
| A — serialize wrap null-cell | fidelity (suite 1) | 31, 157, 171, 207, 423, 426, 502, 801, 815, 826, 865, 881, 923, 977, 1004, 1119, 1142, 1238, 1241, 1318, 1351, 1374, 1532, 1601, 1657, 1728, 1770 (27 in 1..2000) | (a) real serialize bug, pre-documented + pinned |
|
||||
| B — SGR bold loss (`1;22`) | fidelity (suite 1) | 435, 770, 1321 | (a) real serialize bug — garbles style on reveal |
|
||||
| C — cursor off-by-one at right margin | fidelity (suite 1) | 454, 1696 | (a) real serialize bug — misplaces cursor on reveal |
|
||||
| D — DECSC saved-cursor lost across reveal | reconciliation (suite 2) | seed 3 | (a) real snapshot limitation — garbles a DECRC-in-tail reveal |
|
||||
| E — snapshot boundary mid-escape-sequence | reconciliation (suite 2) | seed 4 (+~24% of corpus) | (a) real snapshot limitation — continuation renders literal |
|
||||
|
||||
All five bug classes are reproduced by dedicated minimal `test.skip` repros so
|
||||
they cannot silently regress, AND each is tolerated + counted by its suite's
|
||||
corpus loop so deep mode surfaces only genuinely NEW divergences:
|
||||
|
||||
- Suite 1 (fidelity): Bug A via `bufferHasSerializeHostileWrappedRow`, Bug B via
|
||||
`snapshotHasSelfCancellingBoldReset` (matches the `1;22` in the serialized
|
||||
snapshot), Bug C via `isMarginWrapPendingCursorOffByOne` (cursor x-1 with a
|
||||
full-width content row). Green at the default 300 and at `FUZZ_ITERATIONS=2000`.
|
||||
- Suite 2 (reconciliation): Bug E via `prefixEndsMidSequence`. Bug D and the
|
||||
Bug-C cursor cascade are kept out of the corpus by an append-only racing tail
|
||||
(no DECSC/cursor motion) and pinned only as standalone repros. Green at the
|
||||
default 200 and at `FUZZ_ITERATIONS=1000`.
|
||||
|
||||
Each tolerance has a `< max(3, ITERATIONS*0.5)` guard so a predicate that starts
|
||||
tripping on most seeds fails the suite instead of silently swallowing it.
|
||||
|
||||
Seed 113 (called out in the handoff as a "DECSC/DECRC detour writing colored
|
||||
text mid-line") does not diverge on the current harness. It is a `savedCursor
|
||||
Detour` op seed; DECSC/DECRC SGR carry is correctly preserved by both the
|
||||
emulator and the serializer here. It was most likely an earlier observation
|
||||
folded into Bug C (the DECRC cases 1696 also involve `\x1b7`/`\x1b8`), or a
|
||||
transient during harness construction. No live divergence at 113.
|
||||
|
||||
---
|
||||
|
||||
## Bug A — SerializeAddon drops null cells at a soft-wrap boundary
|
||||
|
||||
**Classification: (a) real `@xterm/addon-serialize` bug.** Pre-existing; found
|
||||
and minimized by the prior agent, pinned by two `test.skip` repros in the fuzz
|
||||
suite (V1 seed 31, V2 seed 157). Full mechanism documented in
|
||||
`bufferHasSerializeHostileWrappedRow` and the suite's headline comment.
|
||||
|
||||
- **V1 (cell loss):** a wrapped continuation row starting with a NULL cell
|
||||
passes the addon's wrap-validity ternary, gets skipped with `CUF` which clamps
|
||||
at the right margin, overwriting the previous row's last cell and shifting the
|
||||
tail left by one. `cols=20: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K'`.
|
||||
- **V2 (stray `-` filler):** a wrapped pair whose source row is entirely null
|
||||
takes the forced-wrap "magic" path; cleanup emits `ESC[0C` (param 0 → 1) so
|
||||
the ECH erase lands one cell right and the first filler `-` survives.
|
||||
|
||||
**Impact:** any snapshot consumer (hidden reveal, parked-tab reveal, sleep/wake,
|
||||
mobile subscribe replay) paints lost/shifted characters or stray `-` fillers
|
||||
when a TUI erases inside a soft-wrapped line. Tolerated + counted by the suite;
|
||||
unskip the repros when upstream fixes or a local serialize post-processor lands.
|
||||
|
||||
---
|
||||
|
||||
## Bug B — SerializeAddon loses BOLD when serializing a dim→bold-only transition
|
||||
|
||||
**Classification: (a) real `@xterm/addon-serialize` bug.** New finding.
|
||||
|
||||
**Seeds:** 435, 770 (alt-screen), 1321 (minimal, 2 ops).
|
||||
|
||||
**Minimal repro (isolated, no fuzz corpus needed), cols=20:**
|
||||
|
||||
```
|
||||
live bytes: "\x1b[2mA\x1b[22m\x1b[1mB"
|
||||
SerializeAddon → : "\x1b[2mA\x1b[1;22mB"
|
||||
live cell B: bold=1 dim=0 (style flags 100000)
|
||||
restored cell B: bold=0 dim=0 (style flags 000000) ← BOLD LOST
|
||||
```
|
||||
|
||||
**Mechanism:** cell A is dim, cell B is bold-only. The serializer diffs the pen
|
||||
from A (dim on) to B (bold on, dim off). To clear dim it appends SGR 22 — but in
|
||||
xterm/ECMA-48 **SGR 22 resets *both* bold and dim** (`normalIntensity`). So the
|
||||
emitted `\x1b[1;22m` sets bold then immediately clears it: the restored cell is
|
||||
neither dim nor bold. Verified directly: writing `\x1b[1;22mX` yields `bold=0`.
|
||||
(`\x1b[1;2m` — the same-cell dim+bold case — round-trips fine, so the bug is
|
||||
specific to a dim-cell → bold-only-cell attribute transition.)
|
||||
|
||||
**Why it garbles a real pane:** agent TUIs routinely draw a dim body line then a
|
||||
bold status/spinner line (Claude Code, Codex). On the live screen the status
|
||||
line is bold; after a hide→reveal snapshot restore it renders normal-weight.
|
||||
The seed-1321 live row `⠦ bash: pnpm typecheck` is bold live, non-bold restored.
|
||||
|
||||
**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` →
|
||||
`it.skip('preserves bold when serializing a dim cell followed by a bold-only cell')`.
|
||||
Tolerated + counted in the corpus via `snapshotHasSelfCancellingBoldReset`.
|
||||
|
||||
---
|
||||
|
||||
## Bug C — SerializeAddon cursor restore is off-by-one when the last content row fills the right margin
|
||||
|
||||
**Classification: (a) real `@xterm/addon-serialize` bug.** New finding.
|
||||
|
||||
**Seeds:** 454 (minimal, plain CUP), 1696 (DECSC/DECRC + wide CJK).
|
||||
|
||||
**Minimal repro (isolated, pure serializer replay), cols=10:**
|
||||
|
||||
```
|
||||
live bytes: "0123456789\x1b[3;5H" (fill row 0 to the margin, CUP to r3c5)
|
||||
SerializeAddon → : "0123456789\x1b[2B\x1b[6D"
|
||||
live cursor: { x: 4, y: 2 }
|
||||
restored cursor: { x: 3, y: 2 } ← ONE COLUMN SHORT
|
||||
```
|
||||
|
||||
Control (`"012\x1b[3;5H"` — row 0 not full) serializes to `"012\x1b[2B\x1b[1C"`
|
||||
and round-trips the cursor exactly, isolating the trigger to a full-width final
|
||||
content row.
|
||||
|
||||
**Mechanism:** after emitting a row filled to exactly `cols`, xterm is left in
|
||||
the *wrap-pending* state (cursor visually on the last column, logically "one
|
||||
past"). The serializer computes its final cursor-restore as relative
|
||||
`CUD`/`CUB` moves from that ambiguous position; the horizontal delta is computed
|
||||
one column short, so the restored cursor lands at `x-1`. Reproduced with pure
|
||||
`serializeAddon.serialize()` replay into a fresh terminal — **no Orca preamble
|
||||
or normalization involved**, confirming it is upstream, not Orca's snapshot
|
||||
path.
|
||||
|
||||
**Why it garbles a real pane:** the cursor is where the next keystroke echoes
|
||||
and where the block/bar cursor is drawn. On reveal of a TUI whose bottom line
|
||||
reached the right edge (wide status lines, long prompts), the cursor sits one
|
||||
cell left of where the live pane had it — visible as a mispositioned prompt
|
||||
caret or spinner, and subsequent input can overwrite the wrong cell.
|
||||
|
||||
**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` →
|
||||
`it.skip('restores the cursor exactly when the last content row fills the right margin')`.
|
||||
Tolerated + counted in the corpus via `isMarginWrapPendingCursorOffByOne`.
|
||||
|
||||
---
|
||||
|
||||
## Bug D — snapshot does not preserve the DECSC saved-cursor register across a hide/reveal boundary
|
||||
|
||||
**Classification: (a) real bug — a structural snapshot limitation.** New
|
||||
finding, surfaced by the reveal-reconciliation fuzz (suite 2), not the fidelity
|
||||
fuzz.
|
||||
|
||||
**Minimal repro (cols=20):**
|
||||
|
||||
```
|
||||
hidden bytes: "AB\x1b7\x1b[4;10HCD" (write AB, DECSC saves cursor at r0c2,
|
||||
move to r3c9, write CD)
|
||||
tail bytes: "\x1b8X" (DECRC restores the saved cursor, write X)
|
||||
|
||||
live (always visible): rows ["ABX", " CD"] cursor { x: 3, y: 0 }
|
||||
reveal (snapshot+tail): rows ["XB", " CD"] cursor { x: 1, y: 0 }
|
||||
^^ 'X' overwrote 'A' — DECRC landed at home, not r0c2
|
||||
snapshotAnsi: "AB\r\n\r\n\r\n\x1b[9CCD" (no saved-cursor state at all)
|
||||
```
|
||||
|
||||
**Mechanism:** the snapshot is a serialized *screen* (SerializeAddon) plus a few
|
||||
rehydrated modes. The VT100 DECSC/DECRC saved-cursor register (also `CSI s` /
|
||||
`CSI u`) is runtime state that never appears in the serialized buffer, so it
|
||||
cannot survive a snapshot. When a hidden TUI runs `\x1b7` (or `\x1b[s`) before
|
||||
the reveal seq and the racing tail (or any post-reveal output) runs `\x1b8` (or
|
||||
`\x1b[u`), the restore targets the fresh terminal's default saved position
|
||||
(home) instead of where the TUI saved it — the next writes land at the wrong
|
||||
cell and overwrite live content.
|
||||
|
||||
**Why it garbles a real pane:** DECSC/DECRC is common in shell prompts and
|
||||
status-line redraws (save cursor, jump to a corner to paint a clock/token
|
||||
counter, restore). If the save happens while the pane is hidden and the restore
|
||||
fires on reveal, the restored paint clobbers the wrong cells. Found by suite-2
|
||||
seed 3 (a `savedCursorDetour` op whose `\x1b7` fell in the hidden prefix and
|
||||
whose `\x1b8` fell in the racing tail after chunk-splitting).
|
||||
|
||||
**Handling:** suite 2 keeps its racing tail append-only (no DECSC/DECRC, cursor
|
||||
motion, scroll regions, or alt frames) so the seq-reconciliation byte-stitch is
|
||||
tested in isolation from this and the other terminal-state-loss garbles. Bug D
|
||||
is instead pinned as a standalone repro,
|
||||
`hidden-reveal-reconciliation.fuzz.test.ts` →
|
||||
`it.skip('preserves the DECSC saved-cursor register across a hide/reveal …')`.
|
||||
|
||||
**Fix direction (not applied — no production changes in this task):** carry the
|
||||
saved-cursor register out-of-band in the snapshot (like `oscLinks`), or have the
|
||||
emulator re-emit a synthetic DECSC restoring the saved position after replay.
|
||||
|
||||
---
|
||||
|
||||
## Bug E — snapshot boundary mid-escape-sequence drops the partial sequence
|
||||
|
||||
**Classification: (a) real bug — a structural snapshot limitation.** New
|
||||
finding, surfaced by the reveal-reconciliation fuzz (suite 2).
|
||||
|
||||
**Minimal repro (cols=20):**
|
||||
|
||||
```
|
||||
hidden prefix: "AB\x1b[3" (write AB, then ESC [ 3 — no final byte yet)
|
||||
tail bytes: "mCD" ('m' completes ESC[3m = italic, then CD)
|
||||
|
||||
live (always visible): rows ["ABCD"] (ESC[3m parsed atomically, CD italic)
|
||||
reveal (snapshot+tail): rows ["ABmCD"] ← 'm' became a literal character
|
||||
snapshotAnsi: "AB" (the partial ESC[3 is in the parser, gone)
|
||||
```
|
||||
|
||||
**Mechanism:** a PTY read (one delivery record) can split an escape sequence.
|
||||
If the pane is revealed while the emulator's parser sits mid-`ESC[…`, the
|
||||
serialized SCREEN cannot carry the partial sequence (it lives in the parser
|
||||
state machine, not the buffer). The racing tail supplies the sequence's
|
||||
remaining bytes, but with the prefix gone the terminal parses them as literal
|
||||
text. Reproduced end-to-end against the real `HeadlessEmulator.getSnapshot`.
|
||||
|
||||
**Why it garbles a real pane:** any TUI whose output is heavy with escape
|
||||
sequences (all of them) can have a read boundary fall mid-escape; if a reveal
|
||||
lands in that window the continuation renders as stray literal bytes (a rogue
|
||||
`m`, `H`, digits) injected into the visible text.
|
||||
|
||||
**Reachability:** requires the reveal/snapshot to fire in the gap between the two
|
||||
halves of a split escape. `main` writes each PTY read to the emulator and
|
||||
records it as one delivery unit (`session.ts emitSubprocessOutput`), and the
|
||||
snapshot is taken synchronously at a drain — so the window is a single delivered
|
||||
record that ended mid-escape. Narrow but real.
|
||||
|
||||
**Handling:** suite 2 tolerates + counts scenarios whose hidden prefix ends
|
||||
mid-escape-sequence (`prefixEndsMidSequence`), the same way suite 1 tolerates the
|
||||
serialize wrap bug — it fired on ~24% of the corpus, confirming the class is
|
||||
common. Pinned by `hidden-reveal-reconciliation.fuzz.test.ts` →
|
||||
`it.skip('completes an escape sequence split across the hide/reveal boundary')`.
|
||||
|
||||
**Fix direction (not applied):** have `main` hold a trailing incomplete escape
|
||||
out of the drain until it completes (a small parser-aware buffer), or defer the
|
||||
snapshot to a parser-clean boundary.
|
||||
|
||||
---
|
||||
|
||||
## Known-legitimate normalization (NOT bugs)
|
||||
|
||||
- **OSC 8 hyperlink underline** — classification (c). xterm marks OSC-8 link
|
||||
cells underlined; SerializeAddon never re-emits OSC 8. Production restores the
|
||||
link ranges out-of-band via `snapshot.oscLinks`
|
||||
(`collectHeadlessOscLinkRanges`), so byte replay keeps the text but drops the
|
||||
underline by design. Pinned by the passing
|
||||
`it('drops OSC 8 underline from byte replay but preserves the range …')`.
|
||||
- **P256→P16 color mode** — classification (c). SerializeAddon re-emits palette
|
||||
indices 0–15 written as `38;5;N` using classic SGR 30–37/90–97, so a restored
|
||||
cell reports `CM_P16` where live reported `CM_P256`. Both resolve through the
|
||||
same 16 theme slots — no visual difference. Canonicalized by
|
||||
`canonicalColorMode` in the parity fixture.
|
||||
|
||||
---
|
||||
|
||||
## Corpus vs deep mode
|
||||
|
||||
- **Suite 1** (`headless-emulator-fidelity.fuzz.test.ts`): default
|
||||
`FUZZ_ITERATIONS=300` (~17s). `FUZZ_ITERATIONS=2000` (~113s) is green — Bugs A,
|
||||
B, and C are each tolerated + counted by a predicate, so the corpus fails only
|
||||
on a genuinely new divergence.
|
||||
- **Suite 2** (`hidden-reveal-reconciliation.fuzz.test.ts`): default
|
||||
`FUZZ_ITERATIONS=200` (~5s). `FUZZ_ITERATIONS=1000` is green — the racing tail
|
||||
is append-only, so the only tolerated class is Bug E (`prefixEndsMidSequence`).
|
||||
- Combined default runtime is ~19s (well under the 60s gate).
|
||||
- `FUZZ_SEED=<n>`: re-run exactly one seed for a repro (both suites).
|
||||
@@ -0,0 +1,467 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
import {
|
||||
buildAgentTuiStreamOps,
|
||||
mulberry32,
|
||||
splitIntoRandomChunks,
|
||||
type AgentTuiStreamDims
|
||||
} from '../../shared/agent-tui-ansi-fuzz-stream'
|
||||
import {
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY,
|
||||
SNAPSHOT_REPLAY_PREAMBLE_ALT,
|
||||
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
|
||||
bufferHasSerializeHostileWrappedRow,
|
||||
createRendererParityTerminal,
|
||||
cursorPosition,
|
||||
isMarginWrapPendingCursorOffByOne,
|
||||
normalBufferRowsTrimmed,
|
||||
snapshotHasSelfCancellingBoldReset,
|
||||
visibleRowStyles,
|
||||
visibleRows,
|
||||
writeChunksToTerminal
|
||||
} from '../../shared/terminal-restore-parity-fixture'
|
||||
|
||||
// Differential garble gate for the hidden-terminal model/view contract
|
||||
// (docs/reference/terminal-model-view-contract.md): with the hidden-delivery
|
||||
// gate on, a hidden pane receives NOTHING — main's HeadlessEmulator is the
|
||||
// source of truth and reveal repaints the renderer xterm from
|
||||
// preamble + rehydrateSequences + snapshotAnsi (applyMainBufferSnapshot).
|
||||
// This fuzz feeds seeded agent-TUI byte streams to the production emulator
|
||||
// and to an always-visible renderer-parity terminal, then asserts the
|
||||
// serialize→replay round trip reproduces the exact screen the renderer would
|
||||
// have shown. Any diff = a garble bug on reveal.
|
||||
//
|
||||
// Runtime knobs:
|
||||
// FUZZ_ITERATIONS=5000 deep/nightly mode (default 300, <60s combined with
|
||||
// the reveal-reconciliation suite)
|
||||
// FUZZ_SEED=1234 re-run exactly one seed (repro from a failure log)
|
||||
|
||||
const DEFAULT_ITERATIONS = 300
|
||||
const FIXED_SEED = readPositiveIntEnv('FUZZ_SEED')
|
||||
const ITERATIONS =
|
||||
FIXED_SEED !== null ? 1 : (readPositiveIntEnv('FUZZ_ITERATIONS') ?? DEFAULT_ITERATIONS)
|
||||
// Matches HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS in pty-connection.ts — the
|
||||
// scrollback budget the reveal restore actually requests from main.
|
||||
const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000
|
||||
|
||||
function readPositiveIntEnv(name: string): number | null {
|
||||
const raw = Number(process.env[name])
|
||||
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null
|
||||
}
|
||||
|
||||
const DIMS: readonly AgentTuiStreamDims[] = [
|
||||
{ cols: 80, rows: 24 },
|
||||
{ cols: 100, rows: 30 },
|
||||
{ cols: 120, rows: 40 }
|
||||
]
|
||||
|
||||
type FidelityCase = {
|
||||
seed: number
|
||||
dims: AgentTuiStreamDims
|
||||
ops: string[]
|
||||
chunked: boolean
|
||||
}
|
||||
|
||||
type FidelityDiff = {
|
||||
stage: string
|
||||
expected: unknown
|
||||
actual: unknown
|
||||
/** True when the always-visible buffer matches the known upstream
|
||||
* @xterm/addon-serialize blank-leading-wrapped-row bug predicate — see
|
||||
* bufferHasSerializeHostileWrappedRow and the skipped repro test below. */
|
||||
knownSerializeWrapBug?: boolean
|
||||
/** True when the divergence is the known Bug B serialize `1;22` bold-reset
|
||||
* self-cancel (snapshotHasSelfCancellingBoldReset). */
|
||||
knownSerializeBoldReset?: boolean
|
||||
/** True when the divergence is the known Bug C wrap-pending cursor off-by-one
|
||||
* at the right margin (isMarginWrapPendingCursorOffByOne). */
|
||||
knownMarginCursorOffByOne?: boolean
|
||||
}
|
||||
|
||||
function buildCase(seed: number): FidelityCase {
|
||||
const rng = mulberry32(seed)
|
||||
const dims = DIMS[Math.floor(rng() * DIMS.length)]!
|
||||
const opCount = 12 + Math.floor(rng() * 28)
|
||||
const ops = buildAgentTuiStreamOps(rng, dims, {
|
||||
includeMouseModes: true,
|
||||
includeOscHyperlinks: false,
|
||||
opCount
|
||||
})
|
||||
return { seed, dims, ops, chunked: true }
|
||||
}
|
||||
|
||||
function firstDiff(stage: string, expected: unknown, actual: unknown): FidelityDiff | null {
|
||||
return JSON.stringify(expected) === JSON.stringify(actual) ? null : { stage, expected, actual }
|
||||
}
|
||||
|
||||
async function runFidelityCase(testCase: FidelityCase): Promise<FidelityDiff | null> {
|
||||
const stream = testCase.ops.join('')
|
||||
const chunks = testCase.chunked
|
||||
? splitIntoRandomChunks(mulberry32(testCase.seed ^ 0x9e3779b9), stream, {
|
||||
minLen: 3,
|
||||
maxLen: 120
|
||||
})
|
||||
: [stream]
|
||||
const emulator = new HeadlessEmulator({ cols: testCase.dims.cols, rows: testCase.dims.rows })
|
||||
const control = createRendererParityTerminal(testCase.dims)
|
||||
const restored = createRendererParityTerminal(testCase.dims)
|
||||
try {
|
||||
for (const chunk of chunks) {
|
||||
await emulator.write(chunk)
|
||||
}
|
||||
await writeChunksToTerminal(control.terminal, chunks)
|
||||
|
||||
// Stage 1 — model fidelity: the emulator's screen must already match the
|
||||
// renderer twin before any serialization enters the picture.
|
||||
const modelDiff = firstDiff(
|
||||
'model-visible (HeadlessEmulator vs renderer twin)',
|
||||
visibleRows(control.terminal),
|
||||
emulator.getVisibleLines()
|
||||
)
|
||||
if (modelDiff) {
|
||||
return modelDiff
|
||||
}
|
||||
|
||||
// Stage 2 — reveal round trip: serialize exactly like
|
||||
// serializeHiddenOutputRecoveryBuffer, replay exactly like
|
||||
// applyMainBufferSnapshot, then compare against the always-visible twin.
|
||||
const alt = emulator.isAlternateScreen
|
||||
const snapshot = emulator.getSnapshot({
|
||||
scrollbackRows: alt ? 0 : HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS
|
||||
})
|
||||
await writeChunksToTerminal(restored.terminal, [
|
||||
alt ? SNAPSHOT_REPLAY_PREAMBLE_ALT : SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
|
||||
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
|
||||
])
|
||||
|
||||
const diffs = [
|
||||
firstDiff(
|
||||
'restore-visible-text',
|
||||
visibleRows(control.terminal),
|
||||
visibleRows(restored.terminal)
|
||||
),
|
||||
firstDiff(
|
||||
'restore-visible-styles',
|
||||
visibleRowStyles(control.terminal),
|
||||
visibleRowStyles(restored.terminal)
|
||||
),
|
||||
firstDiff(
|
||||
'restore-cursor',
|
||||
cursorPosition(control.terminal),
|
||||
cursorPosition(restored.terminal)
|
||||
),
|
||||
firstDiff(
|
||||
'restore-mode-bracketed-paste',
|
||||
control.terminal.modes.bracketedPasteMode,
|
||||
restored.terminal.modes.bracketedPasteMode
|
||||
),
|
||||
// Why alt is excluded from the two comparisons below:
|
||||
// - scrollback: serializeHeadlessTerminalBuffer (orca-runtime.ts)
|
||||
// deliberately forces scrollbackRows=0 while an alt-screen TUI is
|
||||
// active, so normal-buffer history is not part of the alt contract.
|
||||
// - application cursor: HeadlessEmulator.getModes reports
|
||||
// applicationCursor false on the alternate buffer, so rehydrate omits
|
||||
// ?1h there by design.
|
||||
alt
|
||||
? null
|
||||
: firstDiff(
|
||||
'restore-scrollback-text',
|
||||
normalBufferRowsTrimmed(control.terminal),
|
||||
normalBufferRowsTrimmed(restored.terminal)
|
||||
),
|
||||
alt
|
||||
? null
|
||||
: firstDiff(
|
||||
'restore-mode-application-cursor',
|
||||
control.terminal.modes.applicationCursorKeysMode,
|
||||
restored.terminal.modes.applicationCursorKeysMode
|
||||
)
|
||||
]
|
||||
const diff = diffs.find((candidate) => candidate !== null) ?? null
|
||||
if (!diff) {
|
||||
return null
|
||||
}
|
||||
if (bufferHasSerializeHostileWrappedRow(control.terminal)) {
|
||||
return { ...diff, knownSerializeWrapBug: true }
|
||||
}
|
||||
if (
|
||||
diff.stage === 'restore-visible-styles' &&
|
||||
snapshotHasSelfCancellingBoldReset(snapshot.snapshotAnsi)
|
||||
) {
|
||||
return { ...diff, knownSerializeBoldReset: true }
|
||||
}
|
||||
if (
|
||||
diff.stage === 'restore-cursor' &&
|
||||
isMarginWrapPendingCursorOffByOne(control.terminal, restored.terminal)
|
||||
) {
|
||||
return { ...diff, knownMarginCursorOffByOne: true }
|
||||
}
|
||||
return diff
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
control.terminal.dispose()
|
||||
restored.terminal.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/** Greedy op-drop minimizer: re-runs the full differential pipeline on
|
||||
* smaller op lists so a failure report carries the smallest byte stream that
|
||||
* still diverges (plus its seed for exact replay via FUZZ_SEED). */
|
||||
async function minimizeFailure(testCase: FidelityCase): Promise<FidelityCase> {
|
||||
let current = { ...testCase, chunked: false }
|
||||
if ((await runFidelityCase(current)) === null) {
|
||||
current = { ...testCase, chunked: true }
|
||||
}
|
||||
let budget = 400
|
||||
let shrunk = true
|
||||
while (shrunk && budget > 0) {
|
||||
shrunk = false
|
||||
for (let i = current.ops.length - 1; i >= 0 && budget > 0; i--) {
|
||||
const candidate = { ...current, ops: current.ops.toSpliced(i, 1) }
|
||||
budget -= 1
|
||||
if ((await runFidelityCase(candidate)) !== null) {
|
||||
current = candidate
|
||||
shrunk = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function formatFailure(minimized: FidelityCase, diff: FidelityDiff | null): string {
|
||||
return [
|
||||
`HeadlessEmulator fidelity divergence — stage: ${diff?.stage ?? 'unknown'}`,
|
||||
`seed: ${minimized.seed} (re-run: FUZZ_SEED=${minimized.seed} pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/headless-emulator-fidelity.fuzz.test.ts)`,
|
||||
`dims: ${minimized.dims.cols}x${minimized.dims.rows} chunked: ${minimized.chunked}`,
|
||||
`minimized ops (${minimized.ops.length}): ${JSON.stringify(minimized.ops)}`,
|
||||
`expected (always-visible renderer twin): ${JSON.stringify(diff?.expected)}`,
|
||||
`actual (snapshot restore replay): ${JSON.stringify(diff?.actual)}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('headless emulator snapshot fidelity fuzz', () => {
|
||||
// Known-legitimate divergence, pinned so it cannot silently regress into a
|
||||
// real one: xterm marks OSC 8 hyperlink cells underlined, SerializeAddon
|
||||
// never re-emits OSC 8, and production compensates by shipping the ranges
|
||||
// out-of-band in snapshot.oscLinks (collectHeadlessOscLinkRanges) for the
|
||||
// renderer link provider to re-register. Byte-replay therefore keeps the
|
||||
// TEXT but not the link underline — the metadata must carry the range.
|
||||
it('drops OSC 8 underline from byte replay but preserves the range in snapshot metadata', async () => {
|
||||
const emulator = new HeadlessEmulator({ cols: 60, rows: 10 })
|
||||
const restored = createRendererParityTerminal({ cols: 60, rows: 10 })
|
||||
try {
|
||||
await emulator.write('\x1b]8;;https://example.com/pr/7\x07review link\x1b]8;;\x07 tail')
|
||||
const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 })
|
||||
await writeChunksToTerminal(restored.terminal, [
|
||||
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
|
||||
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
|
||||
])
|
||||
expect(visibleRows(restored.terminal)[0]).toBe('review link tail')
|
||||
expect(snapshot.oscLinks).toContainEqual({
|
||||
row: 0,
|
||||
startCol: 0,
|
||||
endCol: 11,
|
||||
uri: 'https://example.com/pr/7'
|
||||
})
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
restored.terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it(`matches an always-visible renderer twin across ${ITERATIONS} seeded agent-TUI streams`, async () => {
|
||||
// Each known-and-pinned serialize bug (A wrap, B bold-reset, C margin
|
||||
// cursor) is tolerated + counted so deep mode (FUZZ_ITERATIONS) surfaces
|
||||
// only GENUINELY NEW divergences. All three are minimized and pinned by the
|
||||
// skipped repro tests below; the counts guard against a degenerate corpus.
|
||||
let knownSerializeWrapBugHits = 0
|
||||
let knownSerializeBoldResetHits = 0
|
||||
let knownMarginCursorOffByOneHits = 0
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
const seed = FIXED_SEED ?? 1 + i
|
||||
const testCase = buildCase(seed)
|
||||
const diff = await runFidelityCase(testCase)
|
||||
if (diff?.knownSerializeWrapBug) {
|
||||
knownSerializeWrapBugHits += 1
|
||||
continue
|
||||
}
|
||||
if (diff?.knownSerializeBoldReset) {
|
||||
knownSerializeBoldResetHits += 1
|
||||
continue
|
||||
}
|
||||
if (diff?.knownMarginCursorOffByOne) {
|
||||
knownMarginCursorOffByOneHits += 1
|
||||
continue
|
||||
}
|
||||
if (diff) {
|
||||
const minimized = await minimizeFailure(testCase)
|
||||
const minimizedDiff = await runFidelityCase(minimized)
|
||||
expect.fail(formatFailure(minimized, minimizedDiff ?? diff))
|
||||
}
|
||||
}
|
||||
// Guard each tolerance from swallowing the suite: a known predicate tripping
|
||||
// on most seeds means the gate has gone degenerate.
|
||||
expect(knownSerializeWrapBugHits).toBeLessThan(Math.max(3, ITERATIONS * 0.5))
|
||||
expect(knownSerializeBoldResetHits).toBeLessThan(Math.max(3, ITERATIONS * 0.5))
|
||||
expect(knownMarginCursorOffByOneHits).toBeLessThan(Math.max(3, ITERATIONS * 0.5))
|
||||
}, 600_000)
|
||||
|
||||
// ── HEADLINE FINDING (do not delete while unfixed upstream) ──────────────
|
||||
// @xterm/addon-serialize 0.15.0-beta.287 does not round-trip null cells
|
||||
// that touch a soft-wrap boundary. Two variants, both found by this fuzz
|
||||
// and minimized below. Every Orca snapshot consumer is affected: hidden
|
||||
// reveal, parked-tab reveal, sleep/wake restore, and mobile subscribe
|
||||
// replay paint lost/shifted characters or stray '-' fillers whenever a TUI
|
||||
// erased inside a soft-wrapped line (shell line editing, status lines wider
|
||||
// than the pane, Claude Code in-place prompt redraws).
|
||||
//
|
||||
// V1 — cell loss (found by seed 31, minimized to 2 ops):
|
||||
// Root cause: the wrap-validity ternary in SerializeAddon.ts (~L214)
|
||||
// nextRowFirstChar.getChars() && isNextRowFirstCharDoubleWidth
|
||||
// ? this._nullCellCount <= 1 : this._nullCellCount <= 0
|
||||
// binds as `(chars && doubleWidth) ? ...`, so a null-leading wrapped row
|
||||
// passes as a "natural" wrap. The serializer then emits the previous row as
|
||||
// full-width text (leaving xterm in wrap-pending) and skips the null cell
|
||||
// with CUF (`ESC[1C`) — but CUF clamps at the right margin instead of
|
||||
// crossing the wrap boundary, so the next character overwrites the previous
|
||||
// row's last cell and the whole tail shifts left by one.
|
||||
// cols=20: write 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K'
|
||||
// live rows: ['ABCDEFGHIJKLMNOPQRST', ' VWXYZ12']
|
||||
// serialize(): 'ABCDEFGHIJKLMNOPQRST\x1b[1CVWXYZ12\x1b[8D'
|
||||
// replayed rows: ['ABCDEFGHIJKLMNOPQRSV', 'WXYZ12'] ← 'T' eaten, tail shifted
|
||||
//
|
||||
// V2 — stray filler '-' (found by seed 157, minimized below): when the
|
||||
// SOURCE row of a wrapped pair is entirely null (a TUI erased the whole
|
||||
// first half of a wrapped line), the addon's forced-wrap "magic" writes
|
||||
// nullCellCount+1 dashes and then cleans up with
|
||||
// ESC[A ESC[(length-nullCellCount)C ESC[(nullCellCount)X ...
|
||||
// With length === nullCellCount that cursor-forward becomes `ESC[0C`, and
|
||||
// CSI param 0 means 1, so the ECH erase lands one cell right and the first
|
||||
// '-' stays visible on the restored row.
|
||||
// Unskip once the upstream fix (or a local serialize post-processor) lands.
|
||||
it.skip('round-trips a wrapped line whose continuation row starts with an erased cell', async () => {
|
||||
const emulator = new HeadlessEmulator({ cols: 20, rows: 6 })
|
||||
const control = createRendererParityTerminal({ cols: 20, rows: 6 })
|
||||
const restored = createRendererParityTerminal({ cols: 20, rows: 6 })
|
||||
try {
|
||||
const bytes = ['ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n', '\x1b[1A\x1b[1K']
|
||||
for (const chunk of bytes) {
|
||||
await emulator.write(chunk)
|
||||
}
|
||||
await writeChunksToTerminal(control.terminal, bytes)
|
||||
const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 })
|
||||
await writeChunksToTerminal(restored.terminal, [
|
||||
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
|
||||
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
|
||||
])
|
||||
expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal))
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
control.terminal.dispose()
|
||||
restored.terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
// V2 repro of the headline finding above (stray '-' filler on a fully
|
||||
// erased wrapped source row). Unskip alongside the V1 repro.
|
||||
it.skip('round-trips a wrapped line whose source row was fully erased', async () => {
|
||||
const emulator = new HeadlessEmulator({ cols: 20, rows: 6 })
|
||||
const control = createRendererParityTerminal({ cols: 20, rows: 6 })
|
||||
const restored = createRendererParityTerminal({ cols: 20, rows: 6 })
|
||||
try {
|
||||
// Wrap a 28-char line, then erase the entire first (source) row of the
|
||||
// wrapped pair: cursor up twice onto it, EL 2.
|
||||
const bytes = ['ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n', '\x1b[2A\x1b[2K']
|
||||
for (const chunk of bytes) {
|
||||
await emulator.write(chunk)
|
||||
}
|
||||
await writeChunksToTerminal(control.terminal, bytes)
|
||||
const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 })
|
||||
await writeChunksToTerminal(restored.terminal, [
|
||||
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
|
||||
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
|
||||
])
|
||||
// Fails today: restored row 0 shows '-' where the live row is blank.
|
||||
expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal))
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
control.terminal.dispose()
|
||||
restored.terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Bug B: SGR bold lost on a dim→bold-only cell transition ──────────────
|
||||
// @xterm/addon-serialize 0.15.0-beta.287: serializing a dim cell followed by
|
||||
// a bold-only cell emits `\x1b[1;22m` for the transition — SGR 1 sets bold,
|
||||
// SGR 22 (normalIntensity) then clears BOTH bold and dim, so the restored
|
||||
// cell loses its bold. Found by fuzz seeds 435, 770, 1321. Garbles the bold
|
||||
// status/spinner line agent TUIs draw after a dim body line, on every reveal.
|
||||
// See notes/garble-fuzz-divergences.md (Bug B). Unskip when upstream fixes
|
||||
// the pen diff (clear dim with an explicit re-apply of bold, not SGR 22).
|
||||
it.skip('preserves bold when serializing a dim cell followed by a bold-only cell', async () => {
|
||||
const emulator = new HeadlessEmulator({ cols: 20, rows: 4 })
|
||||
const control = createRendererParityTerminal({ cols: 20, rows: 4 })
|
||||
const restored = createRendererParityTerminal({ cols: 20, rows: 4 })
|
||||
try {
|
||||
// 'A' dim, 'B' bold-only. Live: A=dim, B=bold. Serializer emits 1;22 for
|
||||
// the B transition, which zeroes bold on restore.
|
||||
const bytes = ['\x1b[2mA\x1b[22m\x1b[1mB\x1b[0m']
|
||||
for (const chunk of bytes) {
|
||||
await emulator.write(chunk)
|
||||
}
|
||||
await writeChunksToTerminal(control.terminal, bytes)
|
||||
const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 })
|
||||
await writeChunksToTerminal(restored.terminal, [
|
||||
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
|
||||
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
|
||||
])
|
||||
// Fails today: 'B' restores with bold cleared (style flags 000000 vs 100000).
|
||||
expect(visibleRowStyles(restored.terminal)).toEqual(visibleRowStyles(control.terminal))
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
control.terminal.dispose()
|
||||
restored.terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Bug C: cursor restored one column short when the last row fills the margin ──
|
||||
// @xterm/addon-serialize 0.15.0-beta.287: after emitting a content row filled
|
||||
// to exactly `cols`, xterm is left wrap-pending (cursor on the last column,
|
||||
// logically one past). The serializer's relative cursor-restore computes the
|
||||
// horizontal delta one short, so the cursor lands at x-1. Found by fuzz seeds
|
||||
// 454, 1696. Garbles the caret/spinner position on reveal of a TUI whose
|
||||
// bottom line reached the right edge; later input can echo into the wrong
|
||||
// cell. See notes/garble-fuzz-divergences.md (Bug C). Unskip when upstream
|
||||
// fixes the wrap-pending relative cursor math.
|
||||
it.skip('restores the cursor exactly when the last content row fills the right margin', async () => {
|
||||
const emulator = new HeadlessEmulator({ cols: 10, rows: 4 })
|
||||
const control = createRendererParityTerminal({ cols: 10, rows: 4 })
|
||||
const restored = createRendererParityTerminal({ cols: 10, rows: 4 })
|
||||
try {
|
||||
// Fill row 0 to exactly 10 cols (wrap-pending), then CUP the cursor to a
|
||||
// known lower-row column. Live cursor is (x=4, y=2).
|
||||
const bytes = ['0123456789\x1b[3;5H']
|
||||
for (const chunk of bytes) {
|
||||
await emulator.write(chunk)
|
||||
}
|
||||
await writeChunksToTerminal(control.terminal, bytes)
|
||||
const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 })
|
||||
await writeChunksToTerminal(restored.terminal, [
|
||||
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
|
||||
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
|
||||
])
|
||||
// Fails today: restored cursor is one column short (x=3 vs x=4).
|
||||
expect(cursorPosition(restored.terminal)).toEqual(cursorPosition(control.terminal))
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
control.terminal.dispose()
|
||||
restored.terminal.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,582 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildAgentTuiStreamOps,
|
||||
mulberry32,
|
||||
splitIntoRandomChunks,
|
||||
type AgentTuiStreamDims
|
||||
} from '../../../../shared/agent-tui-ansi-fuzz-stream'
|
||||
import {
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY,
|
||||
SNAPSHOT_REPLAY_PREAMBLE_ALT,
|
||||
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
|
||||
buildParityMainBufferSnapshot,
|
||||
createRendererParityTerminal,
|
||||
cursorPosition,
|
||||
normalBufferRowsTrimmed,
|
||||
visibleRows,
|
||||
visibleRowStyles,
|
||||
writeChunksToTerminal
|
||||
} from '../../../../shared/terminal-restore-parity-fixture'
|
||||
|
||||
// Property fuzz for the hidden-reveal seq-reconciliation path in
|
||||
// pty-connection.ts. When a pane is hidden, main keeps metering PTY output as
|
||||
// seq'd chunks. On reveal the renderer applies a HeadlessEmulator snapshot
|
||||
// captured at seq S (paints everything <= S), then stitches the racing tail
|
||||
// (chunks straddling / after S) on top WITHOUT duplicating or losing a byte.
|
||||
// The byte arithmetic lives in two non-exported closures:
|
||||
// - getChunkDataAfterSnapshot (pty-connection.ts ~L4629): slices a
|
||||
// pending chunk against the snapshot seq.
|
||||
// - reconcileChunkAgainstRestoredSnapshot (pty-connection.ts ~L4660): dedups
|
||||
// backlog, detects a restarted seq domain, and heals dropped-output gaps by
|
||||
// forcing a fresh restore.
|
||||
// This suite mirrors those rules EXACTLY (each branch tagged with its source
|
||||
// line) and asserts the invariant they guarantee: for any reveal boundary,
|
||||
// snapshot(hidden prefix) + reconciled tail reproduces the same screen an
|
||||
// always-visible terminal shows for the full stream. A wrong slice = a garble.
|
||||
//
|
||||
// Runtime knobs:
|
||||
// FUZZ_ITERATIONS=2000 deep/nightly (default 200, <60s with the fidelity suite)
|
||||
// FUZZ_SEED=1234 re-run exactly one seed
|
||||
|
||||
const DEFAULT_ITERATIONS = 200
|
||||
const FIXED_SEED = readPositiveIntEnv('FUZZ_SEED')
|
||||
const ITERATIONS =
|
||||
FIXED_SEED !== null ? 1 : (readPositiveIntEnv('FUZZ_ITERATIONS') ?? DEFAULT_ITERATIONS)
|
||||
|
||||
function readPositiveIntEnv(name: string): number | null {
|
||||
const raw = Number(process.env[name])
|
||||
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null
|
||||
}
|
||||
|
||||
const DIMS: readonly AgentTuiStreamDims[] = [
|
||||
{ cols: 80, rows: 24 },
|
||||
{ cols: 100, rows: 30 }
|
||||
]
|
||||
|
||||
/** A metered chunk exactly as main delivers to onData: text, the seq of the
|
||||
* LAST raw byte, the raw (pre-OSC-strip) length, and delivery-domain markers.
|
||||
* domain increments across a ptyId-exit seq restart (production clears the
|
||||
* restored baseline at that boundary, so the tail writes verbatim). */
|
||||
type MeteredChunk = {
|
||||
data: string
|
||||
seq?: number
|
||||
rawLength?: number
|
||||
domain: number
|
||||
droppedOutput?: boolean
|
||||
}
|
||||
|
||||
/** Mirror of getChunkDataAfterSnapshot (pty-connection.ts ~L4629): how much of
|
||||
* a chunk in the SNAPSHOT'S seq domain survives once the snapshot at
|
||||
* snapshotSeq has painted everything <= snapshotSeq. null = "cannot slice,
|
||||
* force a fresh snapshot" (OSC stripping desynced raw offsets). Byte-identical
|
||||
* to production; each return tagged with its branch. */
|
||||
function chunkDataAfterSnapshot(
|
||||
chunk: MeteredChunk,
|
||||
snapshotSeq: number | undefined
|
||||
): string | null {
|
||||
if (typeof snapshotSeq !== 'number' || typeof chunk.seq !== 'number') {
|
||||
return chunk.data // L4633: unmetered — pass through
|
||||
}
|
||||
const rawLength = chunk.rawLength ?? chunk.data.length
|
||||
const startSeq = chunk.seq - rawLength
|
||||
if (snapshotSeq >= chunk.seq) {
|
||||
return '' // L4638: fully before/at snapshot — already painted
|
||||
}
|
||||
if (snapshotSeq <= startSeq) {
|
||||
return chunk.data // L4641: fully after snapshot — keep whole
|
||||
}
|
||||
const offset = snapshotSeq - startSeq
|
||||
if (rawLength !== chunk.data.length) {
|
||||
return null // L4645: OSC-stripped, offsets unmappable
|
||||
}
|
||||
return chunk.data.slice(offset) // L4648: straddles — drop the painted prefix
|
||||
}
|
||||
|
||||
type RevealResult = {
|
||||
/** Full normal-buffer content (visible + scrollback, trailing blanks
|
||||
* trimmed). Viewport-independent: a byte lost or duplicated by reconciliation
|
||||
* changes this even when the visible viewport happens to line up. */
|
||||
content: string[]
|
||||
rows: string[]
|
||||
styles: string[]
|
||||
cursor: { x: number; y: number }
|
||||
/** True when the buffer scrolled (baseY > 0). The visible viewport's top
|
||||
* anchor after a snapshot restore vs continuous writing can differ by a row
|
||||
* or two purely from trailing-blank trimming — a snapshot-scrollback-depth
|
||||
* concern owned by the fidelity suite, not seq reconciliation — so styles and
|
||||
* cursor (viewport-relative) are only asserted on non-scrolled scenarios. */
|
||||
scrolled: boolean
|
||||
/** True when the terminal ended on the alternate screen. Alt has no
|
||||
* scrollback and a fixed viewport, so its content is the visible rows and the
|
||||
* normal-buffer content comparison does not apply (the alt-screen restore
|
||||
* contract is scrollback-free by design — serializeHeadlessTerminalBuffer). */
|
||||
alternate: boolean
|
||||
forcedFreshRestore: boolean
|
||||
}
|
||||
|
||||
/** Bug E detector: does the hidden prefix (what the snapshot captured) end
|
||||
* mid-escape-sequence? A PTY read (one delivery record) can split an escape;
|
||||
* the emulator then holds partial parser state that the serialized SCREEN
|
||||
* cannot carry, so the racing tail's continuation bytes render as literal text.
|
||||
* Returns true when the prefix ends with a dangling ESC, ESC[…(no final byte),
|
||||
* ESC](no ST/BEL), or a lone ESC-intermediate. */
|
||||
function prefixEndsMidSequence(prefix: string): boolean {
|
||||
const esc = prefix.lastIndexOf('\x1b')
|
||||
if (esc === -1) {
|
||||
return false
|
||||
}
|
||||
const rest = prefix.slice(esc)
|
||||
if (rest === '\x1b') {
|
||||
return true // dangling ESC
|
||||
}
|
||||
const kind = rest[1]
|
||||
if (kind === '[') {
|
||||
// CSI: complete iff a final byte 0x40–0x7e appears after the params.
|
||||
for (let i = 2; i < rest.length; i++) {
|
||||
const code = rest.charCodeAt(i)
|
||||
if (code >= 0x40 && code <= 0x7e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (kind === ']') {
|
||||
// OSC: complete iff terminated by BEL or ST (ESC \).
|
||||
return !(rest.includes('\x07') || rest.includes('\x1b\\'))
|
||||
}
|
||||
// ESC 7 / ESC 8 / ESC-intermediate: two bytes complete it. Dangling iff only
|
||||
// ESC was written (handled above) — a present kind byte means it completed.
|
||||
return false
|
||||
}
|
||||
|
||||
async function readScreen(
|
||||
term: ReturnType<typeof createRendererParityTerminal>
|
||||
): Promise<RevealResult> {
|
||||
const alternate = term.terminal.buffer.active.type === 'alternate'
|
||||
return {
|
||||
content: alternate ? visibleRows(term.terminal) : normalBufferRowsTrimmed(term.terminal),
|
||||
rows: visibleRows(term.terminal),
|
||||
styles: visibleRowStyles(term.terminal),
|
||||
cursor: cursorPosition(term.terminal),
|
||||
scrolled: term.terminal.buffer.active.baseY > 0,
|
||||
alternate,
|
||||
forcedFreshRestore: false
|
||||
}
|
||||
}
|
||||
|
||||
/** The renderer's screen for a hide→reveal cycle. `revealIdx` is the delivery
|
||||
* index at which the pane is revealed: chunks [0, revealIdx) were captured by
|
||||
* the HeadlessEmulator snapshot; chunks [revealIdx, end) are the racing tail.
|
||||
* The snapshot seq is the seq of the last hidden chunk in the snapshot's
|
||||
* domain; the tail is stitched via the production slice/reconcile rules. */
|
||||
async function revealFromSnapshot(
|
||||
dims: AgentTuiStreamDims,
|
||||
chunks: MeteredChunk[],
|
||||
revealIdx: number
|
||||
): Promise<RevealResult> {
|
||||
const source = createRendererParityTerminal(dims)
|
||||
const restored = createRendererParityTerminal(dims)
|
||||
try {
|
||||
// Snapshot source = every hidden chunk painted in order.
|
||||
await writeChunksToTerminal(
|
||||
source.terminal,
|
||||
chunks.slice(0, revealIdx).map((c) => c.data)
|
||||
)
|
||||
// Snapshot seq = seq of the last hidden chunk that carried one (the seq the
|
||||
// emulator would report). undefined when the hidden prefix was unmetered.
|
||||
let snapshotSeq: number | undefined
|
||||
let snapshotDomain = 0
|
||||
for (let i = revealIdx - 1; i >= 0; i--) {
|
||||
if (typeof chunks[i]!.seq === 'number') {
|
||||
snapshotSeq = chunks[i]!.seq
|
||||
snapshotDomain = chunks[i]!.domain
|
||||
break
|
||||
}
|
||||
}
|
||||
const snapshot = buildParityMainBufferSnapshot(source, snapshotSeq ?? 0)
|
||||
const alt = snapshot.alternateScreen
|
||||
await writeChunksToTerminal(restored.terminal, [
|
||||
alt ? SNAPSHOT_REPLAY_PREAMBLE_ALT : SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
|
||||
snapshot.data,
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
|
||||
])
|
||||
|
||||
// Stitch the tail. A chunk in the snapshot's domain is sliced against the
|
||||
// snapshot seq (drains the painted prefix); a chunk from a later domain
|
||||
// (post-exit seq restart) writes verbatim — production clears the baseline
|
||||
// at the ptyId-exit boundary, so no seq dedupe applies across domains.
|
||||
let forcedFreshRestore = false
|
||||
for (let i = revealIdx; i < chunks.length; i++) {
|
||||
const chunk = chunks[i]!
|
||||
if (chunk.domain !== snapshotDomain) {
|
||||
await writeChunksToTerminal(restored.terminal, [chunk.data])
|
||||
continue
|
||||
}
|
||||
const sliced = chunkDataAfterSnapshot(chunk, snapshotSeq)
|
||||
if (sliced === null) {
|
||||
// Production re-fetches a fresh snapshot here; a fresh snapshot of the
|
||||
// full stream trivially matches the always-visible screen, so mark the
|
||||
// scenario as a fresh-restore (excluded from the stitch comparison).
|
||||
forcedFreshRestore = true
|
||||
continue
|
||||
}
|
||||
if (sliced) {
|
||||
await writeChunksToTerminal(restored.terminal, [sliced])
|
||||
}
|
||||
}
|
||||
const result = await readScreen(restored)
|
||||
result.forcedFreshRestore = forcedFreshRestore
|
||||
return result
|
||||
} finally {
|
||||
source.terminal.dispose()
|
||||
restored.terminal.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
async function alwaysVisible(
|
||||
dims: AgentTuiStreamDims,
|
||||
chunks: MeteredChunk[]
|
||||
): Promise<RevealResult> {
|
||||
const term = createRendererParityTerminal(dims)
|
||||
try {
|
||||
await writeChunksToTerminal(
|
||||
term.terminal,
|
||||
chunks.map((c) => c.data)
|
||||
)
|
||||
return await readScreen(term)
|
||||
} finally {
|
||||
term.terminal.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/** Reference for the seq-reconciliation gate: a snapshot of the WHOLE stream
|
||||
* (reveal at the very end, no racing tail). Sharing the snapshot machinery with
|
||||
* revealFromSnapshot cancels out snapshot-fidelity gaps (Bug C cursor restore,
|
||||
* Bug B bold loss — owned by the fidelity suite), so a diff against a mid-point
|
||||
* reveal is attributable purely to the tail-stitch seq arithmetic. */
|
||||
async function fullSnapshotReference(
|
||||
dims: AgentTuiStreamDims,
|
||||
chunks: MeteredChunk[]
|
||||
): Promise<RevealResult> {
|
||||
return revealFromSnapshot(dims, chunks, chunks.length)
|
||||
}
|
||||
|
||||
type Scenario = {
|
||||
seed: number
|
||||
dims: AgentTuiStreamDims
|
||||
chunks: MeteredChunk[]
|
||||
revealIdx: number
|
||||
domains: number
|
||||
hasDropped: boolean
|
||||
}
|
||||
|
||||
const TAIL_WORDS = [
|
||||
'reading',
|
||||
'tokens 12.4k',
|
||||
'你好世界',
|
||||
'터미널',
|
||||
'✅ done',
|
||||
'PASS test.ts',
|
||||
'+142 -37',
|
||||
'diff --git'
|
||||
] as const
|
||||
// Why no SGR 7 (inverse): inverse marks trailing BLANK cells with an
|
||||
// inverse-fg the serializer round-trips slightly differently depending on how
|
||||
// much of the buffer it captures — a serialize SGR-fidelity nuance (the Bug B
|
||||
// class) that has nothing to do with seq reconciliation. Keeping it out of the
|
||||
// tail keeps the styles gate a clean function of the byte-stitch.
|
||||
const TAIL_SGR = ['\x1b[0m', '\x1b[31m', '\x1b[1m', '\x1b[38;5;204m', '\x1b[22m'] as const
|
||||
|
||||
/** Append-only racing tail: SGR runs + text + newlines only. No absolute/
|
||||
* relative cursor motion, scroll regions, alt frames, or DECSC — so the tail
|
||||
* paints wherever the cursor sits and its screen effect is a pure function of
|
||||
* which bytes the seq-reconciliation applies. Terminal-state-loss garbles
|
||||
* (Bugs C/D/E, scroll region) are pinned separately; this suite isolates the
|
||||
* seq slice. */
|
||||
function buildAppendOnlyTail(rng: () => number, lines: number): string {
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < lines; i++) {
|
||||
const words: string[] = []
|
||||
const count = 1 + Math.floor(rng() * 3)
|
||||
for (let w = 0; w < count; w++) {
|
||||
words.push(TAIL_WORDS[Math.floor(rng() * TAIL_WORDS.length)]!)
|
||||
}
|
||||
const sgr = TAIL_SGR[Math.floor(rng() * TAIL_SGR.length)]!
|
||||
out.push(`${sgr}${words.join(' ')}\x1b[0m\r\n`)
|
||||
}
|
||||
return out.join('')
|
||||
}
|
||||
|
||||
/** Builds a seeded metered stream with a random reveal boundary. A running seq
|
||||
* counter tags the LAST byte of each chunk; at most ONE mid-stream restart
|
||||
* bumps the domain and resets the counter low (a revived ptyId with a fresh
|
||||
* main-side counter — production clears the restored baseline at that exit
|
||||
* boundary, so the post-restart tail writes verbatim). Rare unmetered chunks
|
||||
* (no seq) and droppedOutput markers model no-metering runtimes and pending-cap
|
||||
* trims. Op counts are kept modest so most scenarios reach the full
|
||||
* non-scrolled comparison (the tail-stitch invariant); scrolled scenarios fall
|
||||
* back to the snapshot-depth-tolerant path (see the assertion loop). */
|
||||
function buildScenario(seed: number): Scenario {
|
||||
const rng = mulberry32(seed)
|
||||
const dims = DIMS[Math.floor(rng() * DIMS.length)]!
|
||||
// Hidden prefix: full agent-TUI churn (cursor motion, panels, alt frames,
|
||||
// scroll regions, DECSC…) so the SNAPSHOT is taken over rich state.
|
||||
const prefixOps = buildAgentTuiStreamOps(rng, dims, {
|
||||
includeMouseModes: false,
|
||||
includeOscHyperlinks: false,
|
||||
opCount: 3 + Math.floor(rng() * 6)
|
||||
})
|
||||
const prefixStream = prefixOps.join('')
|
||||
const prefixRaw = splitIntoRandomChunks(mulberry32(seed ^ 0x51ed270b), prefixStream, {
|
||||
minLen: 2,
|
||||
maxLen: 64
|
||||
})
|
||||
// Racing tail: append-only content. Isolates the seq-reconciliation byte
|
||||
// stitch from terminal-state-loss garbles (Bugs C/D/E — pinned separately).
|
||||
const tailStream = buildAppendOnlyTail(rng, 2 + Math.floor(rng() * 5))
|
||||
const tailRaw = splitIntoRandomChunks(mulberry32(seed ^ 0x2545f491), tailStream, {
|
||||
minLen: 2,
|
||||
maxLen: 40
|
||||
})
|
||||
|
||||
const chunks: MeteredChunk[] = []
|
||||
let seq = 100 + Math.floor(rng() * 50)
|
||||
let domain = 0
|
||||
let hasDropped = false
|
||||
// At most one restart, placed inside the tail so the snapshot's own domain
|
||||
// has a stable prefix — mirrors a single mid-session ptyId revival.
|
||||
const restartAt =
|
||||
rng() < 0.25 ? prefixRaw.length + Math.floor(rng() * Math.max(1, tailRaw.length)) : -1
|
||||
const allRaw = [...prefixRaw, ...tailRaw]
|
||||
for (let i = 0; i < allRaw.length; i++) {
|
||||
const data = allRaw[i]!
|
||||
if (i === restartAt) {
|
||||
seq = 10 + Math.floor(rng() * 20)
|
||||
domain = 1
|
||||
}
|
||||
const rawLength = data.length
|
||||
seq += rawLength
|
||||
const chunk: MeteredChunk = { data, seq, rawLength, domain }
|
||||
// Rare unmetered / dropped markers, only in the tail (the prefix must meter
|
||||
// so the snapshot carries a seq).
|
||||
if (i >= prefixRaw.length && rng() < 0.05) {
|
||||
delete chunk.seq
|
||||
delete chunk.rawLength
|
||||
}
|
||||
if (i >= prefixRaw.length && rng() < 0.04) {
|
||||
chunk.droppedOutput = true
|
||||
hasDropped = true
|
||||
}
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Reveal at or after the prefix ends, so the racing tail is purely the
|
||||
// append-only region — the snapshot captures all cursor-motion/scroll/alt
|
||||
// state and the tail cannot depend on unserialized terminal state. Landing
|
||||
// the reveal at various points INSIDE the append-only tail exercises the
|
||||
// straddle and "snapshot seq inside the tail" seq-slice cases.
|
||||
const tailSpan = Math.max(1, chunks.length - prefixRaw.length)
|
||||
const revealIdx = Math.min(chunks.length, prefixRaw.length + Math.floor(rng() * tailSpan))
|
||||
return { seed, dims, chunks, revealIdx, domains: domain + 1, hasDropped }
|
||||
}
|
||||
|
||||
function firstRowDiff(a: string[], b: string[]): number | null {
|
||||
const n = Math.max(a.length, b.length)
|
||||
for (let i = 0; i < n; i++) {
|
||||
if ((a[i] ?? '') !== (b[i] ?? '')) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function formatFailure(s: Scenario, stage: string, expected: unknown, actual: unknown): string {
|
||||
return [
|
||||
`hidden-reveal reconciliation divergence — stage: ${stage}`,
|
||||
`seed: ${s.seed} (re-run: FUZZ_SEED=${s.seed})`,
|
||||
`dims: ${s.dims.cols}x${s.dims.rows} revealIdx: ${s.revealIdx}/${s.chunks.length} domains: ${s.domains} dropped: ${s.hasDropped}`,
|
||||
`chunks: ${JSON.stringify(
|
||||
s.chunks.map((c) => ({ seq: c.seq, len: c.data.length, dom: c.domain }))
|
||||
)}`,
|
||||
`expected (always-visible): ${JSON.stringify(expected)}`,
|
||||
`actual (reveal-from-snapshot): ${JSON.stringify(actual)}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('hidden reveal seq-reconciliation fuzz', () => {
|
||||
it('is a byte-exact identity when the snapshot seq splits a straddling chunk', async () => {
|
||||
// Pin the core invariant on a hand-built case so the property test cannot
|
||||
// pass vacuously. rawLength === data.length so the straddle slices cleanly.
|
||||
// Chunk 0 fully hidden; chunk 1 straddles the reveal; chunk 2 is the tail.
|
||||
const dims = { cols: 40, rows: 6 }
|
||||
const c0 = 'red line one\r\n'
|
||||
const c1 = 'green straddle\r\n'
|
||||
const c2 = 'plain tail rest'
|
||||
const chunks: MeteredChunk[] = [
|
||||
{ data: c0, seq: c0.length, rawLength: c0.length, domain: 0 },
|
||||
{ data: c1, seq: c0.length + c1.length, rawLength: c1.length, domain: 0 },
|
||||
{ data: c2, seq: c0.length + c1.length + c2.length, rawLength: c2.length, domain: 0 }
|
||||
]
|
||||
// Reveal after chunk 1 (snapshot seq = end of c1); tail = c2. Also exercise
|
||||
// the straddle by revealing at chunk 1 with a snapshot seq mid-c1 handled by
|
||||
// chunkDataAfterSnapshot when c1 is in the tail — covered by revealIdx=1.
|
||||
const revealAfterC1 = await revealFromSnapshot(dims, chunks, 2)
|
||||
const control = await alwaysVisible(dims, chunks)
|
||||
expect(revealAfterC1.rows).toEqual(control.rows)
|
||||
expect(revealAfterC1.styles).toEqual(control.styles)
|
||||
expect(revealAfterC1.cursor).toEqual(control.cursor)
|
||||
|
||||
// Reveal at chunk 1: c0 hidden (snapshot seq = end of c0), c1+c2 are the
|
||||
// tail. c1 is fully after the snapshot seq so it writes whole — identity.
|
||||
const revealAtC1 = await revealFromSnapshot(dims, chunks, 1)
|
||||
expect(revealAtC1.rows).toEqual(control.rows)
|
||||
expect(revealAtC1.styles).toEqual(control.styles)
|
||||
expect(revealAtC1.cursor).toEqual(control.cursor)
|
||||
})
|
||||
|
||||
// ── Bug D: DECSC saved-cursor register lost across the hide/reveal boundary ──
|
||||
// The snapshot is a serialized screen; the VT100 saved-cursor register
|
||||
// (DECSC ESC 7 / CSI s) is runtime state it cannot carry. When a hidden TUI
|
||||
// saves the cursor before the reveal seq and the racing tail restores it
|
||||
// (DECRC ESC 8 / CSI u), the restore lands at home instead — the next writes
|
||||
// clobber the wrong cells. Found by fuzz seed 3 (a savedCursorDetour op split
|
||||
// across the reveal boundary). See notes/garble-fuzz-divergences.md (Bug D).
|
||||
// Fix direction: carry the saved-cursor out-of-band in the snapshot (like
|
||||
// oscLinks) or re-emit a synthetic DECSC after replay. Unskip once landed.
|
||||
it.skip('preserves the DECSC saved-cursor register across a hide/reveal boundary', async () => {
|
||||
const dims = { cols: 20, rows: 4 }
|
||||
// Hidden: write 'AB', DECSC saves cursor at r0c2, move to r3c9, write 'CD'.
|
||||
const hidden: MeteredChunk = {
|
||||
data: 'AB\x1b7\x1b[4;10HCD',
|
||||
seq: 'AB\x1b7\x1b[4;10HCD'.length,
|
||||
rawLength: 'AB\x1b7\x1b[4;10HCD'.length,
|
||||
domain: 0
|
||||
}
|
||||
// Tail: DECRC restores the saved cursor, write 'X' → live shows 'ABX'.
|
||||
const tail: MeteredChunk = {
|
||||
data: '\x1b8X',
|
||||
seq: hidden.seq! + 3,
|
||||
rawLength: 3,
|
||||
domain: 0
|
||||
}
|
||||
const reveal = await revealFromSnapshot(dims, [hidden, tail], 1)
|
||||
const live = await alwaysVisible(dims, [hidden, tail])
|
||||
// Fails today: reveal shows 'XB' (DECRC landed at home) not live's 'ABX'.
|
||||
expect(reveal.rows).toEqual(live.rows)
|
||||
expect(reveal.cursor).toEqual(live.cursor)
|
||||
})
|
||||
|
||||
// ── Bug E: snapshot boundary mid-escape-sequence drops the partial sequence ──
|
||||
// A PTY read (one delivery record) can split an escape. If the pane is
|
||||
// revealed with the emulator parser mid-ESC[…, the serialized SCREEN cannot
|
||||
// carry the partial sequence, so the racing tail's continuation bytes render
|
||||
// as literal text. Found by fuzz seed 4 (a hidden prefix chunk that ended on
|
||||
// ESC[3, tail supplied 'm'). See notes/garble-fuzz-divergences.md (Bug E).
|
||||
// Fix direction: have main hold a trailing incomplete escape out of the drain
|
||||
// until it completes, or snapshot only at a parser-clean boundary. Unskip once
|
||||
// landed.
|
||||
it.skip('completes an escape sequence split across the hide/reveal boundary', async () => {
|
||||
const dims = { cols: 20, rows: 3 }
|
||||
// Hidden prefix ends mid-escape: 'AB' then ESC[3 (no final byte).
|
||||
const hidden: MeteredChunk = { data: 'AB\x1b[3', seq: 5, rawLength: 5, domain: 0 }
|
||||
// Tail completes it: 'm' → ESC[3m (italic), then 'CD' italic. Live: 'ABCD'.
|
||||
const tail: MeteredChunk = { data: 'mCD', seq: 8, rawLength: 3, domain: 0 }
|
||||
const reveal = await revealFromSnapshot(dims, [hidden, tail], 1)
|
||||
const live = await alwaysVisible(dims, [hidden, tail])
|
||||
// Fails today: reveal shows 'ABmCD' (the 'm' became literal).
|
||||
expect(reveal.rows).toEqual(live.rows)
|
||||
})
|
||||
|
||||
it(`stitches the racing tail losslessly across ${ITERATIONS} seeded hide/reveal scenarios`, async () => {
|
||||
let statsCompared = 0
|
||||
let statsSkippedScrolled = 0
|
||||
let statsForcedFresh = 0
|
||||
let statsMidSequenceBugE = 0
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
const seed = FIXED_SEED ?? 1 + i
|
||||
const scenario = buildScenario(seed)
|
||||
// Bug E: the snapshot boundary falls mid-escape-sequence (a PTY read
|
||||
// split an escape). The partial sequence lives in the emulator parser,
|
||||
// not the serialized screen, so the tail's continuation renders literal.
|
||||
// Tolerated + counted; pinned by the skipped repro below.
|
||||
const hiddenPrefix = scenario.chunks
|
||||
.slice(0, scenario.revealIdx)
|
||||
.map((c) => c.data)
|
||||
.join('')
|
||||
if (prefixEndsMidSequence(hiddenPrefix)) {
|
||||
statsMidSequenceBugE += 1
|
||||
continue
|
||||
}
|
||||
// Primary control: a snapshot of the WHOLE stream. Sharing the snapshot
|
||||
// machinery isolates the seq-reconciliation tail-stitch from
|
||||
// snapshot-fidelity gaps (fidelity suite's Bugs B/C). If snapshot-at-S +
|
||||
// tail differs from snapshot-of-everything, a byte was lost/duped/mis-
|
||||
// sliced. Also compare against always-visible where the two agree, to
|
||||
// catch a tail that diverges from live output.
|
||||
const [reveal, reference, live] = await Promise.all([
|
||||
revealFromSnapshot(scenario.dims, scenario.chunks, scenario.revealIdx),
|
||||
fullSnapshotReference(scenario.dims, scenario.chunks),
|
||||
alwaysVisible(scenario.dims, scenario.chunks)
|
||||
])
|
||||
if (reveal.forcedFreshRestore || reference.forcedFreshRestore) {
|
||||
statsForcedFresh += 1
|
||||
continue
|
||||
}
|
||||
if (reveal.alternate !== reference.alternate) {
|
||||
expect.fail(
|
||||
formatFailure(scenario, 'alt-screen-state', reference.alternate, reveal.alternate)
|
||||
)
|
||||
}
|
||||
// Scrolled scenarios: top-row survival is a snapshot-depth question
|
||||
// (fidelity suite), not seq reconciliation.
|
||||
if (reveal.scrolled || reference.scrolled) {
|
||||
statsSkippedScrolled += 1
|
||||
continue
|
||||
}
|
||||
statsCompared += 1
|
||||
// Seq-reconciliation gate (snapshot-fidelity-neutral).
|
||||
if (firstRowDiff(reveal.content, reference.content) !== null) {
|
||||
expect.fail(
|
||||
formatFailure(scenario, 'tail-stitch-content', reference.content, reveal.content)
|
||||
)
|
||||
}
|
||||
if (firstRowDiff(reveal.rows, reference.rows) !== null) {
|
||||
expect.fail(formatFailure(scenario, 'tail-stitch-visible', reference.rows, reveal.rows))
|
||||
}
|
||||
if (firstRowDiff(reveal.styles, reference.styles) !== null) {
|
||||
expect.fail(formatFailure(scenario, 'tail-stitch-styles', reference.styles, reveal.styles))
|
||||
}
|
||||
if (JSON.stringify(reveal.cursor) !== JSON.stringify(reference.cursor)) {
|
||||
expect.fail(formatFailure(scenario, 'tail-stitch-cursor', reference.cursor, reveal.cursor))
|
||||
}
|
||||
// Stronger gate: when a full-stream SNAPSHOT already matches the
|
||||
// always-visible LIVE screen (no fidelity gap in play), the mid-point
|
||||
// reveal must match live too — a true end-to-end garble check.
|
||||
if (
|
||||
!live.scrolled &&
|
||||
live.alternate === reference.alternate &&
|
||||
firstRowDiff(reference.rows, live.rows) === null &&
|
||||
firstRowDiff(reference.styles, live.styles) === null &&
|
||||
JSON.stringify(reference.cursor) === JSON.stringify(live.cursor)
|
||||
) {
|
||||
if (firstRowDiff(reveal.rows, live.rows) !== null) {
|
||||
expect.fail(formatFailure(scenario, 'live-visible', live.rows, reveal.rows))
|
||||
}
|
||||
if (firstRowDiff(reveal.styles, live.styles) !== null) {
|
||||
expect.fail(formatFailure(scenario, 'live-styles', live.styles, reveal.styles))
|
||||
}
|
||||
if (JSON.stringify(reveal.cursor) !== JSON.stringify(live.cursor)) {
|
||||
expect.fail(formatFailure(scenario, 'live-cursor', live.cursor, reveal.cursor))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Guard against a degenerate corpus that skips its way to green: a healthy
|
||||
// fraction of scenarios must reach the full non-scrolled comparison.
|
||||
expect(statsCompared).toBeGreaterThan(ITERATIONS * 0.2)
|
||||
// The mid-escape (Bug E) tolerance must not swallow the suite.
|
||||
expect(statsMidSequenceBugE).toBeLessThan(Math.max(3, ITERATIONS * 0.5))
|
||||
expect(
|
||||
statsCompared + statsSkippedScrolled + statsForcedFresh + statsMidSequenceBugE
|
||||
).toBeLessThanOrEqual(ITERATIONS)
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -0,0 +1,279 @@
|
||||
// Seeded ANSI byte-stream generator for the terminal garble differential
|
||||
// fuzz suites (headless-emulator-fidelity.fuzz.test.ts and
|
||||
// hidden-reveal-reconciliation.fuzz.test.ts). The op mix models real agent
|
||||
// TUI output (Claude Code / Codex): CR status-line redraws, cursor-up panel
|
||||
// repaints, DEC 2026 synchronized frames, SGR color runs, wide CJK and ZWJ
|
||||
// emoji, wrapped long lines, alt-screen sessions, and scroll regions.
|
||||
//
|
||||
// Deliberately excluded ops (they would fuzz behavior the snapshot/restore
|
||||
// contract does not promise to preserve, so any diff would be noise, not a
|
||||
// garble bug):
|
||||
// - DECAWM (?7l) and IRM (CSI 4h): SerializeAddon does not re-emit them and
|
||||
// rehydrateSequences (headless-emulator.ts buildRehydrateSequences) only
|
||||
// covers alt-screen/bracketed-paste/app-cursor/mouse modes.
|
||||
// - Terminal queries (DA/DSR/DECRQM): the emulator is write-only by contract
|
||||
// (headless-emulator.ts onQueryReply gating); replies are a separate
|
||||
// authority problem with its own pinned tests (session.test.ts).
|
||||
|
||||
/** Same seeded PRNG as retained-tail-redraw-window.equivalence.test.ts. */
|
||||
export function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentTuiStreamDims = { cols: number; rows: number }
|
||||
|
||||
export type AgentTuiStreamProfile = {
|
||||
/** Mouse-mode toggles require mirroring TerminalMouseModeMirror to build
|
||||
* rehydrate parity; the renderer-side fuzz cannot import that main-only
|
||||
* module (tsconfig.tc.web.json excludes src/main/daemon), so it opts out. */
|
||||
includeMouseModes: boolean
|
||||
/** OSC 8 hyperlinks mark their cells underlined in the xterm buffer, but
|
||||
* SerializeAddon never re-emits OSC 8 — production restores link ranges
|
||||
* out-of-band via snapshot.oscLinks (collectHeadlessOscLinkRanges), so
|
||||
* byte-replay fidelity legitimately drops that underline. The suites pin
|
||||
* the metadata compensation in a targeted test instead of fuzzing it. */
|
||||
includeOscHyperlinks: boolean
|
||||
opCount: number
|
||||
}
|
||||
|
||||
function int(rng: () => number, min: number, max: number): number {
|
||||
return min + Math.floor(rng() * (max - min + 1))
|
||||
}
|
||||
|
||||
function pick<T>(rng: () => number, values: readonly T[]): T {
|
||||
return values[Math.floor(rng() * values.length)]!
|
||||
}
|
||||
|
||||
const WORDS = [
|
||||
'reading',
|
||||
'src/main/daemon/session.ts',
|
||||
'tokens 12.4k',
|
||||
'esc to interrupt',
|
||||
'Thinking…',
|
||||
'bash: pnpm typecheck',
|
||||
'+142 -37',
|
||||
'PASS terminal.test.ts',
|
||||
'waiting for approval',
|
||||
'diff --git a/pty.ts'
|
||||
] as const
|
||||
|
||||
const WIDE_RUNS = [
|
||||
'你好世界',
|
||||
'터미널 상태 확인',
|
||||
'進捗を表示中',
|
||||
'🟢 working',
|
||||
'🤖 codex',
|
||||
'✅ done ✨',
|
||||
// ZWJ emoji join — the exact width divergence the Orca unicode provider
|
||||
// exists for (shared/terminal-unicode-provider.ts).
|
||||
'👨👩👧👦 team',
|
||||
'🇰🇷 locale'
|
||||
] as const
|
||||
|
||||
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'] as const
|
||||
|
||||
function sgr(rng: () => number): string {
|
||||
const roll = rng()
|
||||
if (roll < 0.2) {
|
||||
return `\x1b[${pick(rng, ['0', '1', '2', '3', '4', '7', '9', '22', '24', '39', '49'])}m`
|
||||
}
|
||||
if (roll < 0.55) {
|
||||
return `\x1b[${int(rng, 30, 37) + (rng() < 0.3 ? 60 : 0)}m`
|
||||
}
|
||||
if (roll < 0.8) {
|
||||
return `\x1b[38;5;${int(rng, 0, 255)}m`
|
||||
}
|
||||
return `\x1b[38;2;${int(rng, 0, 255)};${int(rng, 0, 255)};${int(rng, 0, 255)}m`
|
||||
}
|
||||
|
||||
function textRun(rng: () => number): string {
|
||||
const parts: string[] = []
|
||||
const count = int(rng, 1, 3)
|
||||
for (let i = 0; i < count; i++) {
|
||||
parts.push(rng() < 0.25 ? pick(rng, WIDE_RUNS) : pick(rng, WORDS))
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function styledLine(rng: () => number): string {
|
||||
return `${sgr(rng)}${textRun(rng)}\x1b[0m\r\n`
|
||||
}
|
||||
|
||||
function panelRedraw(rng: () => number, dims: AgentTuiStreamDims): string {
|
||||
const height = int(rng, 1, Math.min(6, dims.rows - 2))
|
||||
const lines: string[] = [`\x1b[${height}A\r`]
|
||||
if (rng() < 0.5) {
|
||||
lines.push('\x1b[0J')
|
||||
}
|
||||
for (let i = 0; i < height; i++) {
|
||||
lines.push(`\x1b[2K${sgr(rng)}│ ${textRun(rng)}\x1b[0m\r\n`)
|
||||
}
|
||||
return lines.join('')
|
||||
}
|
||||
|
||||
function statusLineRewrite(rng: () => number): string {
|
||||
return `\r\x1b[2K${sgr(rng)}${pick(rng, SPINNER)} ${textRun(rng)}\x1b[0m`
|
||||
}
|
||||
|
||||
function cursorMotion(rng: () => number, dims: AgentTuiStreamDims): string {
|
||||
const roll = rng()
|
||||
if (roll < 0.4) {
|
||||
return `\x1b[${int(rng, 1, dims.rows)};${int(rng, 1, dims.cols)}H`
|
||||
}
|
||||
if (roll < 0.55) {
|
||||
return `\x1b[${int(rng, 1, dims.cols)}G`
|
||||
}
|
||||
return `\x1b[${int(rng, 1, 4)}${pick(rng, ['A', 'B', 'C', 'D'] as const)}`
|
||||
}
|
||||
|
||||
function eraseOp(rng: () => number): string {
|
||||
return pick(rng, ['\x1b[K', '\x1b[1K', '\x1b[2K', '\x1b[0J', '\x1b[1J'] as const)
|
||||
}
|
||||
|
||||
function wrappedLongLine(rng: () => number, dims: AgentTuiStreamDims): string {
|
||||
const unit = `${textRun(rng)} `
|
||||
const repeats = Math.ceil((dims.cols * int(rng, 1, 3)) / Math.max(unit.length, 1)) + 1
|
||||
return `${sgr(rng)}${unit.repeat(repeats)}\x1b[0m\r\n`
|
||||
}
|
||||
|
||||
function scrollRegionBurst(rng: () => number, dims: AgentTuiStreamDims): string {
|
||||
const top = int(rng, 1, Math.max(1, dims.rows - 4))
|
||||
const bottom = int(rng, top + 1, dims.rows)
|
||||
const body: string[] = [`\x1b[${top};${bottom}r`, `\x1b[${bottom};1H`]
|
||||
for (let i = 0; i < int(rng, 1, 4); i++) {
|
||||
body.push(`${textRun(rng)}\r\n`)
|
||||
}
|
||||
body.push('\x1b[r')
|
||||
return body.join('')
|
||||
}
|
||||
|
||||
function altScreenFrame(rng: () => number, dims: AgentTuiStreamDims): string {
|
||||
const rows = int(rng, 2, Math.min(8, dims.rows))
|
||||
const body: string[] = ['\x1b[?1049h', '\x1b[2J\x1b[H', '\x1b[?25l']
|
||||
for (let i = 0; i < rows; i++) {
|
||||
body.push(`${sgr(rng)}│ ${textRun(rng)}\x1b[0m${i === rows - 1 ? '' : '\r\n'}`)
|
||||
}
|
||||
body.push(`\x1b[${int(rng, 1, dims.rows)};${int(rng, 1, dims.cols)}H\x1b[?25h`)
|
||||
if (rng() < 0.5) {
|
||||
body.push('\x1b[?1049l')
|
||||
}
|
||||
return body.join('')
|
||||
}
|
||||
|
||||
function synchronizedFrame(rng: () => number, dims: AgentTuiStreamDims): string {
|
||||
return `\x1b[?2026h${rng() < 0.5 ? panelRedraw(rng, dims) : statusLineRewrite(rng)}\x1b[?2026l`
|
||||
}
|
||||
|
||||
function savedCursorDetour(rng: () => number, dims: AgentTuiStreamDims): string {
|
||||
return `\x1b7${cursorMotion(rng, dims)}${sgr(rng)}${textRun(rng)}\x1b[0m\x1b8`
|
||||
}
|
||||
|
||||
function oscOp(rng: () => number, profile: AgentTuiStreamProfile): string {
|
||||
const roll = rng()
|
||||
if (roll < 0.5) {
|
||||
return `\x1b]0;${textRun(rng)}\x07`
|
||||
}
|
||||
if (roll < 0.8) {
|
||||
return profile.includeOscHyperlinks
|
||||
? `\x1b]8;;https://example.com/${int(rng, 1, 999)}\x07link\x1b]8;;\x07`
|
||||
: `https://example.com/pr/${int(rng, 1, 999)}\r\n`
|
||||
}
|
||||
return '\x1b]133;A\x07'
|
||||
}
|
||||
|
||||
function modeToggle(rng: () => number, profile: AgentTuiStreamProfile): string {
|
||||
const toggles = ['\x1b[?2004h', '\x1b[?2004l', '\x1b[?1h', '\x1b[?1l', '\x1b[?25l', '\x1b[?25h']
|
||||
if (profile.includeMouseModes) {
|
||||
toggles.push(
|
||||
'\x1b[?1000h',
|
||||
'\x1b[?1002h',
|
||||
'\x1b[?1003h',
|
||||
'\x1b[?1006h',
|
||||
'\x1b[?1000l',
|
||||
'\x1b[?1006l'
|
||||
)
|
||||
}
|
||||
return pick(rng, toggles)
|
||||
}
|
||||
|
||||
/** One seeded agent-TUI-shaped op. Weights favor the redraw ops that have
|
||||
* historically produced hidden-restore garble (CR rewrites, cursor-up panel
|
||||
* repaints, DEC 2026 frames, alt-screen churn). */
|
||||
function nextOp(
|
||||
rng: () => number,
|
||||
dims: AgentTuiStreamDims,
|
||||
profile: AgentTuiStreamProfile
|
||||
): string {
|
||||
const roll = rng()
|
||||
if (roll < 0.18) {
|
||||
return styledLine(rng)
|
||||
}
|
||||
if (roll < 0.32) {
|
||||
return statusLineRewrite(rng)
|
||||
}
|
||||
if (roll < 0.46) {
|
||||
return panelRedraw(rng, dims)
|
||||
}
|
||||
if (roll < 0.54) {
|
||||
return synchronizedFrame(rng, dims)
|
||||
}
|
||||
if (roll < 0.62) {
|
||||
return wrappedLongLine(rng, dims)
|
||||
}
|
||||
if (roll < 0.7) {
|
||||
return cursorMotion(rng, dims) + eraseOp(rng)
|
||||
}
|
||||
if (roll < 0.76) {
|
||||
return altScreenFrame(rng, dims)
|
||||
}
|
||||
if (roll < 0.82) {
|
||||
return scrollRegionBurst(rng, dims)
|
||||
}
|
||||
if (roll < 0.88) {
|
||||
return savedCursorDetour(rng, dims)
|
||||
}
|
||||
if (roll < 0.94) {
|
||||
return oscOp(rng, profile)
|
||||
}
|
||||
return modeToggle(rng, profile)
|
||||
}
|
||||
|
||||
export function buildAgentTuiStreamOps(
|
||||
rng: () => number,
|
||||
dims: AgentTuiStreamDims,
|
||||
profile: AgentTuiStreamProfile
|
||||
): string[] {
|
||||
const ops: string[] = []
|
||||
for (let i = 0; i < profile.opCount; i++) {
|
||||
ops.push(nextOp(rng, dims, profile))
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
/** Splits a stream at random boundaries, including inside escape sequences
|
||||
* (PTY chunking does that constantly). Never splits a surrogate pair: PTY
|
||||
* bytes are decoded to complete code points before reaching JS strings. */
|
||||
export function splitIntoRandomChunks(
|
||||
rng: () => number,
|
||||
stream: string,
|
||||
bounds: { minLen: number; maxLen: number }
|
||||
): string[] {
|
||||
const chunks: string[] = []
|
||||
let cursor = 0
|
||||
while (cursor < stream.length) {
|
||||
let end = Math.min(stream.length, cursor + int(rng, bounds.minLen, bounds.maxLen))
|
||||
const boundaryCode = stream.charCodeAt(end - 1)
|
||||
if (end < stream.length && boundaryCode >= 0xd800 && boundaryCode <= 0xdbff) {
|
||||
end += 1
|
||||
}
|
||||
chunks.push(stream.slice(cursor, end))
|
||||
cursor = end
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// Renderer-parity headless terminal + snapshot/replay mirrors for the garble
|
||||
// differential fuzz suites (headless-emulator-fidelity.fuzz.test.ts and
|
||||
// hidden-reveal-reconciliation.fuzz.test.ts). Lives in src/shared because the
|
||||
// main-side and renderer-side fuzz suites both consume it and neither
|
||||
// tsconfig (tsconfig.node.json / tsconfig.tc.web.json) includes the other
|
||||
// side's sources.
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import { SerializeAddon } from '@xterm/addon-serialize'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { activateOrcaTerminalUnicodeProvider } from './terminal-unicode-provider'
|
||||
import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy'
|
||||
|
||||
export type ParityTerminal = {
|
||||
terminal: Terminal
|
||||
serializeAddon: SerializeAddon
|
||||
}
|
||||
|
||||
/** Builds an @xterm/headless terminal configured exactly like the renderer
|
||||
* pane where buffer state is concerned: scrollback + kitty vtExtensions from
|
||||
* buildDefaultTerminalOptions (pane-terminal-options.ts), Unicode11Addon
|
||||
* (pane-dom-creation.ts) and the Orca ZWJ provider (pane-lifecycle.ts).
|
||||
* Font/cursor/render options are omitted — they never alter buffer cells. */
|
||||
export function createRendererParityTerminal(dims: { cols: number; rows: number }): ParityTerminal {
|
||||
const terminal = new Terminal({
|
||||
cols: dims.cols,
|
||||
rows: dims.rows,
|
||||
scrollback: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT,
|
||||
allowProposedApi: true,
|
||||
vtExtensions: { kittyKeyboard: true }
|
||||
})
|
||||
const serializeAddon = new SerializeAddon()
|
||||
terminal.loadAddon(serializeAddon)
|
||||
terminal.loadAddon(new Unicode11Addon())
|
||||
activateOrcaTerminalUnicodeProvider(terminal)
|
||||
return { terminal, serializeAddon }
|
||||
}
|
||||
|
||||
export function writeToTerminal(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise((resolve) => terminal.write(data, resolve))
|
||||
}
|
||||
|
||||
export async function writeChunksToTerminal(terminal: Terminal, chunks: string[]): Promise<void> {
|
||||
for (const chunk of chunks) {
|
||||
await writeToTerminal(terminal, chunk)
|
||||
}
|
||||
}
|
||||
|
||||
/** Bottom-anchored visible screen rows (baseY, not viewportY — scroll intent
|
||||
* is enforced separately by the production restore path). */
|
||||
export function visibleRows(terminal: Terminal): string[] {
|
||||
const buffer = terminal.buffer.active
|
||||
const rows: string[] = []
|
||||
for (let y = 0; y < terminal.rows; y++) {
|
||||
rows.push(buffer.getLine(buffer.baseY + y)?.translateToString(true) ?? '')
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// xterm attribute color modes (Attributes CM_* in xterm's buffer model).
|
||||
const COLOR_MODE_P16 = 16777216
|
||||
const COLOR_MODE_P256 = 33554432
|
||||
|
||||
/** Known-legitimate serializer normalization: SerializeAddon re-emits palette
|
||||
* indices 0-15 written via 38;5;N / 48;5;N as classic SGR 30-37/90-97, so a
|
||||
* restored cell reports CM_P16 where the live cell reported CM_P256. Both
|
||||
* modes resolve through the same 16 theme slots — no visual difference. */
|
||||
function canonicalColorMode(mode: number, color: number): number {
|
||||
return mode === COLOR_MODE_P256 && color >= 0 && color < 16 ? COLOR_MODE_P16 : mode
|
||||
}
|
||||
|
||||
/** Per-cell descriptor rows so SGR runs that shift cells are caught even
|
||||
* when the text matches. Encodes only VISUALLY EFFECTIVE state:
|
||||
* - glyph cells: char, width, fg, bg, all attribute flags;
|
||||
* - blank cells (null cells and spaces render identically): width, bg,
|
||||
* underline/strikethrough (drawn across blanks), and fg only when inverse
|
||||
* swaps it into the cell background. SerializeAddon legitimately skips
|
||||
* null cells with cursor motion, dropping their invisible fg/bold/italic
|
||||
* state, and may materialize a skipped run as plain spaces — neither can
|
||||
* be seen, so neither may fail the garble gate.
|
||||
* Trailing default blanks are trimmed: the serializer does not re-emit
|
||||
* pristine cells past the last written column. */
|
||||
export function visibleRowStyles(terminal: Terminal): string[] {
|
||||
const buffer = terminal.buffer.active
|
||||
const out: string[] = []
|
||||
for (let y = 0; y < terminal.rows; y++) {
|
||||
const line = buffer.getLine(buffer.baseY + y)
|
||||
const cells: string[] = []
|
||||
for (let x = 0; line && x < line.length; x++) {
|
||||
const cell = line.getCell(x)
|
||||
if (!cell) {
|
||||
continue
|
||||
}
|
||||
const chars = cell.getChars()
|
||||
const fgMode = canonicalColorMode(cell.getFgColorMode(), cell.getFgColor())
|
||||
const bgMode = canonicalColorMode(cell.getBgColorMode(), cell.getBgColor())
|
||||
if (chars === '' || chars === ' ') {
|
||||
const blankFlags = [cell.isUnderline(), cell.isStrikethrough()]
|
||||
.map((flag) => (flag ? '1' : '0'))
|
||||
.join('')
|
||||
const inverseFg = cell.isInverse() ? `·if${fgMode}:${cell.getFgColor()}` : ''
|
||||
cells.push(
|
||||
`▯·w${cell.getWidth()}·b${bgMode}:${cell.getBgColor()}·${blankFlags}${inverseFg}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
const flags = [
|
||||
cell.isBold(),
|
||||
cell.isDim(),
|
||||
cell.isItalic(),
|
||||
cell.isUnderline(),
|
||||
cell.isInverse(),
|
||||
cell.isStrikethrough()
|
||||
]
|
||||
.map((flag) => (flag ? '1' : '0'))
|
||||
.join('')
|
||||
cells.push(
|
||||
`${chars}·w${cell.getWidth()}·f${fgMode}:${cell.getFgColor()}·b${bgMode}:${cell.getBgColor()}·${flags}`
|
||||
)
|
||||
}
|
||||
const defaultBlank = `▯·w1·b0:-1·00`
|
||||
while (cells.length > 0 && cells.at(-1) === defaultBlank) {
|
||||
cells.pop()
|
||||
}
|
||||
out.push(cells.join('|'))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function cursorPosition(terminal: Terminal): { x: number; y: number } {
|
||||
return { x: terminal.buffer.active.cursorX, y: terminal.buffer.active.cursorY }
|
||||
}
|
||||
|
||||
/** KNOWN UPSTREAM BUG predicate (@xterm/addon-serialize 0.15.0-beta.287):
|
||||
* null cells touching a soft-wrap boundary do not round-trip. Two confirmed
|
||||
* variants (see the skipped repros in headless-emulator-fidelity.fuzz.test.ts):
|
||||
* - V1 (cell loss): a wrapped continuation row starting with a NULL cell
|
||||
* (only erasure creates those — typed spaces have chars ' ') passes the
|
||||
* addon's wrap-validity ternary (SerializeAddon.ts ~L214 binds as
|
||||
* `(chars && isDoubleWidth) ? ...`), so the blank is skipped with CUF —
|
||||
* which clamps at the right margin instead of crossing the wrap boundary,
|
||||
* overwriting the previous row's last cell and shifting the tail left.
|
||||
* - V2 (filler artifact): a wrapped pair whose SOURCE row is entirely null
|
||||
* takes the forced-wrap "magic" path, whose cleanup emits `ESC[0C`; CSI
|
||||
* param 0 means 1, so the erase lands one cell right and the first filler
|
||||
* '-' stays visible on the restored screen.
|
||||
* The fuzz suites use this predicate to tolerate (and count) exactly these
|
||||
* divergences without masking unknown ones. */
|
||||
export function bufferHasSerializeHostileWrappedRow(terminal: Terminal): boolean {
|
||||
const buffer = terminal.buffer.active
|
||||
for (let y = 1; y < buffer.length; y++) {
|
||||
const line = buffer.getLine(y)
|
||||
if (!line?.isWrapped) {
|
||||
continue
|
||||
}
|
||||
if (line.getCell(0)?.getChars() === '') {
|
||||
return true
|
||||
}
|
||||
const previous = buffer.getLine(y - 1)
|
||||
let previousIsAllNull = previous !== undefined
|
||||
for (let x = 0; previous && x < previous.length; x++) {
|
||||
if (previous.getCell(x)?.getChars() !== '') {
|
||||
previousIsAllNull = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (previousIsAllNull) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** KNOWN UPSTREAM BUG predicate — Bug B (@xterm/addon-serialize 0.15.0-beta.287):
|
||||
* serializing a dim cell followed by a bold-only cell emits `\x1b[1;22m`; SGR
|
||||
* 22 (normalIntensity) clears bold too, so the restored cell loses bold. The
|
||||
* fuzz suites tolerate + count exactly the case where the serialized snapshot
|
||||
* contains that self-cancelling transition. Minimal repro + mechanism in
|
||||
* headless-emulator-fidelity.fuzz.test.ts and notes/garble-fuzz-divergences.md. */
|
||||
const SELF_CANCELLING_BOLD_RESET_RE = new RegExp(
|
||||
// `1;22` in any SGR position: bold set then immediately reset in one CSI m.
|
||||
`${String.fromCharCode(27)}\\[(?:[0-9;]*;)?1;22(?:;[0-9;]*)?m`
|
||||
)
|
||||
export function snapshotHasSelfCancellingBoldReset(snapshotAnsi: string): boolean {
|
||||
return SELF_CANCELLING_BOLD_RESET_RE.test(snapshotAnsi)
|
||||
}
|
||||
|
||||
/** KNOWN UPSTREAM BUG predicate — Bug C (@xterm/addon-serialize 0.15.0-beta.287):
|
||||
* after a content row filled to exactly `cols`, xterm is wrap-pending and the
|
||||
* serializer's relative cursor restore lands one column short. Tolerated +
|
||||
* counted only when the live cursor is exactly one column right of the restored
|
||||
* one AND some content row fills the full width (the wrap-pending trigger). */
|
||||
export function isMarginWrapPendingCursorOffByOne(control: Terminal, restored: Terminal): boolean {
|
||||
const live = cursorPosition(control)
|
||||
const back = cursorPosition(restored)
|
||||
if (live.y !== back.y || live.x !== back.x + 1) {
|
||||
return false
|
||||
}
|
||||
const buffer = control.buffer.active
|
||||
for (let y = 0; y < buffer.length; y++) {
|
||||
const line = buffer.getLine(y)
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
const lastCol = control.cols - 1
|
||||
if ((line.getCell(lastCol)?.getChars() ?? '') !== '') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Full normal-buffer text with trailing blank rows trimmed (SerializeAddon
|
||||
* restores content rows; both sides may differ only in trailing blanks). */
|
||||
export function normalBufferRowsTrimmed(terminal: Terminal): string[] {
|
||||
const buffer = terminal.buffer.normal
|
||||
const rows: string[] = []
|
||||
for (let y = 0; y < buffer.length; y++) {
|
||||
rows.push(buffer.getLine(y)?.translateToString(true) ?? '')
|
||||
}
|
||||
while (rows.length > 0 && rows.at(-1) === '') {
|
||||
rows.pop()
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// Mirror of applyMainBufferSnapshot's clear preamble (pty-connection.ts):
|
||||
// normal-buffer restores wipe screen+scrollback+home; alt-screen restores
|
||||
// clear only the alt screen so the normal buffer's scrollback survives.
|
||||
export const SNAPSHOT_REPLAY_PREAMBLE_NORMAL = '\x1b[2J\x1b[3J\x1b[H'
|
||||
export const SNAPSHOT_REPLAY_PREAMBLE_ALT = '\x1b[?1049h\x1b[2J\x1b[H'
|
||||
|
||||
// Twin of POST_REPLAY_LIVE_SNAPSHOT_RESET (layout-serialization.ts) — the
|
||||
// renderer suite pins equality against the real constant so drift fails fast.
|
||||
export const POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY = '\x1b[0 q\x1b[?25h\x1b[?1004l'
|
||||
|
||||
export type ParityMainSnapshot = {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq: number
|
||||
alternateScreen: boolean
|
||||
pendingDeliveryStartSeq?: number
|
||||
}
|
||||
|
||||
/** Mirror of the production main-buffer snapshot the renderer restore path
|
||||
* consumes: HeadlessEmulator.getSnapshot (snapshotAnsi normalization +
|
||||
* rehydrateSequences) composed exactly like
|
||||
* OrcaRuntime.serializeHeadlessTerminalBuffer (alt forces scrollback 0,
|
||||
* data = rehydrateSequences + snapshotAnsi). The renderer fuzz cannot import
|
||||
* HeadlessEmulator itself — tsconfig.tc.web.json excludes src/main/daemon. */
|
||||
export function buildParityMainBufferSnapshot(
|
||||
parity: ParityTerminal,
|
||||
seq: number,
|
||||
opts: { pendingDeliveryStartSeq?: number; scrollbackRows?: number } = {}
|
||||
): ParityMainSnapshot {
|
||||
const { terminal } = parity
|
||||
const alternateScreen = terminal.buffer.active.type === 'alternate'
|
||||
// Why scrollback 0 on alt: serializeHeadlessTerminalBuffer forces it so
|
||||
// normal-buffer scrollback cannot replay underneath an alt-screen TUI frame.
|
||||
const scrollback = alternateScreen
|
||||
? 0
|
||||
: (opts.scrollbackRows ?? DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT)
|
||||
let snapshotAnsi = parity.serializeAddon.serialize({ scrollback })
|
||||
if (alternateScreen) {
|
||||
// Why: HeadlessEmulator.normalizeSnapshotAnsiForModes drops the
|
||||
// serializer's duplicate ?1049h; rehydrateSequences re-enters alt.
|
||||
const marker = '\x1b[?1049h'
|
||||
const start = snapshotAnsi.lastIndexOf(marker)
|
||||
if (start !== -1) {
|
||||
snapshotAnsi = snapshotAnsi.slice(start + marker.length)
|
||||
}
|
||||
}
|
||||
const seqs: string[] = []
|
||||
if (alternateScreen) {
|
||||
seqs.push('\x1b[?1049h')
|
||||
}
|
||||
if (terminal.modes.bracketedPasteMode) {
|
||||
seqs.push('\x1b[?2004h')
|
||||
}
|
||||
// Why normal-buffer-only: HeadlessEmulator.getModes reports
|
||||
// applicationCursor false while the alternate buffer is active, so the
|
||||
// production rehydrate omits ?1h for alt-screen snapshots.
|
||||
if (!alternateScreen && terminal.modes.applicationCursorKeysMode) {
|
||||
seqs.push('\x1b[?1h')
|
||||
}
|
||||
// Mouse-mode rehydrate omitted: TerminalMouseModeMirror is main-only and
|
||||
// mouse reporting is input encoding — it cannot alter rendered output.
|
||||
const snapshot: ParityMainSnapshot = {
|
||||
data: seqs.join('') + snapshotAnsi,
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
seq,
|
||||
alternateScreen
|
||||
}
|
||||
if (opts.pendingDeliveryStartSeq !== undefined) {
|
||||
snapshot.pendingDeliveryStartSeq = opts.pendingDeliveryStartSeq
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
@@ -73,6 +73,53 @@ function writeParkedFrameScript(scriptPath: string, runId: string): void {
|
||||
)
|
||||
}
|
||||
|
||||
// Deterministic static alt-screen frame for the park/reveal cycle test: rich
|
||||
// styling (box drawing, SGR colors, wide glyphs) that exercises the snapshot
|
||||
// restore, painted once and held so the on-screen content is stable across
|
||||
// cycles. No spinner/progress churn — the frame must be byte-identical every
|
||||
// reveal so drift is detectable.
|
||||
function cycleReferenceFrame(runId: string): string {
|
||||
const rows = [
|
||||
'╭──────────────────────────────────────────────────────────╮',
|
||||
`│ Park/reveal cycle reference ${runId.slice(0, 8)} 🟢 你好世界 터미널 │`,
|
||||
'├───────────────┬──────────────────────────────────────────┤',
|
||||
`│ model │ \x1b[1mcodex/opencode\x1b[22m stream +142 -37 │`,
|
||||
`│ status │ \x1b[38;5;204mrunning\x1b[0m\x1b[2;36m diff --git a/pty.ts esc↩ │`,
|
||||
'╰───────────────┴──────────────────────────────────────────╯',
|
||||
`CYCLE_REFERENCE_${runId}`
|
||||
]
|
||||
return [
|
||||
'\x1b[?1049h',
|
||||
'\x1b[2J\x1b[H',
|
||||
'\x1b[?25l',
|
||||
rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'),
|
||||
'\x1b[?25h'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function writeCycleReferenceScript(scriptPath: string, runId: string): void {
|
||||
mkdirSync(path.dirname(scriptPath), { recursive: true })
|
||||
// Paint the frame once, then hold the process open so the alt-screen TUI
|
||||
// stays on screen (and the parkable PTY session stays alive) across cycles.
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
`process.stdout.write(${JSON.stringify(cycleReferenceFrame(runId))}); setInterval(() => {}, 1000)\n`
|
||||
)
|
||||
}
|
||||
|
||||
// Why: serialize() re-emits the buffer with cursor-restore trailer sequences
|
||||
// (ESC[…H, ESC[?25h) and the exact CSI form can differ run-to-run without any
|
||||
// visible change. Compare the CONTENT rows, not the trailer — strip trailing
|
||||
// control sequences and normalize whitespace-only tail lines.
|
||||
function terminalContentRows(serialized: string): string[] {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const stripped = serialized.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
||||
return stripped
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.replace(/\s+$/, ''))
|
||||
.filter((line) => line.length > 0)
|
||||
}
|
||||
|
||||
async function readParkingWiring(
|
||||
page: Page
|
||||
): Promise<{ present: boolean; parkDelayMs: number | null }> {
|
||||
@@ -406,4 +453,100 @@ test.describe('Terminal hidden view parking', () => {
|
||||
expect(tabCState.hasManager).toBe(true)
|
||||
expect(tabCState.paneCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// Drives 25 deterministic park→reveal cycles on a static rich TUI frame and
|
||||
// asserts every reveal reproduces the SAME content the tab showed while it was
|
||||
// continuously visible (the never-parked reference). This is the field-garble
|
||||
// guard end-to-end: it exercises the real renderer teardown + HeadlessEmulator
|
||||
// snapshot restore + PTY reattach path the fuzz suites model in isolation, and
|
||||
// fails if any single cycle — or accumulated drift across 25 — garbles a cell.
|
||||
test('reproduces a static frame byte-for-byte across 25 park/reveal cycles', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo: TestInfo) => {
|
||||
test.setTimeout(180_000)
|
||||
await waitForSessionReady(orcaPage)
|
||||
const setup = await setUpParkableTabA(orcaPage)
|
||||
const { worktreeId, tabAId, tabAPtyId } = setup
|
||||
|
||||
const runId = randomUUID()
|
||||
const marker = `CYCLE_REFERENCE_${runId}`
|
||||
const scriptPath = path.join(testRepoPath, `.orca-cycle-reference-${runId}.mjs`)
|
||||
writeCycleReferenceScript(scriptPath, runId)
|
||||
try {
|
||||
await sendToTerminal(orcaPage, tabAPtyId, `node ${JSON.stringify(scriptPath)}\r`)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 12_000), {
|
||||
timeout: 15_000,
|
||||
message: 'cycle reference frame did not render while tab A was visible'
|
||||
})
|
||||
.toContain(marker)
|
||||
|
||||
// Tab B stays visible whenever tab A is parked; toggling the active tab
|
||||
// between them is the deterministic hide/reveal driver.
|
||||
const tabBId = await createActiveTerminalTab(orcaPage, worktreeId)
|
||||
|
||||
// One park/reveal cycle to run the frame through the snapshot restore for a
|
||||
// baseline. Why not compare against the visible-before-park content: an
|
||||
// alt-screen restore deliberately drops the normal-buffer scrollback
|
||||
// (serializeHeadlessTerminalBuffer forces scrollback 0 under alt), so the
|
||||
// pre-park serialize carries the shell command echo the restore correctly
|
||||
// omits — that is contract, not garble. Baselining after one reveal makes
|
||||
// both sides pass through identical machinery, so any later diff is drift.
|
||||
const runOneParkRevealCycle = async (cycle: number): Promise<string[]> => {
|
||||
await activateTerminalTab(orcaPage, tabBId)
|
||||
await waitForTabParked(orcaPage, tabAId)
|
||||
await activateTerminalTab(orcaPage, tabAId)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const revealed = await waitForPaneIdentitySnapshot(orcaPage, 1)
|
||||
expect(revealed.panes[0]?.ptyId).toBe(tabAPtyId)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 12_000), {
|
||||
timeout: 15_000,
|
||||
message: `cycle ${cycle}: reference frame did not restore on reveal`
|
||||
})
|
||||
.toContain(marker)
|
||||
const rows = terminalContentRows(await getTerminalContent(orcaPage, 12_000))
|
||||
// Garble sentinel: the hidden-skip banner must never appear.
|
||||
expect(rows.join('\n')).not.toContain('Orca skipped hidden terminal output')
|
||||
return rows
|
||||
}
|
||||
|
||||
const referenceRows = await runOneParkRevealCycle(0)
|
||||
expect(referenceRows.join('\n')).toContain(marker)
|
||||
expect(referenceRows.join('\n')).toContain('╭')
|
||||
|
||||
// waitForTabParked (inside runOneParkRevealCycle) throws if the tab never
|
||||
// parked, so reaching here means the machinery ran every cycle — no
|
||||
// separate premise guard needed for a vacuous-green check.
|
||||
const CYCLES = 25
|
||||
const mismatches: string[] = []
|
||||
for (let cycle = 1; cycle < CYCLES; cycle++) {
|
||||
const rows = await runOneParkRevealCycle(cycle)
|
||||
if (JSON.stringify(rows) !== JSON.stringify(referenceRows)) {
|
||||
mismatches.push(
|
||||
`cycle ${cycle}:\n expected: ${JSON.stringify(referenceRows)}\n actual: ${JSON.stringify(rows)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
testInfo.annotations.push({
|
||||
type: 'terminal-parking-cycles',
|
||||
description: `cycles=${CYCLES} mismatches=${mismatches.length}`
|
||||
})
|
||||
expect(
|
||||
mismatches,
|
||||
`park/reveal drift across ${CYCLES} cycles:\n${mismatches.join('\n')}`
|
||||
).toEqual([])
|
||||
|
||||
const screenshotPath = testInfo.outputPath('park-reveal-25-cycles-final.png')
|
||||
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
|
||||
await testInfo.attach('park-reveal-25-cycles-final.png', {
|
||||
path: screenshotPath,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
} finally {
|
||||
rmSync(scriptPath, { force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user