test: add PTY integration test harness
Add a pty-mock workspace crate (echo/alt-screen/paste-echo/buffer-fill/ sync-blocks subcommands) plus an in-process integration test suite that drives Proxy directly via a new_for_test constructor and #[cfg(test)] state accessors. Sets up the regression net needed before swapping the underlying VT emulator from vt100 to termwiz. Pulled from upstream draft PR #35; the alt-screen feed-order semantic change is intentionally deferred (its dedicated test is left #[ignore]).
This commit is contained in:
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -63,6 +63,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- run: cargo build -p pty-mock
|
||||||
- run: cargo test --all-targets
|
- run: cargo test --all-targets
|
||||||
|
|
||||||
test:
|
test:
|
||||||
|
|||||||
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -750,6 +750,13 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pty-mock"
|
||||||
|
version = "0.1.4"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.43"
|
version = "1.0.43"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/claude-chill",
|
"crates/claude-chill",
|
||||||
|
"crates/pty-mock",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
539
crates/claude-chill/src/integration_tests.rs
Normal file
539
crates/claude-chill/src/integration_tests.rs
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::proxy::{Proxy, ProxyConfig};
|
||||||
|
use nix::fcntl::{FcntlArg, OFlag, fcntl};
|
||||||
|
use nix::poll::{PollFd, PollFlags, PollTimeout, poll};
|
||||||
|
use nix::pty::openpty;
|
||||||
|
use nix::sys::termios::{self, SetArg};
|
||||||
|
use nix::unistd::read;
|
||||||
|
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
use std::process::{Child, Command};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
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() {
|
||||||
|
return Some(bin);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_command(cmd: &str, args: &[&str]) -> (OwnedFd, Child) {
|
||||||
|
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(cmd)
|
||||||
|
.args(args)
|
||||||
|
.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 command")
|
||||||
|
};
|
||||||
|
|
||||||
|
drop(pty.slave);
|
||||||
|
|
||||||
|
// Set master to non-blocking
|
||||||
|
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");
|
||||||
|
|
||||||
|
(pty.master, child)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_mock(subcommand: &str) -> Option<(OwnedFd, Child)> {
|
||||||
|
let bin = pty_mock_binary()?;
|
||||||
|
Some(spawn_command(&bin, &[subcommand]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_pty_output(master: &OwnedFd, timeout_ms: u16) -> Vec<u8> {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut buf = [0u8; 65536];
|
||||||
|
let mut poll_fds = [PollFd::new(master.as_fd(), PollFlags::POLLIN)];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match poll(&mut poll_fds, PollTimeout::from(timeout_ms)) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(_) => match read(master.as_fd(), &mut buf) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => result.extend_from_slice(&buf[..n]),
|
||||||
|
Err(nix::errno::Errno::EAGAIN) => break,
|
||||||
|
Err(_) => break,
|
||||||
|
},
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_proxy(master: OwnedFd, child: Child) -> Proxy {
|
||||||
|
let config = ProxyConfig::default();
|
||||||
|
Proxy::new_for_test(master, child, config, 24, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dev_null() -> OwnedFd {
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
let f = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.open("/dev/null")
|
||||||
|
.expect("open /dev/null");
|
||||||
|
OwnedFd::from(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dup_master(proxy: &Proxy) -> OwnedFd {
|
||||||
|
let raw = proxy.pty_master_fd_raw();
|
||||||
|
unsafe { OwnedFd::from_raw_fd(libc::dup(raw)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_echo_baseline() {
|
||||||
|
let Some((master, child)) = spawn_mock("echo") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut proxy = make_proxy(master, child);
|
||||||
|
let sink = dev_null();
|
||||||
|
|
||||||
|
proxy
|
||||||
|
.process_input(b"hello world\r\n", &sink)
|
||||||
|
.expect("process_input");
|
||||||
|
proxy.flush_drain_buffer(&sink).expect("flush drain");
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
|
||||||
|
let master_dup = dup_master(&proxy);
|
||||||
|
let output = read_pty_output(&master_dup, 100);
|
||||||
|
assert!(!output.is_empty(), "expected output from echo child");
|
||||||
|
proxy
|
||||||
|
.process_output(&output, &sink)
|
||||||
|
.expect("process_output");
|
||||||
|
|
||||||
|
let mut history = Vec::new();
|
||||||
|
proxy.history().append_all(&mut history);
|
||||||
|
let history_str = String::from_utf8_lossy(&history);
|
||||||
|
assert!(
|
||||||
|
history_str.contains("hello world"),
|
||||||
|
"history should contain echoed text, got: {:?}",
|
||||||
|
history_str
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_alt_screen_tracking() {
|
||||||
|
let Some((master, child)) = spawn_mock("alt-screen") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut proxy = make_proxy(master, child);
|
||||||
|
let sink = dev_null();
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
|
||||||
|
let master_dup = dup_master(&proxy);
|
||||||
|
let output = read_pty_output(&master_dup, 100);
|
||||||
|
if !output.is_empty() {
|
||||||
|
proxy
|
||||||
|
.process_output(&output, &sink)
|
||||||
|
.expect("process_output");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
proxy.is_in_alternate_screen(),
|
||||||
|
"proxy should be in alt screen"
|
||||||
|
);
|
||||||
|
|
||||||
|
proxy.process_input(b"x", &sink).expect("process_input");
|
||||||
|
proxy.flush_drain_buffer(&sink).expect("flush drain");
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
|
||||||
|
let master_dup = dup_master(&proxy);
|
||||||
|
let output = read_pty_output(&master_dup, 100);
|
||||||
|
if !output.is_empty() {
|
||||||
|
proxy
|
||||||
|
.process_output(&output, &sink)
|
||||||
|
.expect("process_output");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!proxy.is_in_alternate_screen(),
|
||||||
|
"proxy should have exited alt screen"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bracketed_paste_flow() {
|
||||||
|
let Some((master, child)) = spawn_mock("paste-echo") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut proxy = make_proxy(master, child);
|
||||||
|
let sink = dev_null();
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
let master_dup = dup_master(&proxy);
|
||||||
|
let output = read_pty_output(&master_dup, 100);
|
||||||
|
if !output.is_empty() {
|
||||||
|
proxy
|
||||||
|
.process_output(&output, &sink)
|
||||||
|
.expect("process_output");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut paste = Vec::new();
|
||||||
|
paste.extend_from_slice(b"\x1b[200~");
|
||||||
|
paste.extend_from_slice(b"pasted content\r\n");
|
||||||
|
paste.extend_from_slice(b"\x1b[201~");
|
||||||
|
|
||||||
|
proxy.process_input(&paste, &sink).expect("process_input");
|
||||||
|
proxy.flush_drain_buffer(&sink).expect("flush drain");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!proxy.is_in_bracketed_paste(),
|
||||||
|
"paste mode should be off after complete paste"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_split_paste_end_marker() {
|
||||||
|
let Some((master, child)) = spawn_mock("paste-echo") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut proxy = make_proxy(master, child);
|
||||||
|
let sink = dev_null();
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
let master_dup = dup_master(&proxy);
|
||||||
|
let output = read_pty_output(&master_dup, 100);
|
||||||
|
if !output.is_empty() {
|
||||||
|
proxy
|
||||||
|
.process_output(&output, &sink)
|
||||||
|
.expect("process_output");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut chunk1 = Vec::new();
|
||||||
|
chunk1.extend_from_slice(b"\x1b[200~");
|
||||||
|
chunk1.extend_from_slice(b"split test\r\n");
|
||||||
|
chunk1.extend_from_slice(b"\x1b[20"); // partial end marker
|
||||||
|
|
||||||
|
proxy
|
||||||
|
.process_input(&chunk1, &sink)
|
||||||
|
.expect("process_input chunk1");
|
||||||
|
proxy.flush_drain_buffer(&sink).expect("flush drain");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
proxy.is_in_bracketed_paste(),
|
||||||
|
"should still be in paste mode after partial end marker"
|
||||||
|
);
|
||||||
|
|
||||||
|
proxy
|
||||||
|
.process_input(b"1~", &sink)
|
||||||
|
.expect("process_input chunk2");
|
||||||
|
proxy.flush_drain_buffer(&sink).expect("flush drain");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!proxy.is_in_bracketed_paste(),
|
||||||
|
"paste mode should be off after completing split end marker"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_alt_screen_exit_restores_main_content() {
|
||||||
|
let Some((master, child)) = spawn_mock("echo") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut proxy = make_proxy(master, child);
|
||||||
|
let sink = dev_null();
|
||||||
|
|
||||||
|
// Simulate child writing main screen content
|
||||||
|
let main_content = b"Claude Code v2.1.84\r\nOpus 4.6\r\n~/public/claude-chill\r\n";
|
||||||
|
proxy
|
||||||
|
.process_output(main_content, &sink)
|
||||||
|
.expect("process_output main content");
|
||||||
|
|
||||||
|
let screen_before = proxy.vt_screen_text();
|
||||||
|
assert!(
|
||||||
|
screen_before.contains("Claude Code v2.1.84"),
|
||||||
|
"main content should be on screen before alt screen, got: {:?}",
|
||||||
|
screen_before
|
||||||
|
);
|
||||||
|
|
||||||
|
// Child enters alt screen
|
||||||
|
proxy
|
||||||
|
.process_output(b"\x1b[?1049h", &sink)
|
||||||
|
.expect("process_output alt enter");
|
||||||
|
assert!(proxy.is_in_alternate_screen());
|
||||||
|
|
||||||
|
// Child writes alt screen content (e.g. vim, less)
|
||||||
|
proxy
|
||||||
|
.process_output(b"alt screen editor content\r\nline 2\r\n", &sink)
|
||||||
|
.expect("process_output alt content");
|
||||||
|
|
||||||
|
// Child exits alt screen
|
||||||
|
proxy
|
||||||
|
.process_output(b"\x1b[?1049l", &sink)
|
||||||
|
.expect("process_output alt exit");
|
||||||
|
assert!(!proxy.is_in_alternate_screen());
|
||||||
|
|
||||||
|
// The VT parser should have restored the main screen content
|
||||||
|
let screen_after = proxy.vt_screen_text();
|
||||||
|
assert!(
|
||||||
|
screen_after.contains("Claude Code v2.1.84"),
|
||||||
|
"main content should be restored after alt screen exit, got: {:?}",
|
||||||
|
screen_after
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skipped on this branch: depends on the alt-screen feed-order fix from
|
||||||
|
// upstream PR #35 (incremental VT feeding around alt enter/exit). We're
|
||||||
|
// pulling in only the test harness now; the semantic fix is a separate
|
||||||
|
// step. Re-enable by removing #[ignore] once that change lands.
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn test_alt_screen_exit_restores_after_clear_screen_sync_block() {
|
||||||
|
// Reproduces bug: sync block containing clear screen clears VT parser,
|
||||||
|
// but render_vt_screen never fires before alt-screen-enter arrives
|
||||||
|
// in the next chunk. VT parser saves blank screen. After alt exit,
|
||||||
|
// contents_formatted() paints blank over the real terminal which
|
||||||
|
// still had the original content (it never got the clear render).
|
||||||
|
let Some((master, child)) = spawn_mock("echo") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut proxy = make_proxy(master, child);
|
||||||
|
let sink = dev_null();
|
||||||
|
|
||||||
|
// Initial content via sync block — this gets rendered
|
||||||
|
let mut initial_sync = Vec::new();
|
||||||
|
initial_sync.extend_from_slice(b"\x1b[?2026h");
|
||||||
|
initial_sync.extend_from_slice(b"\x1b[H");
|
||||||
|
initial_sync.extend_from_slice(b"Claude Code v2.1.84\r\n");
|
||||||
|
initial_sync.extend_from_slice(b"Opus 4.6\r\n");
|
||||||
|
initial_sync.extend_from_slice(b"~/public/claude-chill\r\n");
|
||||||
|
initial_sync.extend_from_slice(b"\x1b[?2026l");
|
||||||
|
|
||||||
|
proxy
|
||||||
|
.process_output(&initial_sync, &sink)
|
||||||
|
.expect("initial sync block");
|
||||||
|
|
||||||
|
let screen_before = proxy.vt_screen_text();
|
||||||
|
assert!(
|
||||||
|
screen_before.contains("Claude Code v2.1.84"),
|
||||||
|
"header should be on screen initially"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync block with clear screen (simulates ctrl-g response) —
|
||||||
|
// VT parser processes the clear but render never fires
|
||||||
|
let mut clear_sync = Vec::new();
|
||||||
|
clear_sync.extend_from_slice(b"\x1b[?2026h");
|
||||||
|
clear_sync.extend_from_slice(b"\x1b[2J"); // clear screen
|
||||||
|
clear_sync.extend_from_slice(b"\x1b[H");
|
||||||
|
clear_sync.extend_from_slice(b"\x1b[?2026l");
|
||||||
|
|
||||||
|
proxy
|
||||||
|
.process_output(&clear_sync, &sink)
|
||||||
|
.expect("clear sync block");
|
||||||
|
|
||||||
|
// VT parser screen is now blank (clear screen was processed)
|
||||||
|
let screen_after_clear = proxy.vt_screen_text();
|
||||||
|
assert!(
|
||||||
|
!screen_after_clear.contains("Claude Code"),
|
||||||
|
"VT screen should be blank after clear sync block"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alt screen enter in the NEXT chunk — no render_vt_screen fired
|
||||||
|
// between the clear sync and this. VT parser saves blank screen.
|
||||||
|
proxy
|
||||||
|
.process_output(b"\x1b[?1049h", &sink)
|
||||||
|
.expect("alt screen enter");
|
||||||
|
assert!(proxy.is_in_alternate_screen());
|
||||||
|
|
||||||
|
// Editor content during alt screen
|
||||||
|
proxy
|
||||||
|
.process_output(b"editor content\r\n", &sink)
|
||||||
|
.expect("alt content");
|
||||||
|
|
||||||
|
// Alt screen exit — VT restores saved (blank) screen
|
||||||
|
proxy
|
||||||
|
.process_output(b"\x1b[?1049l", &sink)
|
||||||
|
.expect("alt screen exit");
|
||||||
|
assert!(!proxy.is_in_alternate_screen());
|
||||||
|
|
||||||
|
// Clear screen wiped the main buffer before alt-screen saved it.
|
||||||
|
// Correct VT100 behavior: restored screen should be blank.
|
||||||
|
let screen_after_exit = proxy.vt_screen_text();
|
||||||
|
assert!(
|
||||||
|
!screen_after_exit.contains("Claude Code v2.1.84"),
|
||||||
|
"header should NOT survive clear + alt screen round-trip, got: {:?}",
|
||||||
|
screen_after_exit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sync_block_tracking() {
|
||||||
|
let Some((master, child)) = spawn_mock("echo") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut proxy = make_proxy(master, child);
|
||||||
|
let sink = dev_null();
|
||||||
|
|
||||||
|
// Start a sync block without ending it
|
||||||
|
proxy
|
||||||
|
.process_output(b"\x1b[?2026h some content", &sink)
|
||||||
|
.expect("partial sync block");
|
||||||
|
assert!(
|
||||||
|
proxy.is_in_sync_block(),
|
||||||
|
"should be in sync block after SYNC_START without SYNC_END"
|
||||||
|
);
|
||||||
|
|
||||||
|
// End the sync block
|
||||||
|
proxy
|
||||||
|
.process_output(b" more content \x1b[?2026l", &sink)
|
||||||
|
.expect("sync block end");
|
||||||
|
assert!(
|
||||||
|
!proxy.is_in_sync_block(),
|
||||||
|
"should not be in sync block after SYNC_END"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lookback_mode_toggle() {
|
||||||
|
let Some((master, child)) = spawn_mock("echo") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut proxy = make_proxy(master, child);
|
||||||
|
let sink = dev_null();
|
||||||
|
|
||||||
|
// Generate some output so lookback has content
|
||||||
|
proxy
|
||||||
|
.process_output(b"line 1\r\nline 2\r\nline 3\r\n", &sink)
|
||||||
|
.expect("output");
|
||||||
|
|
||||||
|
assert!(!proxy.is_in_lookback_mode());
|
||||||
|
|
||||||
|
// Send legacy lookback key (0x1E = Ctrl+^)
|
||||||
|
send_input(&mut proxy, &sink, &[0x1E]);
|
||||||
|
assert!(
|
||||||
|
proxy.is_in_lookback_mode(),
|
||||||
|
"should enter lookback mode after Ctrl+^"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send it again to exit
|
||||||
|
send_input(&mut proxy, &sink, &[0x1E]);
|
||||||
|
assert!(
|
||||||
|
!proxy.is_in_lookback_mode(),
|
||||||
|
"should exit lookback mode after second Ctrl+^"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pump_proxy(proxy: &mut Proxy, sink: &OwnedFd, timeout_ms: u16) {
|
||||||
|
let master_dup = dup_master(proxy);
|
||||||
|
let output = read_pty_output(&master_dup, timeout_ms);
|
||||||
|
if !output.is_empty() {
|
||||||
|
proxy.process_output(&output, sink).expect("process_output");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_input(proxy: &mut Proxy, sink: &OwnedFd, data: &[u8]) {
|
||||||
|
proxy.process_input(data, sink).expect("process_input");
|
||||||
|
proxy.flush_drain_buffer(sink).expect("flush drain");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_screen(proxy: &mut Proxy, sink: &OwnedFd, text: &str, timeout: Duration) -> bool {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
while start.elapsed() < timeout {
|
||||||
|
pump_proxy(proxy, sink, 100);
|
||||||
|
if proxy.vt_screen_text().contains(text) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore] // requires claude CLI to be installed and authenticated
|
||||||
|
fn test_claude_alt_screen_restore() {
|
||||||
|
let (master, child) = spawn_command("claude", &[]);
|
||||||
|
let mut proxy = make_proxy(master, child);
|
||||||
|
let sink = dev_null();
|
||||||
|
|
||||||
|
// Wait for Claude Code to start up and show its UI
|
||||||
|
assert!(
|
||||||
|
wait_for_screen(&mut proxy, &sink, "Claude Code", Duration::from_secs(10)),
|
||||||
|
"Claude Code did not start, screen: {:?}",
|
||||||
|
proxy.vt_screen_text()
|
||||||
|
);
|
||||||
|
|
||||||
|
let screen_before = proxy.vt_screen_text();
|
||||||
|
eprintln!("Screen before alt screen:\n{}", screen_before);
|
||||||
|
assert!(
|
||||||
|
screen_before.contains("▐▛███▜▌"),
|
||||||
|
"logo should be present before alt screen"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send Ctrl-G to open the editor (enters alt screen)
|
||||||
|
send_input(&mut proxy, &sink, &[0x07]); // Ctrl-G
|
||||||
|
|
||||||
|
// Wait for alt screen to be entered
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
while start.elapsed() < Duration::from_secs(5) {
|
||||||
|
pump_proxy(&mut proxy, &sink, 100);
|
||||||
|
if proxy.is_in_alternate_screen() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
proxy.is_in_alternate_screen(),
|
||||||
|
"should have entered alt screen after Ctrl-G"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pump until editor has rendered
|
||||||
|
pump_proxy(&mut proxy, &sink, 500);
|
||||||
|
|
||||||
|
// Send :wq<Enter> to close the editor (exits alt screen)
|
||||||
|
send_input(&mut proxy, &sink, b"\x1b");
|
||||||
|
pump_proxy(&mut proxy, &sink, 100);
|
||||||
|
send_input(&mut proxy, &sink, b":wq\r");
|
||||||
|
|
||||||
|
// Wait for alt screen to be exited
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
while start.elapsed() < Duration::from_secs(5) {
|
||||||
|
pump_proxy(&mut proxy, &sink, 100);
|
||||||
|
if !proxy.is_in_alternate_screen() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain remaining output after alt screen exit
|
||||||
|
pump_proxy(&mut proxy, &sink, 500);
|
||||||
|
|
||||||
|
let vt_screen_after = proxy.vt_screen_text();
|
||||||
|
|
||||||
|
eprintln!("VT parser screen after alt exit:\n{}", vt_screen_after);
|
||||||
|
|
||||||
|
// Claude Code bug: after editor exit, the logo (▐▛███▜▌) is not
|
||||||
|
// redrawn. This happens with or without claude-chill.
|
||||||
|
// Update this assertion if Claude Code fixes the redraw.
|
||||||
|
assert!(
|
||||||
|
!vt_screen_after.contains("▐▛███▜▌"),
|
||||||
|
"logo should NOT appear after alt screen exit (known Claude Code bug), got: {:?}",
|
||||||
|
vt_screen_after
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send /exit to quit
|
||||||
|
send_input(&mut proxy, &sink, b"/exit\r");
|
||||||
|
pump_proxy(&mut proxy, &sink, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ pub mod config;
|
|||||||
pub mod escape_filter;
|
pub mod escape_filter;
|
||||||
pub mod escape_sequences;
|
pub mod escape_sequences;
|
||||||
pub mod history_filter;
|
pub mod history_filter;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod integration_tests;
|
||||||
pub mod key_parser;
|
pub mod key_parser;
|
||||||
pub mod line_buffer;
|
pub mod line_buffer;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
|
|||||||
@@ -322,6 +322,105 @@ impl Proxy {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn new_for_test(
|
||||||
|
pty_master: OwnedFd,
|
||||||
|
child: Child,
|
||||||
|
config: ProxyConfig,
|
||||||
|
rows: u16,
|
||||||
|
cols: u16,
|
||||||
|
) -> Self {
|
||||||
|
let vt_parser = vt100::Parser::new(rows, cols, 0);
|
||||||
|
let mut history = LineBuffer::new(config.max_history_lines);
|
||||||
|
history.push_bytes(CLEAR_SCREEN);
|
||||||
|
history.push_bytes(CURSOR_HOME);
|
||||||
|
let auto_lookback_timeout = Duration::from_millis(config.auto_lookback_timeout_ms);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
history,
|
||||||
|
history_filter: HistoryFilter::new(),
|
||||||
|
config,
|
||||||
|
pty_master,
|
||||||
|
child,
|
||||||
|
original_termios: None,
|
||||||
|
vt_parser,
|
||||||
|
vt_prev_screen: None,
|
||||||
|
last_output_time: None,
|
||||||
|
last_render_time: None,
|
||||||
|
last_stdin_time: None,
|
||||||
|
last_auto_lookback_time: None,
|
||||||
|
auto_lookback_timeout,
|
||||||
|
sync_buffer: Vec::with_capacity(SYNC_BUFFER_CAPACITY),
|
||||||
|
in_sync_block: false,
|
||||||
|
in_lookback_mode: false,
|
||||||
|
in_alternate_screen: false,
|
||||||
|
in_bracketed_paste: false,
|
||||||
|
kitty_mode_supported: false,
|
||||||
|
kitty_mode_stack: 0,
|
||||||
|
kitty_output_parser: TermwizParser::new(),
|
||||||
|
vt_render_pending: false,
|
||||||
|
lookback_cache: Vec::new(),
|
||||||
|
lookback_input_buffer: Vec::with_capacity(INPUT_BUFFER_CAPACITY),
|
||||||
|
output_buffer: Vec::with_capacity(OUTPUT_BUFFER_CAPACITY),
|
||||||
|
sync_start_finder: memmem::Finder::new(SYNC_START),
|
||||||
|
sync_end_finder: memmem::Finder::new(SYNC_END),
|
||||||
|
clear_screen_finder: memmem::Finder::new(CLEAR_SCREEN),
|
||||||
|
cursor_home_finder: memmem::Finder::new(CURSOR_HOME),
|
||||||
|
alt_screen_enter_finder: memmem::Finder::new(ALT_SCREEN_ENTER),
|
||||||
|
alt_screen_exit_finder: memmem::Finder::new(ALT_SCREEN_EXIT),
|
||||||
|
alt_screen_enter_legacy_finder: memmem::Finder::new(ALT_SCREEN_ENTER_LEGACY),
|
||||||
|
alt_screen_exit_legacy_finder: memmem::Finder::new(ALT_SCREEN_EXIT_LEGACY),
|
||||||
|
paste_start_finder: memmem::Finder::new(BRACKETED_PASTE_START),
|
||||||
|
paste_end_finder: memmem::Finder::new(BRACKETED_PASTE_END),
|
||||||
|
pty_drain_buffer: Vec::new(),
|
||||||
|
paste_remainder: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn history(&self) -> &LineBuffer {
|
||||||
|
&self.history
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn is_in_alternate_screen(&self) -> bool {
|
||||||
|
self.in_alternate_screen
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn is_in_bracketed_paste(&self) -> bool {
|
||||||
|
self.in_bracketed_paste
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn is_in_sync_block(&self) -> bool {
|
||||||
|
self.in_sync_block
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn is_in_lookback_mode(&self) -> bool {
|
||||||
|
self.in_lookback_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn vt_screen_text(&self) -> String {
|
||||||
|
self.vt_parser.screen().contents()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn pty_master_fd_raw(&self) -> i32 {
|
||||||
|
self.pty_master.as_raw_fd()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn flush_drain_buffer<F: AsFd>(&mut self, stdout_fd: &F) -> Result<()> {
|
||||||
|
if !self.pty_drain_buffer.is_empty() {
|
||||||
|
let drained = std::mem::take(&mut self.pty_drain_buffer);
|
||||||
|
self.process_output(&drained, stdout_fd)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run(&mut self) -> Result<i32> {
|
pub fn run(&mut self) -> Result<i32> {
|
||||||
let stdin_fd = io::stdin();
|
let stdin_fd = io::stdin();
|
||||||
let stdout_fd = io::stdout();
|
let stdout_fd = io::stdout();
|
||||||
@@ -406,7 +505,7 @@ impl Proxy {
|
|||||||
self.wait_child()
|
self.wait_child()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process_output<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
|
pub(crate) fn process_output<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
|
||||||
self.process_output_inner(data, stdout_fd, true)
|
self.process_output_inner(data, stdout_fd, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -842,7 +941,7 @@ impl Proxy {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process_input<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
|
pub(crate) fn process_input<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
|
||||||
self.last_stdin_time = Some(Instant::now());
|
self.last_stdin_time = Some(Instant::now());
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
|
|||||||
12
crates/pty-mock/Cargo.toml
Normal file
12
crates/pty-mock/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "pty-mock"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "pty-mock"
|
||||||
|
path = "src/bin.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
146
crates/pty-mock/src/bin.rs
Normal file
146
crates/pty-mock/src/bin.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use std::io::{self, BufRead, Read, Write};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
Echo,
|
||||||
|
AltScreen,
|
||||||
|
PasteEcho,
|
||||||
|
BufferFill,
|
||||||
|
SyncBlocks,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_echo() {
|
||||||
|
let stdin = io::stdin();
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
for line in stdin.lock().lines() {
|
||||||
|
let line = match line {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
if writeln!(stdout, "{}", line).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if stdout.flush().is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_alt_screen() {
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
let mut stdin = io::stdin();
|
||||||
|
|
||||||
|
// Enter alt screen
|
||||||
|
let _ = stdout.write_all(b"\x1b[?1049h");
|
||||||
|
let _ = stdout.flush();
|
||||||
|
|
||||||
|
// Write some content while in alt screen
|
||||||
|
let _ = stdout.write_all(b"alt screen content\r\n");
|
||||||
|
let _ = stdout.flush();
|
||||||
|
|
||||||
|
// Wait for any byte on stdin as trigger to exit
|
||||||
|
let mut trigger = [0u8; 1];
|
||||||
|
let _ = stdin.read_exact(&mut trigger);
|
||||||
|
|
||||||
|
// Exit alt screen
|
||||||
|
let _ = stdout.write_all(b"\x1b[?1049l");
|
||||||
|
let _ = stdout.flush();
|
||||||
|
|
||||||
|
// Write content after alt screen exit
|
||||||
|
let _ = stdout.write_all(b"back to normal\r\n");
|
||||||
|
let _ = stdout.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_paste_echo() {
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
let stdin = io::stdin();
|
||||||
|
|
||||||
|
// Enable bracketed paste mode
|
||||||
|
let _ = stdout.write_all(b"\x1b[?2004h");
|
||||||
|
let _ = stdout.flush();
|
||||||
|
|
||||||
|
// Read lines, prefix paste content
|
||||||
|
for line in stdin.lock().lines() {
|
||||||
|
let line = match line {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
if writeln!(stdout, "pasted: {}", line).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if stdout.flush().is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_buffer_fill() {
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
let stdin = io::stdin();
|
||||||
|
let mut stdin_lock = stdin.lock();
|
||||||
|
|
||||||
|
// Write large output to fill the PTY buffer
|
||||||
|
let chunk = "X".repeat(1024) + "\r\n";
|
||||||
|
for _ in 0..256 {
|
||||||
|
if stdout.write_all(chunk.as_bytes()).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = stdout.flush();
|
||||||
|
|
||||||
|
// Now read stdin and echo it back
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
loop {
|
||||||
|
match stdin_lock.read(&mut buf) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
if stdout.write_all(&buf[..n]).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if stdout.flush().is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_sync_blocks() {
|
||||||
|
let stdin = io::stdin();
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
|
||||||
|
for line in stdin.lock().lines() {
|
||||||
|
let line = match line {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
// Wrap response in sync update block
|
||||||
|
let _ = stdout.write_all(b"\x1b[?2026h");
|
||||||
|
if writeln!(stdout, "sync: {}", line).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let _ = stdout.write_all(b"\x1b[?2026l");
|
||||||
|
if stdout.flush().is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
match cli.command {
|
||||||
|
Commands::Echo => cmd_echo(),
|
||||||
|
Commands::AltScreen => cmd_alt_screen(),
|
||||||
|
Commands::PasteEcho => cmd_paste_echo(),
|
||||||
|
Commands::BufferFill => cmd_buffer_fill(),
|
||||||
|
Commands::SyncBlocks => cmd_sync_blocks(),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user