test: add failing color/SGR/hyperlink preservation tests

Pin the baseline: feed OSC 8 hyperlinks, strikethrough, curly underline,
underline color, and double underline through Proxy, force a render,
and capture what the proxy emits to its stdout side. Each test asserts
the styling survives the round-trip.

All five fail on the current vt100 0.16 backend because its cell model
only stores fg/bg color, bold, dim, italic, underline, inverse — every
other attribute is dropped at render time. Tests are #[ignore]-gated so
CI stays green; run with --ignored to see the baseline failures. Once
the emulator is swapped to termwiz, remove the gates.

A truecolor-foreground sanity test runs by default and confirms the
parts of the SGR space vt100 does support are still working.

Adds Proxy::force_render as a #[cfg(test)] wrapper around the private
render_vt_screen method.
This commit is contained in:
2026-05-01 17:20:35 +00:00
parent 3256167f48
commit 1be6e4954e
3 changed files with 223 additions and 0 deletions

View File

@@ -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<String> {
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<u8> {
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<Vec<u8>> {
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)
);
}
}

View File

@@ -1,3 +1,5 @@
#[cfg(test)]
mod color_preservation_tests;
pub mod config;
pub mod escape_filter;
pub mod escape_sequences;

View File

@@ -412,6 +412,11 @@ impl Proxy {
self.pty_master.as_raw_fd()
}
#[cfg(test)]
pub(crate) fn force_render<F: AsFd>(&mut self, stdout_fd: &F) -> Result<()> {
self.render_vt_screen(stdout_fd)
}
#[cfg(test)]
pub(crate) fn flush_drain_buffer<F: AsFd>(&mut self, stdout_fd: &F) -> Result<()> {
if !self.pty_drain_buffer.is_empty() {