test(vterm): match host output past the differ's cell-skipping

The macOS `VTERM_ALT_READY` flake finally reported itself through #151's
breadcrumbs, identically in two runs: pmacs still running, `init.lua`
reached, `terminal.open` ok, and a settled screen whose tail is nothing but
`\x1b[22;42H` repeated 133 times.

That cursor is the evidence. A blank terminal parks at 1;1. Column 42 is
where the cursor lands after writing a 15-byte marker that ends at column
41 — so the child DID write and the emulator DID receive it. What failed
was the assertion: `wait_for_output` required the needle to appear as
contiguous bytes, but the TUI differ paints only changed cells and skips
ones that already match, so a run held contiguously on one screen row can
still reach the host as `PREF<cursor-move>IX`.

Match over escape-stripped bytes when the needle is plain text. This cannot
mask the failure that matters: text the child never wrote is absent from
the stripped stream too, so a genuinely silent child still fails. Needles
carrying their own escape (the OSC 52 clipboard reply) keep the strict path,
since stripping would consume the bytes under test.

The failure arm now also reports how much of the needle rendered, so the
next occurrence distinguishes "nothing reached the host" — a PTY/spawn
fault — from a partial render, instead of leaving a tail of pure escapes
that cannot tell them apart.

Both helpers are pinned directly, including that stripping rejoins a split
run without inventing absent text. `strip_ansi`'s first draft mishandled
`ESC ( B`, whose intermediate byte makes it three bytes rather than two;
its test caught that.

Test-only; no runtime code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-24 13:49:14 -04:00
parent ddaa80d4a8
commit 8ef2fa0793
1 changed files with 160 additions and 8 deletions

View File

@ -573,6 +573,74 @@ fn describe_startup(pty: &mut PmacsPty, startup: &[(&str, &Path)]) -> String {
out
}
/// Host bytes with ANSI escape sequences removed.
///
/// The TUI differ paints only cells that CHANGED and skips ones already
/// matching, so a run the emulator holds contiguously on one screen row can
/// still reach the host as `PREF<cursor-move>IX`. Assertions about child
/// output therefore fall back to matching over this stripped stream.
///
/// This cannot mask the failure that matters: text the child never wrote is
/// absent from the stripped bytes too. It only removes the false negative
/// where the differ split a run that did render.
fn strip_ansi(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] != 0x1b {
out.push(bytes[i]);
i += 1;
continue;
}
match bytes.get(i + 1) {
// CSI: parameters/intermediates, then a final byte in 0x40..=0x7e.
Some(b'[') => {
i += 2;
while i < bytes.len() && !(0x40..=0x7e).contains(&bytes[i]) {
i += 1;
}
i += usize::from(i < bytes.len());
}
// OSC: terminated by BEL or ST (`ESC \`).
Some(b']') => {
i += 2;
while i < bytes.len() && bytes[i] != 0x07 {
if bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'\\') {
i += 1;
break;
}
i += 1;
}
i += usize::from(i < bytes.len());
}
// nF form: intermediates in 0x20..=0x2f then one final byte
// (`ESC ( B` designates ASCII into G0 and is three bytes, not two).
Some(0x20..=0x2f) => {
i += 1;
while i < bytes.len() && (0x20..=0x2f).contains(&bytes[i]) {
i += 1;
}
i += usize::from(i < bytes.len());
}
// Single-byte final (`ESC 7`, `ESC M`, …).
Some(_) => i += 2,
None => i += 1,
}
}
out
}
/// Longest prefix of `needle` that appears anywhere in `haystack`.
///
/// Diagnostic only (see the failure arm of [`wait_for_output`]): it tells a
/// failed match whether the child's bytes reached the host at all.
fn longest_rendered_prefix(haystack: &[u8], needle: &[u8]) -> usize {
(1..=needle.len())
.rev()
.find(|&n| haystack.windows(n).any(|window| window == &needle[..n]))
.unwrap_or(0)
}
fn wait_for_output(
pty: &mut PmacsPty,
needle: &[u8],
@ -580,11 +648,17 @@ fn wait_for_output(
startup: &[(&str, &Path)],
) {
let deadline = Instant::now() + timeout;
// A needle carrying its own escape (e.g. the OSC 52 clipboard reply) is
// matched strictly; stripping would consume the very bytes under test.
let needle_is_plain_text = !needle.contains(&0x1b);
loop {
if pty
.output()
.windows(needle.len())
.any(|window| window == needle)
let output = pty.output();
let contiguous = output.windows(needle.len()).any(|window| window == needle);
if contiguous
|| (needle_is_plain_text && {
let visible = strip_ansi(&output);
visible.windows(needle.len()).any(|window| window == needle)
})
{
return;
}
@ -592,11 +666,29 @@ fn wait_for_output(
let diagnosis = describe_startup(pty, startup);
let output = pty.output();
let start = output.len().saturating_sub(4_000);
// Both the contiguous and the escape-stripped match failed, so
// report how much of the needle rendered at all. The printed tail
// cannot answer that on its own: a settled screen emits empty
// diffs forever and pushes any real text out of the window. `0`
// means no child text ever reached the host — the serious case,
// pointing at the PTY/spawn path rather than at painting.
let visible = strip_ansi(&output);
let seen = longest_rendered_prefix(&visible, needle)
.max(longest_rendered_prefix(&output, needle));
let verdict = if seen == 0 {
"no child text reached the host"
} else {
"child text rendered only partially"
};
panic!(
"host output never contained {:?} after {timeout:?}\n \
startup: {diagnosis}\n tail: {}",
String::from_utf8_lossy(needle),
output[start..].escape_ascii()
"host output never contained {needle:?} after {timeout:?}\n \
startup: {diagnosis}\n \
rendered prefix: {seen}/{len} bytes ({prefix:?}) {verdict}\n \
tail: {tail}",
needle = String::from_utf8_lossy(needle),
len = needle.len(),
prefix = String::from_utf8_lossy(&needle[..seen]),
tail = output[start..].escape_ascii()
);
}
thread::sleep(Duration::from_millis(20));
@ -761,3 +853,63 @@ fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_a
"pmacs must disable host bracketed paste on exit"
);
}
/// `strip_ansi` joins a run the differ split, WITHOUT inventing text.
///
/// This is the property that makes the loose match in [`wait_for_output`]
/// safe: it rescues a marker that rendered across a cursor move, and still
/// reports absent for a child that never wrote.
#[test]
fn strip_ansi_rejoins_a_split_run_but_never_invents_absent_text() {
let needle = b"VTERM_ALT_READY";
// Split by a cursor move mid-run: stripping rejoins it.
let split = b"\x1b[9;30HVTERM_ALT_\x1b[10;1HREADY".to_vec();
let visible = strip_ansi(&split);
assert!(visible.windows(needle.len()).any(|w| w == needle));
// A silent child stays silent: no amount of stripping conjures the text.
let silent = b"\x1b[?2026h\x1b[22;42H\x1b[?25h\x1b[?2026l".repeat(4);
let visible = strip_ansi(&silent);
assert!(
!visible.windows(needle.len()).any(|w| w == needle),
"stripping must not manufacture text the child never wrote"
);
assert!(
visible.is_empty(),
"pure escapes strip to nothing: {visible:?}"
);
// OSC (clipboard) and two-byte escapes are consumed, payload text kept.
assert_eq!(strip_ansi(b"a\x1b]52;c;Zm9v\x07b"), b"ab");
assert_eq!(strip_ansi(b"x\x1b(By"), b"xy");
}
/// The flake diagnostic's discriminator (see [`wait_for_output`]).
///
/// The macOS `VTERM_ALT_READY` failure reports a settled screen whose tail is
/// pure cursor/sync escapes, which alone cannot say whether the child ever
/// wrote. These two cases are exactly what the failure arm must tell apart.
#[test]
fn longest_rendered_prefix_separates_absent_child_text_from_a_split_render() {
let needle = b"VTERM_ALT_READY";
// Nothing from the child: a settled screen of cursor moves only.
let silent = b"\x1b[?2026h\x1b[22;42H\x1b[?25h\x1b[?2026l".repeat(4);
assert_eq!(longest_rendered_prefix(&silent, needle), 0);
// Rendered, but the emulator held it across two screen rows, so the host
// stream carries a cursor move mid-marker and the contiguous match fails.
let mut split = Vec::new();
split.extend_from_slice(b"\x1b[9;30HVTERM_ALT_");
split.extend_from_slice(b"\x1b[10;1HREADY");
let seen = longest_rendered_prefix(&split, needle);
assert_eq!(seen, 10, "must report the rendered prefix, not zero");
assert_eq!(&needle[..seen], b"VTERM_ALT_");
// Fully contiguous is the passing case and never reaches the failure arm.
assert_eq!(
longest_rendered_prefix(b"\x1b[9;1HVTERM_ALT_READY", needle),
needle.len()
);
}