diff --git a/tests/vterm_stage2_acceptance.rs b/tests/vterm_stage2_acceptance.rs index 9208785..ff1737a 100644 --- a/tests/vterm_stage2_acceptance.rs +++ b/tests/vterm_stage2_acceptance.rs @@ -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 `PREFIX`. 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 { + 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() + ); +}