diff --git a/tests/vterm_stage2_acceptance.rs b/tests/vterm_stage2_acceptance.rs index 9208785..852ed1f 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,29 +648,78 @@ fn wait_for_output( startup: &[(&str, &Path)], ) { let deadline = Instant::now() + timeout; + // Matching is STRICT, deliberately. An earlier revision also tried an + // escape-stripped match to tolerate a run the differ had split; that is + // unsound for painted CELLS, because `cell::diff` drops an already-matching + // cell entirely rather than merely interrupting the run, and no match + // strategy recovers a byte that was never sent. Assertions here are + // therefore limited to protocol escapes pmacs writes straight to the host + // (clipboard, mode resets), which the differ never touches. Content that + // must be seen on SCREEN is asserted in-process over `snapshot_text`. loop { - if pty - .output() - .windows(needle.len()) - .any(|window| window == needle) - { + let output = pty.output(); + if output.windows(needle.len()).any(|window| window == needle) { return; } if Instant::now() >= deadline { let diagnosis = describe_startup(pty, startup); let output = pty.output(); let start = output.len().saturating_sub(4_000); + // Report how much of the needle reached the host 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. A prefix strictly between 0 and the full length means + // the bytes arrived mutilated rather than never — which for cell + // content is the differ dropping an already-matching cell. + 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)); } } +/// [`wait_for_file`] that reports startup breadcrumbs when it times out. +/// +/// The plain helper panics with only the missing path, which is the least +/// useful thing to know: a readiness file that never appears is exactly when +/// "how far did startup get" decides where to look next. +fn wait_for_published_file( + pty: &mut PmacsPty, + path: &Path, + timeout: Duration, + startup: &[(&str, &Path)], +) -> Vec { + let deadline = Instant::now() + timeout; + loop { + if let Ok(bytes) = fs::read(path) { + return bytes; + } + assert!( + Instant::now() < deadline, + "child never published {} within {timeout:?}\n startup: {}", + path.display(), + describe_startup(pty, startup) + ); + thread::sleep(Duration::from_millis(20)); + } +} + fn wait_for_file(path: &Path, timeout: Duration) -> Vec { let deadline = Instant::now() + timeout; loop { @@ -625,6 +742,8 @@ fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_a let config_root = temp.path().join("config"); let config_dir = config_root.join("pmacs"); let state_root = temp.path().join("state"); + // Readiness is published as a FILE, not as host bytes. See the wait below. + let alt_ready_path = temp.path().join("alt-ready"); let input_path = temp.path().join("child-input"); let size_path = temp.path().join("child-size"); let init_path = temp.path().join("init-reached"); @@ -643,18 +762,30 @@ fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_a " data = b''\n", " while marker not in data:\n", " data += os.read(0, 4096)\n", + // CRLF, not bare LF. The supervisor's PTY trampoline runs + // `stty raw`, which clears OPOST, so a lone `\n` moves DOWN + // without returning to column 1 and every line staircases five + // columns right. Against this session's 40-column child that + // walks the readiness marker into the right margin, where it + // wraps mid-word and reaches the host as two pieces separated by + // other repainted cells — unmatchable, and intermittent because + // the column depends on whether pmacs has resized the PTY to the + // window width yet. Explicit carriage returns keep every write + // column-stable, so the markers below start at column 1. "os.write(1, b'\\x1b[?1049h\\x1b[2J')\n", - "for i in range(20): os.write(1, b'alt%02d\\n' % i)\n", + "for i in range(20): os.write(1, b'alt%02d\\r\\n' % i)\n", "os.write(1, b'VTERM_ALT_READY')\n", + "open({:?}, 'wb').write(b'1')\n", "read_until(b'ALT_GATE\\n')\n", "os.write(1, b'\\x1b[?1049l')\n", - "for i in range(40): os.write(1, b'main%02d\\n' % i)\n", + "for i in range(40): os.write(1, b'main%02d\\r\\n' % i)\n", "os.write(1, b'VTERM_MAIN_READY\\x07')\n", "data = read_exact(18)\n", "open({:?}, 'wb').write(data)\n", "size = os.get_terminal_size(0)\n", "open({:?}, 'w').write(f'{{size.lines}} {{size.columns}}\\n')\n" ), + alt_ready_path.to_str().expect("UTF-8 alt-ready path"), input_path.to_str().expect("UTF-8 input path"), size_path.to_str().expect("UTF-8 size path") ); @@ -710,11 +841,27 @@ fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_a ("init.lua reached", init_path.as_path()), ("terminal.open", open_path.as_path()), ]; - wait_for_output( - &mut pty, - b"VTERM_ALT_READY", - Duration::from_secs(10), - startup, + // Gate on a file the child publishes, not on its text appearing in the + // host stream. Host bytes cannot carry this assertion: `cell::diff` splits + // a run at any cell where `prev == next` and NEVER TRANSMITS that cell + // (pinned by `cell::tests::diff_split_by_unchanged_cell_is_two_spans`), so + // whenever a character of the marker already happens to sit at its + // destination the host receives the marker with that byte missing. No + // matching strategy can recover a byte that was never sent — which is what + // the macOS failures were, reporting a stable `rendered prefix: 6/15` + // across two different child layouts. + // + // This is a synchronisation gate, not the assertion. That the child's + // output reaches the SCREEN is pinned in-process, at the layer that can + // see it, by `lua_surface_is_strict_...` asserting over `snapshot_text`. + // What this test uniquely owns is host lifecycle — the clipboard escape, + // geometry propagation, and terminal restore asserted below — and those + // are protocol escapes pmacs writes directly, never painted cells, so the + // differ cannot split them. + assert_eq!( + wait_for_published_file(&mut pty, &alt_ready_path, Duration::from_secs(10), startup), + b"1", + "alt-screen readiness breadcrumb was published but malformed" ); pty.resize(30, 90).expect("resize host PTY"); @@ -761,3 +908,81 @@ 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 interrupted by escapes, WITHOUT inventing text. +/// +/// It backs the failure diagnostic in [`wait_for_output`], not the match: it +/// separates "arrived, interrupted by cursor moves" from "never arrived". +/// It deliberately does NOT rescue a run the cell differ split, because that +/// path drops the matching cell rather than escaping around it — the case +/// pinned below and by `cell::tests::diff_split_by_unchanged_cell_is_two_spans`. +#[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 case that defeated two attempted fixes, kept here so the wrong + // remedy is not reached for again. `cell::diff` splits a run at an + // already-matching cell and never transmits it, so the host sees the + // marker with an interior byte MISSING, not merely escaped around. + // Stripping is powerless; the longest prefix stops at the hole, which is + // exactly the `6/15 ("VTERM_")` the macOS runs reported. + let dropped = b"\x1b[9;1HVTERM_\x1b[9;8HLT_READY".to_vec(); + let visible = strip_ansi(&dropped); + assert_eq!(visible, b"VTERM_LT_READY", "the 'A' was never sent"); + assert!(!visible.windows(needle.len()).any(|w| w == needle)); + assert_eq!( + longest_rendered_prefix(&visible, needle), + 6, + "a dropped interior cell caps the prefix at the hole" + ); +} + +/// 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() + ); +}