* fix(cli): restore bounded OpenTUI dialogs and add migration gates * fix(cli): preserve OpenTUI input and attachment ownership * feat(cli): align OpenTUI status lines with Ink * fix(cli): align OpenTUI editor and cancellation transactions * fix(tui): harden migration cleanup and evidence after review * test(tui): fail acceptance on unhandled errors * docs(cli): clarify consolidated OpenTUI delivery status * test(cli): isolate system settings in TUI parity fixtures * fix(cli): settle OpenTUI cancellation when replay resets a session * style(cli): format merged OpenTUI test callbacks * test(cli): preserve visible input across session rotation * test(cli): await cancellation before releasing held response * fix(cli): preserve completion and escape precedence and validate status colors * fix(cli): preserve attachment navigation with one completion * fix(cli): display session titles in the OpenTUI composer border * fix(cli): discard generated command work after cancellation |
||
|---|---|---|
| .. | ||
| fixtures | ||
| lib | ||
| test | ||
| accept-noflicker.sh | ||
| component-parity.sh | ||
| README.md | ||
| runner.mjs | ||
| self-test.mjs | ||
TUI parity harness
Deterministic, dependency-light harness that compares a base CLI capture
(current Ink behavior) against a fixed CLI capture (OpenTUI behavior) with
machine-checkable metrics and reviewable artifacts. Node built-ins plus the
repository's existing @lydell/node-pty capability for PTY allocation.
Runner contract
node scripts/tui-parity/runner.mjs \
--scenario <file|dir> ... \
--out <dir> \
[--base '<command>'] [--fixed '<command>']
--scenariois a*.scenario.jsonfile or a directory of them.--outis the artifact directory. Exit codes: 0 pass, 1 threshold/capture failure, 2 usage or validation error.--base/--fixedoverride the scenario commands (single scenario only). This is the real-CLI contract: point base at the Ink build and fixed at the OpenTUI build with the same scenario parameters. Overrides always capture through a native PTY. Relative.js/.mjs/.cjsscript paths afternoderesolve against the repository root. The final resolved argv (after any override) is re-validated before anything is captured: declaredcompareParamsmust be present and equal on both sides, and terminal-size flags must match the capture geometry. A divergent override aborts the run with a parameter-binding error instead of executing under a stale "validated" claim.- Base and fixed always share the scenario's terminal size, timeout, stdin
input, environment, and thresholds. Scenarios that attach per-side
parameters are rejected.
compareParamsnames argv flags whose values are extracted, normalised (--flag valueand--flag=valueare equivalent), and required to be present and equal on both sides; terminal-size flags (--rows/--lines/--columns/--cols) must additionally matchterminal.rows/terminal.columns. Any mismatch fails validation before anything runs.
Native TTY capture is enforced
Plain argv commands are captured through a native PTY allocated by the
harness (@lydell/node-pty, rows/columns and TERM from the scenario). The
child therefore runs with a real TTY, and each side's summary.json records
the evidence (capture.tty: mode, backend, allocated, rows, columns,
handshake). If no PTY backend is available, direct capture is refused
with native TTY capture refused: ...; the harness never silently falls back
to non-TTY pipes for a real CLI. Two explicit waivers exist, both recorded in
artifacts and reports:
"pty": "fixture"— the command is a deterministic fixture that needs no TTY (the harness's own emitters). Captured over pipes and clearly marked as waived; real-CLI captures may not use this mode."pty": "wrapped"— the command already runs inside a caller-supplied PTY wrapper (expect, tmux,script, orlib/tty-handshake.mjs). The harness generates a per-run nonce inTUI_PARITY_PTY_NONCEand verifies that the captured stream contains the matchingESC ] 697 ; tty-handshake ; <nonce> BELmarker.lib/tty-handshake.mjsemits that marker only when its stdout really is a TTY, and refuses otherwise. A missing handshake fails the side.
TUI_PARITY_NO_PTY=1 forces the no-backend path in tests.
Scenario schema
{
"id": "kebab-case-id",
"description": "what is being compared",
"terminal": { "rows": 12, "columns": 40 },
"timeoutMs": 10000,
"input": [{ "data": "\r", "delayMs": 0 }],
"env": { "KEY": "value" },
"compareParams": ["--frames", "--rows"],
"commands": {
"base": ["argv", "captured through a native PTY"],
"fixed": { "argv": ["argv..."], "pty": "fixture" }
},
"thresholds": { "maxFullScreenClears": 0 },
"expectBaseFailure": false,
"proves": "what a passing run proves",
"doesNotProve": "what it cannot prove"
}
commands.<side> is either a plain argv array (native PTY capture) or an
object with argv and optional pty ("fixture" or "wrapped"). Any other
per-side key is rejected.
expectBaseFailure (optional boolean, default false) declares that the base
side is a defect fixture rather than a clean reference. Such a scenario passes
only when the base side fails: a both-pass there means the fixture emitted no
defect, so the comparison would report success while proving nothing.
Thresholds (all optional, evaluated per side): maxFullScreenClears,
maxPartialScreenErases, maxLineErases, maxDuplicateEvents,
maxDec2026Unbalanced (integers >= 0), and requireSync,
requireEventMarkers, requireExitCodeZero (booleans; the exit-code
requirement defaults to true; spawn failures, timeouts, PTY refusals, and
unverified handshakes always fail a side).
Metrics
Counted from each side's raw stdout:
- Full-screen clear opcodes:
ESC[2J,ESC[3J, RIS (ESC c). - Line erases:
ESC[Kin modes 0/1/2; partial screen erasesESC[Jmodes 0/1 tracked separately. - DEC 2026 synchronised-output begin/end (
ESC[?2026h/ESC[?2026l) plus an unbalanced count for stray ends and unclosed begins.requireSyncaccepts a stream only if at least one begin exists, pairs balance, and — when live-output event markers are measured — every marker occurrence was emitted inside an active DEC 2026 interval. Coverage is measured per event, not inferred from counts: empty begin/end pairs that wrap no events and markers emitted outside every interval failrequireSynceven when the begin count equals the unique event count. - Live-output event markers using OSC
697;<id>;<seq>(BEL or ST terminated). A marker whose(id, seq)was already seen is a duplicate. Each marker occurrence is additionally classified as covered (inside an active DEC 2026 interval) or unwrapped (outside every interval), and both counts are reported. Without markers the duplicate count is reported as not measurable. - Process exit code, signal, timeout, and duration.
Timeout handling tracks and clears every pending input timer, and kills the capture process tree where supported (SIGTERM to the process group, SIGKILL after a 1s grace). The kill escalation survives the main child closing: the SIGKILL step is not cancelled when the captured command exits, and after it fires the harness re-probes the process group until it is confirmed gone (bounded). A descendant that ignores SIGTERM and detaches stdio therefore cannot outlive the capture, and a capture cannot linger on delayed input or orphaned descendants.
proves and doesNotProve are mandatory scenario fields and are copied
verbatim into every report, so each scenario states what its result does and
does not demonstrate.
Artifacts
Per run under --out:
run-summary.json
<scenario-id>/
base|fixed/
raw.ansi exact captured stdout
stderr.txt captured stderr (empty for PTY captures: a real
terminal merges stderr into the PTY stream)
screen.txt final screen from the built-in terminal model
summary.json capture metadata (incl. tty evidence), metrics,
verdict, reasons
comparison.json side-by-side metrics, deltas, outcome
report.md reviewer-facing report
Outcomes: base-fails-fixed-passes (evidence that the fix removes the
defect), both-pass (no base-side defect exhibited), fixed-fails
(threshold violation), capture-error (spawn failure, refusal, or timeout).
The run passes only on the first two — and for a scenario with
expectBaseFailure, only on base-fails-fixed-passes.
Fixtures and tests
fixtures/emitters/tui-emitter.mjsis a deterministic emitter: identical flags produce identical bytes, and its flags inject the exact defects the metrics count (--clears-per-frame,--scrollback-clears,--dups,--sync,--hang-ms,--exit-code).--tree-hangspawns an idle grandchild and hangs, to exercise process-tree kill on timeout.--stubborn-hangspawns a grandchild that ignores SIGTERM and detaches its stdio, then hangs; it exercises kill escalation that must survive the main child closing.fixtures/wrappers/pty-launcher.mjswraps a command in a PTY, standing in for external wrappers in handshake tests.fixtures/scenarios/stream-redraw.scenario.jsonreproduces a failing base and passing fixed side without launching the real CLI.
node --test scripts/tui-parity/test # unit + end-to-end tests
node scripts/tui-parity/self-test.mjs # full pipeline self-check
Tests cover the PTY refusal and the native/wrapped happy paths, divergent
compareParams (including --rows/--columns), per-side parameter
rejection, and a bounded regression test where input.delayMs exceeds
timeoutMs. They also lock in the three adversarial counterexamples as
regressions: empty DEC 2026 pairs plus unwrapped event markers can never
satisfy requireSync; a descendant that ignores SIGTERM and detaches stdio
is still reaped after the main child closes (fixture and native PTY); and a
--base/--fixed override that diverges from compareParams or the capture
geometry aborts before anything runs. Fixture results are evidence about the
harness and its metrics, not about the products. Real-CLI parity claims
require running the same harness with real base/fixed commands through the
native PTY contract.
Normalization limits
The built-in terminal model supports printable text, CR/LF/BS/TAB, cursor
movement, ED/EL/IL/DL/ICH/DCH/ECH, SGR (ignored), alternate screen
(47/1047/1049), and RIS. Scroll regions, double-width characters, and
wide-grapheme measurement are not modeled; screen.txt is a review aid, and
the machine-checkable part of the harness is the metric counts. PTY captures
apply the terminal line discipline (e.g. LF echoed as CRLF); metrics count
opcodes, not whitespace, so this does not change threshold semantics.