diff --git a/crates/claude-chill/src/color_preservation_tests.rs b/crates/claude-chill/src/color_preservation_tests.rs new file mode 100644 index 0000000..6fed42a --- /dev/null +++ b/crates/claude-chill/src/color_preservation_tests.rs @@ -0,0 +1,216 @@ +//! Color / SGR / hyperlink preservation tests. +//! +//! These tests feed modern terminal styling bytes through a real `Proxy`, +//! force a render, capture what the proxy emits to its stdout side, and +//! assert that the styling survives the round-trip. +//! +//! They currently FAIL on master because the underlying `vt100 0.16` +//! emulator only models a handful of attributes (fg/bg color, bold, dim, +//! italic, underline, inverse). Anything else — OSC 8 hyperlinks, +//! strikethrough, fancy underline styles, underline color — is silently +//! dropped on render. +//! +//! They are gated behind `#[ignore]` so CI stays green. Run them with: +//! cargo test -p claude-chill --lib color_preservation -- --ignored +//! +//! The expectation is that they pass once the emulator is swapped to +//! termwiz's `Surface`. Remove the `#[ignore]` attributes at that point. + +#[cfg(test)] +mod tests { + use crate::proxy::{Proxy, ProxyConfig}; + use nix::fcntl::{FcntlArg, OFlag, fcntl}; + use nix::pty::openpty; + use nix::sys::termios::{self, SetArg}; + use nix::unistd::{pipe, read}; + use std::os::fd::{AsFd, AsRawFd, OwnedFd}; + use std::os::unix::process::CommandExt; + use std::process::{Child, Command}; + + fn pty_mock_binary() -> Option { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let bin = format!("{}/../../target/debug/pty-mock", manifest_dir); + if std::path::Path::new(&bin).exists() { + Some(bin) + } else { + None + } + } + + fn spawn_echo_child() -> Option<(OwnedFd, Child)> { + let bin = pty_mock_binary()?; + let pty = openpty(None, None).expect("openpty failed"); + + let slave_fd = pty.slave.as_raw_fd(); + let mut termios_settings = termios::tcgetattr(&pty.slave).expect("tcgetattr failed"); + termios::cfmakeraw(&mut termios_settings); + termios::tcsetattr(&pty.slave, SetArg::TCSANOW, &termios_settings) + .expect("tcsetattr failed"); + + let child = unsafe { + Command::new(&bin) + .arg("echo") + .stdin(pty.slave.try_clone().expect("clone slave")) + .stdout(pty.slave.try_clone().expect("clone slave")) + .stderr(pty.slave.try_clone().expect("clone slave")) + .pre_exec(move || { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + if libc::ioctl(slave_fd, libc::TIOCSCTTY as libc::c_ulong, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }) + .spawn() + .expect("failed to spawn pty-mock echo") + }; + + drop(pty.slave); + + let flags = fcntl(&pty.master, FcntlArg::F_GETFL).expect("F_GETFL"); + let flags = OFlag::from_bits_truncate(flags); + fcntl(&pty.master, FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK)).expect("F_SETFL"); + + Some((pty.master, child)) + } + + fn make_proxy(master: OwnedFd, child: Child) -> Proxy { + Proxy::new_for_test(master, child, ProxyConfig::default(), 24, 80) + } + + /// Make a non-blocking pipe to capture proxy output. + fn capture_pipe() -> (OwnedFd, OwnedFd) { + let (rx, tx) = pipe().expect("pipe failed"); + let flags = fcntl(&rx, FcntlArg::F_GETFL).expect("F_GETFL"); + let flags = OFlag::from_bits_truncate(flags); + fcntl(&rx, FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK)).expect("F_SETFL"); + (rx, tx) + } + + fn drain(rx: &OwnedFd) -> Vec { + let mut buf = [0u8; 65536]; + let mut out = Vec::new(); + loop { + match read(rx.as_fd(), &mut buf) { + Ok(0) => break, + Ok(n) => out.extend_from_slice(&buf[..n]), + Err(_) => break, + } + } + out + } + + /// Feed `input` through the proxy, force a render to a captured pipe, + /// and return what the proxy emitted. + fn render_and_capture(input: &[u8]) -> Option> { + let (master, child) = spawn_echo_child()?; + let mut proxy = make_proxy(master, child); + let (rx, tx) = capture_pipe(); + + proxy.process_output(input, &tx).expect("process_output"); + proxy.force_render(&tx).expect("force_render"); + + Some(drain(&rx)) + } + + fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) + } + + #[test] + #[ignore] // FIXME: passes once VT emulator swap to termwiz lands + fn osc8_hyperlink_survives_render() { + // OSC 8 hyperlinks — used for clickable file paths / URLs. + // Format: ESC ] 8 ; ; URL ESC \ ... ESC ] 8 ; ; ESC \ + let mut input = Vec::new(); + input.extend_from_slice(b"\x1b]8;;https://example.com/foo\x1b\\"); + input.extend_from_slice(b"link text"); + input.extend_from_slice(b"\x1b]8;;\x1b\\"); + + let captured = render_and_capture(&input).expect("pty-mock binary missing"); + assert!( + contains_bytes(&captured, b"\x1b]8;;https://example.com/foo"), + "OSC 8 hyperlink open should survive render, got: {:?}", + String::from_utf8_lossy(&captured) + ); + assert!( + contains_bytes(&captured, b"\x1b]8;;\x1b\\"), + "OSC 8 hyperlink close should survive render, got: {:?}", + String::from_utf8_lossy(&captured) + ); + } + + #[test] + #[ignore] // FIXME: passes once VT emulator swap to termwiz lands + fn strikethrough_survives_render() { + // SGR 9 = strikethrough. vt100 0.16 has no field for it. + let input = b"\x1b[9mstruck through text\x1b[0m"; + let captured = render_and_capture(input).expect("pty-mock binary missing"); + assert!( + contains_bytes(&captured, b"\x1b[9m") || contains_bytes(&captured, b";9m"), + "SGR 9 strikethrough should survive render, got: {:?}", + String::from_utf8_lossy(&captured) + ); + } + + #[test] + #[ignore] // FIXME: passes once VT emulator swap to termwiz lands + fn curly_underline_survives_render() { + // SGR 4:3 = curly underline (kitty/wezterm/ghostty all support). + let input = b"\x1b[4:3mcurly underline\x1b[0m"; + let captured = render_and_capture(input).expect("pty-mock binary missing"); + assert!( + contains_bytes(&captured, b"4:3"), + "SGR 4:3 curly underline should survive render, got: {:?}", + String::from_utf8_lossy(&captured) + ); + } + + #[test] + #[ignore] // FIXME: passes once VT emulator swap to termwiz lands + fn underline_color_survives_render() { + // SGR 58:2::R:G:B = truecolor underline color. + let input = b"\x1b[4m\x1b[58:2::255:0:0mcolored underline\x1b[0m"; + let captured = render_and_capture(input).expect("pty-mock binary missing"); + assert!( + contains_bytes(&captured, b"58:2") || contains_bytes(&captured, b"58;2"), + "SGR 58 underline color should survive render, got: {:?}", + String::from_utf8_lossy(&captured) + ); + } + + #[test] + #[ignore] // FIXME: passes once VT emulator swap to termwiz lands + fn double_underline_survives_render() { + // SGR 21 = doubly underlined. + let input = b"\x1b[21mdouble underlined\x1b[0m"; + let captured = render_and_capture(input).expect("pty-mock binary missing"); + assert!( + contains_bytes(&captured, b"\x1b[21m") + || contains_bytes(&captured, b";21m") + || contains_bytes(&captured, b"4:2"), + "SGR 21 double underline should survive render, got: {:?}", + String::from_utf8_lossy(&captured) + ); + } + + /// Sanity check: confirm a styling attribute that vt100 *does* support + /// already survives the render. If this ever fails, something more + /// fundamental is broken than just attribute coverage. + #[test] + fn truecolor_foreground_survives_render() { + // SGR 38;2;R;G;B = truecolor foreground. vt100 supports this. + let input = b"\x1b[38;2;255;128;0morange text\x1b[0m"; + let Some(captured) = render_and_capture(input) else { + // pty-mock not built — skip. + return; + }; + assert!( + contains_bytes(&captured, b"38;2;255;128;0") + || contains_bytes(&captured, b"38:2::255:128:0"), + "truecolor foreground should survive render (sanity), got: {:?}", + String::from_utf8_lossy(&captured) + ); + } +} diff --git a/crates/claude-chill/src/lib.rs b/crates/claude-chill/src/lib.rs index 1219fef..3fc02d7 100644 --- a/crates/claude-chill/src/lib.rs +++ b/crates/claude-chill/src/lib.rs @@ -1,3 +1,5 @@ +#[cfg(test)] +mod color_preservation_tests; pub mod config; pub mod escape_filter; pub mod escape_sequences; diff --git a/crates/claude-chill/src/proxy.rs b/crates/claude-chill/src/proxy.rs index 071c15b..a4a29d7 100644 --- a/crates/claude-chill/src/proxy.rs +++ b/crates/claude-chill/src/proxy.rs @@ -412,6 +412,11 @@ impl Proxy { self.pty_master.as_raw_fd() } + #[cfg(test)] + pub(crate) fn force_render(&mut self, stdout_fd: &F) -> Result<()> { + self.render_vt_screen(stdout_fd) + } + #[cfg(test)] pub(crate) fn flush_drain_buffer(&mut self, stdout_fd: &F) -> Result<()> { if !self.pty_drain_buffer.is_empty() {