colors-rewrite #1
216
crates/claude-chill/src/color_preservation_tests.rs
Normal file
216
crates/claude-chill/src/color_preservation_tests.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
#[cfg(test)]
|
||||
mod color_preservation_tests;
|
||||
pub mod config;
|
||||
pub mod escape_filter;
|
||||
pub mod escape_sequences;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user