diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..681311eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 4ced9c80..a253c5c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -601,6 +601,15 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" +[[package]] +name = "cp_r" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "837ca07dfd27a2663ac7c4701bb35856b534c2a61dd47af06ccf65d3bec79ebc" +dependencies = [ + "filetime", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -2222,6 +2231,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "peg" version = "0.8.5" @@ -2492,6 +2507,24 @@ dependencies = [ "vt100", ] +[[package]] +name = "pty_terminal_test" +version = "0.0.0" +dependencies = [ + "anyhow", + "crossterm", + "ctor", + "ntest", + "portable-pty", + "pty_terminal", + "pty_terminal_test_client", + "subprocess_test", +] + +[[package]] +name = "pty_terminal_test_client" +version = "0.0.0" + [[package]] name = "quote" version = "1.0.44" @@ -3816,11 +3849,15 @@ dependencies = [ "anyhow", "async-trait", "clap", - "copy_dir", "cow-utils", + "cp_r", + "crossterm", "insta", "jsonc-parser", + "pathdiff", "pty_terminal", + "pty_terminal_test", + "pty_terminal_test_client", "regex", "rustc-hash", "serde", diff --git a/Cargo.toml b/Cargo.toml index 1e85701b..65153612 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ const_format = "0.2.34" constcat = "0.6.1" copy_dir = "0.1.3" cow-utils = "0.1.3" +cp_r = "0.5.2" crossterm = { version = "0.29.0", features = ["event-stream"] } csv-async = { version = "1.3.1", features = ["tokio"] } ctor = "0.6" @@ -89,11 +90,14 @@ os_str_bytes = "7.1.1" ouroboros = "0.18.5" owo-colors = "4.1.0" passfd = { git = "https://github.com/polachok/passfd", rev = "d55881752c16aced1a49a75f9c428d38d3767213", default-features = false } +pathdiff = "0.2.3" petgraph = "0.8.2" phf = { version = "0.11.3", features = ["macros"] } portable-pty = "0.9.0" pretty_assertions = "1.4.1" pty_terminal = { path = "crates/pty_terminal" } +pty_terminal_test = { path = "crates/pty_terminal_test" } +pty_terminal_test_client = { path = "crates/pty_terminal_test_client" } rand = "0.9.1" ratatui = "0.30.0" rayon = "1.10.0" diff --git a/crates/pty_terminal/src/terminal.rs b/crates/pty_terminal/src/terminal.rs index 31a2262d..9e44a10c 100644 --- a/crates/pty_terminal/src/terminal.rs +++ b/crates/pty_terminal/src/terminal.rs @@ -1,4 +1,5 @@ use std::{ + collections::VecDeque, io::{Read, Write}, sync::{Arc, Mutex, OnceLock}, thread, @@ -9,26 +10,58 @@ use portable_pty::{ChildKiller, ExitStatus, MasterPty}; use crate::geo::ScreenSize; -/// A headless terminal -pub struct Terminal { - master: Box, - parser: vt100::Parser, - child_killer: Box, +/// The read half of a PTY connection. Implements [`Read`]. +/// +/// Reading feeds data through an internal vt100 parser (shared with [`PtyWriter`]), +/// keeping `screen_contents()` up-to-date with parsed terminal output. +pub struct PtyReader { reader: Box, - writer: Arc>>>, + parser: Arc>>, +} - /// Unprocessed data buffer for `read_until` - read_until_buffer: Vec, +/// The write half of a PTY connection. Implements [`Write`]. +/// +/// The writer is shared with `Vt100Callbacks` (for DSR query responses) and the +/// background child-monitoring thread (which sets it to `None` on child exit). +pub struct PtyWriter { + writer: Arc>>>, + parser: Arc>>, + master: Box, +} - /// Exit status from the child process, set once by background thread +/// A cloneable handle to a child process spawned in a PTY. +pub struct ChildHandle { + child_killer: Box, exit_status: Arc>, } +impl Clone for ChildHandle { + fn clone(&self) -> Self { + Self { + child_killer: self.child_killer.clone_killer(), + exit_status: Arc::clone(&self.exit_status), + } + } +} + +/// A headless terminal consisting of a PTY reader, writer, and a child process handle. +pub struct Terminal { + pub pty_reader: PtyReader, + pub pty_writer: PtyWriter, + pub child_handle: ChildHandle, +} + struct Vt100Callbacks { writer: Arc>>>, + unhandled_osc_sequences: VecDeque>>, } impl vt100::Callbacks for Vt100Callbacks { + fn unhandled_osc(&mut self, _screen: &mut vt100::Screen, params: &[&[u8]]) { + let owned: Vec> = params.iter().map(|p| p.to_vec()).collect(); + self.unhandled_osc_sequences.push_back(owned); + } + fn unhandled_csi( &mut self, screen: &mut vt100::Screen, @@ -53,198 +86,98 @@ impl vt100::Callbacks for Vt100Callbacks { } } -impl Terminal { - /// Spawns a new child process in a headless terminal with the given size and command. - /// - /// # Errors - /// - /// Returns an error if the PTY cannot be opened or the command fails to spawn. - /// - /// # Panics - /// - /// Panics if the writer lock is poisoned when the background thread closes it. - pub fn spawn(size: ScreenSize, cmd: CommandBuilder) -> anyhow::Result { - let pty_pair = portable_pty::native_pty_system().openpty(portable_pty::PtySize { - rows: size.rows, - cols: size.cols, - pixel_width: 0, - pixel_height: 0, - })?; - // Create reader BEFORE spawning child to ensure it's ready for data - let reader = pty_pair.master.try_clone_reader()?; - let writer: Arc>>> = - Arc::new(Mutex::new(Some(pty_pair.master.take_writer()?))); - // Spawn child and immediately drop slave to ensure EOF is signaled when child exits - let mut child = pty_pair.slave.spawn_command(cmd)?; - let child_killer = child.clone_killer(); - drop(pty_pair.slave); // Critical: drop slave so EOF is signaled when child exits - let master = pty_pair.master; - let exit_status: Arc> = Arc::new(OnceLock::new()); - - // Background thread: wait for child to exit, set exit status, then close writer to trigger EOF - thread::spawn({ - let writer = Arc::clone(&writer); - let exit_status = Arc::clone(&exit_status); - move || { - // Wait for child and set exit status - if let Ok(status) = child.wait() { - let _ = exit_status.set(status); - } - // Close writer to signal EOF to the reader - *writer.lock().unwrap() = None; - } - }); - - Ok(Self { - master, - parser: vt100::Parser::new_with_callbacks( - size.rows, - size.cols, - 0, - Vt100Callbacks { writer: Arc::clone(&writer) }, - ), - child_killer, - reader, - read_until_buffer: Vec::new(), - writer, - exit_status, - }) +impl Read for PtyReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let n = self.reader.read(buf)?; + if n > 0 { + self.parser.lock().unwrap().process(&buf[..n]); + } + Ok(n) } +} - /// Read until the first occurrence of the expected string is found. - /// Multiple occurrences may be buffered internally. Keep calling with the same string to - /// find subsequent occurrences. - /// - /// However, `screen_contents` will reflect all data, including subsequent occurrences, - /// even before they are consumed by `read_until`. It is designed this way because the - /// screen must always have latest data for proper query responses. - /// - /// # Errors - /// - /// Returns an error if the expected string is not found before EOF or if reading fails. - pub fn read_until(&mut self, expected: &str) -> anyhow::Result<()> { - let expected_bytes = expected.as_bytes(); - - let mut buf = [0u8; 8192]; - - loop { - // look for the expected str in buffer - // There could be buffered occurrences in the first iteration, - // or new data read from the previous iteration. - if let Some(pos) = self - .read_until_buffer - .windows(expected_bytes.len()) - .position(|window| window == expected_bytes) - { - // Consume data in read_until_buffer before and including the expected str - let split_pos = pos + expected_bytes.len(); - self.read_until_buffer.drain(0..split_pos); - return Ok(()); - } - - // Not found yet - read more data - let n = self.reader.read(&mut buf)?; +impl Write for PtyWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut guard = + self.writer.lock().map_err(|e| std::io::Error::other(format!("Poisoned lock: {e}")))?; - if n == 0 { - // EOF - expected string not found - return Err(anyhow::anyhow!("Expected string not found: {expected}")); - } + guard.as_mut().map_or_else( + || Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "Child process has exited")), + |writer| writer.write(buf), + ) + } - let data = &buf[..n]; - // Feed data to parser, which updates screen state and handles control sequence queries. - self.parser.process(data); + fn flush(&mut self) -> std::io::Result<()> { + let mut guard = + self.writer.lock().map_err(|e| std::io::Error::other(format!("Poisoned lock: {e}")))?; - self.read_until_buffer.extend_from_slice(data); - } + guard.as_mut().map_or(Ok(()), Write::flush) } +} - /// Kills the child process. +impl PtyReader { + /// Returns the current terminal screen contents as a string (parsed by the vt100 emulator). /// - /// # Errors + /// # Panics /// - /// Returns an error if the child process cannot be killed. - pub fn kill(&mut self) -> anyhow::Result<()> { - self.child_killer.kill()?; - Ok(()) + /// Panics if the parser lock is poisoned. + #[must_use] + pub fn screen_contents(&self) -> String { + self.parser.lock().unwrap().screen().contents() } - /// Reads all remaining output until the child process exits. + /// Drains and returns all unhandled OSC sequences received since the last call. /// - /// Returns the exit status of the child process. + /// Each entry is a list of byte-vector parameters from a single OSC sequence + /// (`ESC ] param1 ; param2 ; ... ST`). /// - /// # Errors + /// # Panics /// - /// Returns an error if: - /// - Reading from the PTY fails - /// - The exit status is not available (should not happen in normal operation) + /// Panics if the parser lock is poisoned. + #[must_use] + pub fn take_unhandled_osc_sequences(&self) -> VecDeque>> { + std::mem::take(&mut self.parser.lock().unwrap().callbacks_mut().unhandled_osc_sequences) + } + + /// Returns the current cursor position as `(row, col)`, both 0-indexed. /// /// # Panics /// - /// Panics if the writer lock is poisoned. - pub fn read_to_end(&mut self) -> anyhow::Result { - // `read_to_end` will move cursor to the end, so clear any buffered data for `read_until` - self.read_until_buffer.clear(); - - let mut buf = [0u8; 8192]; - // Read all remaining data until EOF - loop { - let n = self.reader.read(&mut buf)?; - self.parser.process(&buf[..n]); - if n == 0 { - break; - } - } - - // Wait for exit status to be set by background thread - let status = self.exit_status.wait().clone(); - - // Close the writer since the child has exited and all output has been consumed. - // This ensures subsequent write() calls fail immediately, rather than racing - // with the background thread which also closes the writer. - *self.writer.lock().unwrap() = None; + /// Panics if the parser lock is poisoned. + #[must_use] + pub fn cursor_position(&self) -> (u16, u16) { + self.parser.lock().unwrap().screen().cursor_position() + } +} - Ok(status) +impl PtyWriter { + /// Returns `true` if the child process write channel has been closed. + /// + /// # Panics + /// + /// Panics if the writer lock is poisoned. + #[must_use] + pub fn is_closed(&self) -> bool { + self.writer.lock().unwrap().is_none() } - /// Writes data to the child process's stdin. + /// Writes `line` followed by a platform-appropriate line ending to the child process. + /// + /// On Unix, appends `\n`. On Windows `ConPTY`, appends `\r\n` for proper line handling. /// /// # Errors /// - /// Returns an error if the child process has already exited or if writing fails. - pub fn write(&self, data: &[u8]) -> anyhow::Result<()> { - // On Windows ConPTY, convert LF to CRLF for proper line handling - #[cfg(target_os = "windows")] - let converted: Vec = { - let mut result = Vec::new(); - for &byte in data { - if byte == b'\n' { - result.push(b'\r'); - result.push(b'\n'); - } else { - result.push(byte); - } - } - result - }; + /// Returns an error if the child process has exited or writing fails. + pub fn write_line(&mut self, line: &[u8]) -> std::io::Result<()> { + self.write_all(line)?; + + #[cfg(not(target_os = "windows"))] + self.write_all(b"\n")?; #[cfg(target_os = "windows")] - let data_to_write: &[u8] = &converted; + self.write_all(b"\r\n")?; - #[cfg(not(target_os = "windows"))] - let data_to_write: &[u8] = data; - - let mut writer_guard = self - .writer - .lock() - .map_err(|e| anyhow::anyhow!("Failed to acquire writer lock: {e}"))?; - - if let Some(writer) = writer_guard.as_mut() { - writer.write_all(data_to_write)?; - writer.flush()?; - Ok(()) - } else { - Err(anyhow::anyhow!("Cannot write: child process has exited")) - } + self.flush() } /// Sends Ctrl+C (SIGINT) to the child process. @@ -256,38 +189,110 @@ impl Terminal { /// # Errors /// /// Returns an error if the child process has already exited or writing fails. - pub fn send_ctrl_c(&self) -> anyhow::Result<()> { - self.write(&[0x03]) + pub fn send_ctrl_c(&mut self) -> std::io::Result<()> { + self.write_all(&[0x03])?; + self.flush() } - /// Clones the child process killer for use from another thread. - #[must_use] - pub fn clone_killer(&self) -> Box { - self.child_killer.clone_killer() + /// Resizes the terminal to the given size. + /// + /// On Unix, delivers SIGWINCH to the child process. On Windows, `ConPTY` resizes synchronously. + /// + /// # Errors + /// + /// Returns an error if the PTY cannot be resized. + /// + /// # Panics + /// + /// Panics if the parser lock is poisoned. + pub fn resize(&self, size: ScreenSize) -> anyhow::Result<()> { + self.master.resize(portable_pty::PtySize { + rows: size.rows, + cols: size.cols, + pixel_width: 0, + pixel_height: 0, + })?; + + self.parser.lock().unwrap().screen_mut().set_size(size.rows, size.cols); + + Ok(()) } +} +impl ChildHandle { + /// Blocks until the child process has exited and returns its exit status. #[must_use] - pub fn screen_contents(&self) -> String { - self.parser.screen().contents() + pub fn wait(&self) -> ExitStatus { + self.exit_status.wait().clone() } - /// Resizes the terminal to the given size. + /// Kills the child process. /// /// # Errors /// - /// Returns an error if the PTY cannot be resized. - pub fn resize(&mut self, size: ScreenSize) -> anyhow::Result<()> { - // Resize the underlying PTY via portable-pty's MasterPty::resize - self.master.resize(portable_pty::PtySize { + /// Returns an error if the child process cannot be killed. + pub fn kill(&mut self) -> anyhow::Result<()> { + self.child_killer.kill()?; + Ok(()) + } +} + +impl Terminal { + /// Spawns a new child process in a headless terminal with the given size and command. + /// + /// # Errors + /// + /// Returns an error if the PTY cannot be opened or the command fails to spawn. + /// + /// # Panics + /// + /// Panics if the writer lock is poisoned when the background thread closes it. + pub fn spawn(size: ScreenSize, cmd: CommandBuilder) -> anyhow::Result { + let pty_pair = portable_pty::native_pty_system().openpty(portable_pty::PtySize { rows: size.rows, cols: size.cols, pixel_width: 0, pixel_height: 0, })?; + // Create reader BEFORE spawning child to ensure it's ready for data + let reader = pty_pair.master.try_clone_reader()?; + let writer: Arc>>> = + Arc::new(Mutex::new(Some(pty_pair.master.take_writer()?))); + // Spawn child and immediately drop slave to ensure EOF is signaled when child exits + let mut child = pty_pair.slave.spawn_command(cmd)?; + let child_killer = child.clone_killer(); + drop(pty_pair.slave); // Critical: drop slave so EOF is signaled when child exits + let master = pty_pair.master; + let exit_status: Arc> = Arc::new(OnceLock::new()); + + // Background thread: wait for child to exit, set exit status, then close writer to trigger EOF + thread::spawn({ + let writer = Arc::clone(&writer); + let exit_status = Arc::clone(&exit_status); + move || { + // Wait for child and set exit status + if let Ok(status) = child.wait() { + let _ = exit_status.set(status); + } + // Close writer to signal EOF to the reader + *writer.lock().unwrap() = None; + } + }); - // Update the vt100 parser's internal screen dimensions - self.parser.screen_mut().set_size(size.rows, size.cols); + let parser = Arc::new(Mutex::new(vt100::Parser::new_with_callbacks( + size.rows, + size.cols, + 0, + Vt100Callbacks { + writer: Arc::clone(&writer), + unhandled_osc_sequences: VecDeque::new(), + }, + ))); - Ok(()) + Ok(Self { + pty_reader: PtyReader { reader, parser: Arc::clone(&parser) }, + pty_writer: PtyWriter { writer, parser, master }, + child_handle: ChildHandle { child_killer, exit_status }, + }) } } diff --git a/crates/pty_terminal/tests/terminal.rs b/crates/pty_terminal/tests/terminal.rs index c0de9be4..76435d91 100644 --- a/crates/pty_terminal/tests/terminal.rs +++ b/crates/pty_terminal/tests/terminal.rs @@ -1,4 +1,7 @@ -use std::io::{IsTerminal, Write, stderr, stdin, stdout}; +use std::{ + io::{BufRead, BufReader, IsTerminal, Read, Write, stderr, stdin, stdout}, + time::{Duration, Instant}, +}; use ntest::timeout; use portable_pty::CommandBuilder; @@ -13,150 +16,15 @@ fn is_terminal() { println!("{} {} {}", stdin().is_terminal(), stdout().is_terminal(), stderr().is_terminal()); })); - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); - let _ = terminal.read_to_end().unwrap(); - let output = terminal.screen_contents(); + let Terminal { mut pty_reader, pty_writer: _pty_writer, child_handle } = + Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + let mut discard = Vec::new(); + pty_reader.read_to_end(&mut discard).unwrap(); + let _ = child_handle.wait(); + let output = pty_reader.screen_contents(); assert_eq!(output.trim(), "true true true"); } -#[test] -#[timeout(5000)] -#[expect(clippy::print_stdout, reason = "subprocess test output")] -fn read_until_single() { - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { - println!("hello world"); - })); - - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); - terminal.read_until("hello").unwrap(); - let _ = terminal.read_to_end().unwrap(); - let output = terminal.screen_contents(); - // After reading until "hello", the buffer should contain " world" - // read_to_end should process the buffered data and continue reading - assert!(output.contains("world")); -} - -#[test] -#[timeout(5000)] -#[expect(clippy::print_stdout, reason = "subprocess test output")] -fn read_until_multiple_sequential() { - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { - print!("first second third"); - let _ = stdout().flush(); - })); - - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); - terminal.read_until("first").unwrap(); - terminal.read_until("second").unwrap(); - terminal.read_until("third").unwrap(); - let _ = terminal.read_to_end().unwrap(); - let output = terminal.screen_contents(); - // All three words should be in the screen - assert!(output.contains("first")); - assert!(output.contains("second")); - assert!(output.contains("third")); -} - -#[test] -#[timeout(5000)] -#[expect(clippy::print_stdout, reason = "subprocess test output")] -fn read_until_not_found() { - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { - print!("hello world"); - let _ = stdout().flush(); - })); - - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); - let result = terminal.read_until("nonexistent"); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Expected string not found")); -} - -#[test] -#[timeout(5000)] -#[expect(clippy::print_stdout, reason = "subprocess test output")] -fn read_until_with_read_to_end() { - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { - print!("prefix middle suffix"); - let _ = stdout().flush(); - })); - - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); - terminal.read_until("middle").unwrap(); - // At this point, " suffix" should be buffered - let _ = terminal.read_to_end().unwrap(); - let output = terminal.screen_contents(); - // The full output should include everything - assert!(output.contains("prefix")); - assert!(output.contains("middle")); - assert!(output.contains("suffix")); -} - -#[test] -#[timeout(5000)] -#[expect(clippy::print_stdout, reason = "subprocess test output")] -fn read_until_boundary_spanning() { - // Test that read_until works when the expected string may span across read() boundaries. - // Boundary spanning is about the reader side: the PTY reader may return partial data even - // from a single write. We print the full string at once because on Windows, ConPTY - // reprocesses output and can insert escape sequences between individually-printed characters. - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { - print!("abcdef"); - let _ = stdout().flush(); - })); - - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); - // Search for a pattern that's likely to span boundaries - terminal.read_until("abcd").unwrap(); - let _ = terminal.read_to_end().unwrap(); - let output = terminal.screen_contents(); - assert!(output.contains("abcdef")); -} - -#[test] -#[timeout(5000)] -#[expect(clippy::print_stdout, reason = "subprocess test output")] -fn read_until_exact_boundary() { - // Test where we search for something at the exact boundary - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { - print!("firstsecond"); - let _ = stdout().flush(); - })); - - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); - // This should find "second" even if "first" was in a previous read - terminal.read_until("second").unwrap(); - let _ = terminal.read_to_end().unwrap(); - let output = terminal.screen_contents(); - assert!(output.contains("first")); - assert!(output.contains("second")); -} - -#[test] -#[timeout(5000)] -#[expect(clippy::print_stdout, reason = "subprocess test output")] -fn read_until_after_read_to_end() { - // Test that read_until works with data that comes after EOF - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { - println!("hello world foo bar"); - })); - - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); - - // Use read_until first to consume part of the data - terminal.read_until("world").unwrap(); - - // Read everything else - let _ = terminal.read_to_end().unwrap(); - let output = terminal.screen_contents(); - assert!(output.contains("hello world foo bar")); - - // After read_to_end, buffer is empty and we're at EOF - // Trying to find anything should fail - let result = terminal.read_until("bar"); - assert!(result.is_err()); -} - #[test] #[timeout(5000)] #[expect(clippy::print_stdout, reason = "subprocess test output")] @@ -172,16 +40,16 @@ fn write_basic_echo() { } })); - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + let Terminal { mut pty_reader, mut pty_writer, child_handle } = + Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); - // Write data to the terminal - terminal.write(b"hello world\n").unwrap(); + pty_writer.write_line(b"hello world").unwrap(); - // Read until we see the echo - terminal.read_until("hello world").unwrap(); - let _ = terminal.read_to_end().unwrap(); + let mut discard = Vec::new(); + pty_reader.read_to_end(&mut discard).unwrap(); + let _ = child_handle.wait(); - let output = terminal.screen_contents(); + let output = pty_reader.screen_contents(); // PTY echoes the input, so we see "hello world\nhello world" assert_eq!(output.trim(), "hello world\nhello world"); } @@ -195,7 +63,7 @@ fn write_multiple_lines() { let stdin = stdin(); let mut stdout = stdout(); for line in stdin.lock().lines().map_while(Result::ok) { - print!("Echo: {line}"); + println!("Echo: {line}"); stdout.flush().unwrap(); if line == "third" { break; @@ -203,21 +71,38 @@ fn write_multiple_lines() { } })); - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); - - terminal.write(b"first\n").unwrap(); - terminal.read_until("Echo: first").unwrap(); - - terminal.write(b"second\n").unwrap(); - terminal.read_until("Echo: second").unwrap(); - - terminal.write(b"third\n").unwrap(); - terminal.read_until("Echo: third").unwrap(); - - let _ = terminal.read_to_end().unwrap(); - let output = terminal.screen_contents(); - // PTY echoes input, so we see both the typed input and the echo response - assert_eq!(output.trim(), "first\nEcho: firstsecond\nEcho: secondthird\nEcho: third"); + let Terminal { mut pty_reader, mut pty_writer, child_handle } = + Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + + pty_writer.write_line(b"first").unwrap(); + { + let mut buf_reader = BufReader::new(&mut pty_reader); + let mut line = Vec::new(); + // Read PTY echo of "first\n" + buf_reader.read_until(b'\n', &mut line).unwrap(); + line.clear(); + // Read child response "Echo: first\n" + buf_reader.read_until(b'\n', &mut line).unwrap(); + } + + pty_writer.write_line(b"second").unwrap(); + { + let mut buf_reader = BufReader::new(&mut pty_reader); + let mut line = Vec::new(); + buf_reader.read_until(b'\n', &mut line).unwrap(); + line.clear(); + buf_reader.read_until(b'\n', &mut line).unwrap(); + } + + pty_writer.write_line(b"third").unwrap(); + + let mut discard = Vec::new(); + pty_reader.read_to_end(&mut discard).unwrap(); + let _ = child_handle.wait(); + + let output = pty_reader.screen_contents(); + // PTY echoes input, then child prints "Echo: {line}\n" for each + assert_eq!(output.trim(), "first\nEcho: first\nsecond\nEcho: second\nthird\nEcho: third"); } #[test] @@ -228,15 +113,23 @@ fn write_after_exit() { print!("exiting"); })); - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + let Terminal { mut pty_reader, mut pty_writer, child_handle } = + Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); // Read all output - this blocks until child exits and EOF is reached - let _ = terminal.read_to_end().unwrap(); - - // The background thread should have set writer to None by now - // since read_to_end only returns after EOF (child exit) - // Writing should fail with either our custom error or an I/O error - let result = terminal.write(b"too late\n"); + let mut discard = Vec::new(); + pty_reader.read_to_end(&mut discard).unwrap(); + let _ = child_handle.wait(); + + // Writer shutdown is done by a background thread after child wait returns. + // Poll briefly for the writer state to flip to closed before asserting write failure. + let deadline = Instant::now() + Duration::from_millis(300); + while !pty_writer.is_closed() { + assert!(Instant::now() <= deadline, "writer did not close after child exit"); + std::thread::yield_now(); + } + + let result = pty_writer.write_all(b"too late\n"); assert!(result.is_err()); } @@ -256,19 +149,25 @@ fn write_interactive_prompt() { stdout.flush().unwrap(); })); - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + let Terminal { mut pty_reader, mut pty_writer, child_handle } = + Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); - // Wait for prompt - terminal.read_until("Name:").unwrap(); + // Wait for prompt "Name: " (read until the space after colon) + { + let mut buf_reader = BufReader::new(&mut pty_reader); + let mut buf = Vec::new(); + buf_reader.read_until(b' ', &mut buf).unwrap(); + assert!(String::from_utf8_lossy(&buf).contains("Name:")); + } // Send response - terminal.write(b"Alice\n").unwrap(); + pty_writer.write_line(b"Alice").unwrap(); - // Wait for greeting - terminal.read_until("Hello, Alice").unwrap(); + let mut discard = Vec::new(); + pty_reader.read_to_end(&mut discard).unwrap(); + let _ = child_handle.wait(); - let _ = terminal.read_to_end().unwrap(); - let output = terminal.screen_contents(); + let output = pty_reader.screen_contents(); assert_eq!(output.trim(), "Name: Alice\nHello, Alice"); } @@ -341,24 +240,32 @@ fn resize_terminal() { stdout().flush().unwrap(); })); - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + let Terminal { mut pty_reader, mut pty_writer, child_handle: _ } = + Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); - // Read initial size - terminal.read_until("initial: 80 80").unwrap(); + // Wait for initial size line (synchronize before resizing) + { + let mut buf_reader = BufReader::new(&mut pty_reader); + let mut line = Vec::new(); + buf_reader.read_until(b'\n', &mut line).unwrap(); + assert!(String::from_utf8_lossy(&line).contains("initial: 80 80")); + } // Perform resize - terminal.resize(ScreenSize { rows: 40, cols: 40 }).unwrap(); + pty_writer.resize(ScreenSize { rows: 40, cols: 40 }).unwrap(); // Signal the process to continue and check resize - terminal.write(b"\n").unwrap(); + pty_writer.write_line(b"").unwrap(); - // Verify resize was detected (SIGWINCH on Unix, synchronous on Windows) - terminal.read_until("RESIZE_DETECTED").unwrap(); + // Read remaining output + let mut discard = Vec::new(); + pty_reader.read_to_end(&mut discard).unwrap(); + let output = pty_reader.screen_contents(); + // Verify resize was detected (SIGWINCH on Unix, synchronous on Windows) + assert!(output.contains("RESIZE_DETECTED")); // Verify new size is correct - terminal.read_until("resized: 40 40").unwrap(); - - let _ = terminal.read_to_end().unwrap(); + assert!(output.contains("resized: 40 40")); } #[test] @@ -404,18 +311,27 @@ fn send_ctrl_c_interrupts_process() { } })); - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + let Terminal { mut pty_reader, mut pty_writer, child_handle: _ } = + Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); // Wait for process to be ready - terminal.read_until("ready").unwrap(); + { + let mut buf_reader = BufReader::new(&mut pty_reader); + let mut line = Vec::new(); + buf_reader.read_until(b'\n', &mut line).unwrap(); + assert!(String::from_utf8_lossy(&line).contains("ready")); + } // Send Ctrl+C - terminal.send_ctrl_c().unwrap(); + pty_writer.send_ctrl_c().unwrap(); - // Verify interruption was detected - terminal.read_until("INTERRUPTED").unwrap(); + // Read remaining output + let mut discard = Vec::new(); + pty_reader.read_to_end(&mut discard).unwrap(); - let _ = terminal.read_to_end().unwrap(); + let output = pty_reader.screen_contents(); + // Verify interruption was detected + assert!(output.contains("INTERRUPTED")); } #[test] @@ -426,8 +342,11 @@ fn read_to_end_returns_exit_status_success() { println!("success"); })); - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); - let status = terminal.read_to_end().unwrap(); + let Terminal { mut pty_reader, pty_writer: _pty_writer, child_handle } = + Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + let mut discard = Vec::new(); + pty_reader.read_to_end(&mut discard).unwrap(); + let status = child_handle.wait(); assert!(status.success()); assert_eq!(status.exit_code(), 0); } @@ -439,8 +358,11 @@ fn read_to_end_returns_exit_status_nonzero() { std::process::exit(42); })); - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); - let status = terminal.read_to_end().unwrap(); + let Terminal { mut pty_reader, pty_writer: _pty_writer, child_handle } = + Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + let mut discard = Vec::new(); + pty_reader.read_to_end(&mut discard).unwrap(); + let status = child_handle.wait(); assert!(!status.success()); assert_eq!(status.exit_code(), 42); } diff --git a/crates/pty_terminal_test/.clippy.toml b/crates/pty_terminal_test/.clippy.toml new file mode 120000 index 00000000..c7929b36 --- /dev/null +++ b/crates/pty_terminal_test/.clippy.toml @@ -0,0 +1 @@ +../../.non-vite.clippy.toml \ No newline at end of file diff --git a/crates/pty_terminal_test/Cargo.toml b/crates/pty_terminal_test/Cargo.toml new file mode 100644 index 00000000..f50a99e4 --- /dev/null +++ b/crates/pty_terminal_test/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "pty_terminal_test" +version = "0.0.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[dependencies] +anyhow = { workspace = true } +portable-pty = { workspace = true } +pty_terminal = { workspace = true } +pty_terminal_test_client = { workspace = true } + +[dev-dependencies] +crossterm = { workspace = true } +ctor = { workspace = true } +ntest = "0.9.5" +pty_terminal_test_client = { workspace = true, features = ["testing"] } +subprocess_test = { workspace = true, features = ["portable-pty"] } + +[lints] +workspace = true diff --git a/crates/pty_terminal_test/README.md b/crates/pty_terminal_test/README.md new file mode 100644 index 00000000..ab97238c --- /dev/null +++ b/crates/pty_terminal_test/README.md @@ -0,0 +1,86 @@ +# pty_terminal_test + +`pty_terminal_test` is a thin test helper on top of `pty_terminal` for writing +integration tests against interactive CLI processes. + +It provides: + +- `TestTerminal::spawn(...)` to start a child process in a PTY. +- `writer` (`PtyWriter`) to send input to the child. +- `reader` (`Reader`) to wait for milestones and collect final exit status. + +## Why this crate exists + +Reading raw PTY bytes is often not enough for deterministic interactive tests. +You usually need explicit synchronization points from the child process. + +This crate solves that by pairing: + +- `pty_terminal_test_client::mark_milestone("name")` in the child process, and +- `reader.expect_milestone("name")` in the test process. + +## Core API + +```rust +use portable_pty::CommandBuilder; +use pty_terminal::geo::ScreenSize; +use pty_terminal_test::TestTerminal; + +let cmd = CommandBuilder::from("your-binary-or-subprocess-test-command"); +let TestTerminal { mut writer, mut reader, child_handle: _ } = + TestTerminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd)?; + +// Wait until child reaches a known point. +let _screen = reader.expect_milestone("ready"); + +// Interact with child. +writer.write_all(b"q")?; +writer.flush()?; + +// Wait for completion. +let status = reader.wait_for_exit(); +assert!(status.success()); +# Ok::<(), Box>(()) +``` + +## Milestone protocol + +Milestones are encoded as an OSC 8 hyperlink: + +- open: `ESC ] 8 ; ; https://milestone.invalid/ ESC \` +- hypertext: zero-width space (`U+200B`) +- close: `ESC ] 8 ; ; ESC \` + +`Reader::expect_milestone` works like this: + +1. Drain parsed unhandled OSC sequences from `PtyReader`. +2. Decode OSC 8 URI payload back into milestone name. +3. If no match yet, continue reading from PTY and repeat. +4. On match, return current `screen_contents()`. + +The helper strips the protocol's zero-width space from returned screen text. + +## Cross-platform behavior + +The OSC 8 + zero-width anchor approach is used because it works across Unix and +Windows ConPTY in this project. In particular, zero-length hyperlink opens can +be lost on some Windows output paths, so the zero-width anchor is intentional. + +## Typical test pattern + +In the child process: + +```rust +pty_terminal_test_client::mark_milestone("ready"); +// do work... +pty_terminal_test_client::mark_milestone("after-input"); +``` + +In the parent test: + +```rust +let _ = reader.expect_milestone("ready"); +writer.write_all(b"input")?; +writer.flush()?; +let screen = reader.expect_milestone("after-input"); +``` diff --git a/crates/pty_terminal_test/src/lib.rs b/crates/pty_terminal_test/src/lib.rs new file mode 100644 index 00000000..3f53c69d --- /dev/null +++ b/crates/pty_terminal_test/src/lib.rs @@ -0,0 +1,102 @@ +use std::io::{BufReader, Read}; + +pub use portable_pty::CommandBuilder; +use pty_terminal::terminal::{PtyReader, Terminal}; +pub use pty_terminal::{ + ExitStatus, + geo::ScreenSize, + terminal::{ChildHandle, PtyWriter}, +}; + +const MILESTONE_HYPERTEXT: char = '\u{200b}'; + +/// A test-oriented terminal that provides milestone-based synchronization. +/// +/// Wraps a PTY terminal, splitting it into a [`PtyWriter`] for sending input +/// and a [`Reader`] that can wait for named milestones emitted by the child +/// process via [`pty_terminal_test_client::mark_milestone`]. +pub struct TestTerminal { + pub writer: PtyWriter, + pub reader: Reader, + pub child_handle: ChildHandle, +} + +/// The read half of a test terminal, wrapping [`PtyReader`] with milestone support. +pub struct Reader { + pty: BufReader, + child_handle: ChildHandle, +} + +impl TestTerminal { + /// Spawns a new child process in a test terminal. + /// + /// # Errors + /// + /// Returns an error if the PTY cannot be opened or the command fails to spawn. + pub fn spawn(size: ScreenSize, cmd: CommandBuilder) -> anyhow::Result { + let Terminal { pty_reader, pty_writer, child_handle } = Terminal::spawn(size, cmd)?; + Ok(Self { + writer: pty_writer, + reader: Reader { pty: BufReader::new(pty_reader), child_handle: child_handle.clone() }, + child_handle, + }) + } +} + +impl Reader { + /// Returns terminal screen contents with milestone hyperlink text removed. + #[must_use] + pub fn screen_contents(&self) -> String { + let mut contents = self.pty.get_ref().screen_contents(); + contents.retain(|ch| ch != MILESTONE_HYPERTEXT); + contents + } + + /// Reads from the PTY until a milestone with the given name is encountered. + /// + /// Returns the terminal screen contents at the moment the milestone is detected. + /// + /// Milestones use a uniform protocol across platforms: the milestone name + /// is encoded in an OSC 8 hyperlink URI. We parse unhandled OSC sequences + /// from the VT parser state (instead of raw byte matching), then decode the + /// milestone URI payload. The zero-width milestone hyperlink anchor is + /// stripped from returned screen contents. + /// + /// # Panics + /// + /// Panics if the child process exits (EOF) before the named milestone is received, + /// or if a read error occurs. + #[must_use] + pub fn expect_milestone(&mut self, name: &str) -> String { + let mut buf = [0u8; 4096]; + + loop { + let found = self + .pty + .get_ref() + .take_unhandled_osc_sequences() + .into_iter() + .filter_map(|params| { + pty_terminal_test_client::decode_milestone_from_osc8_params(¶ms) + }) + .any(|decoded| decoded == name); + if found { + return self.screen_contents(); + } + + let n = self.pty.read(&mut buf).expect("PTY read failed"); + assert!(n > 0, "EOF reached before milestone '{name}'"); + } + } + + /// Reads all remaining PTY output until the child exits, then returns the exit status. + /// + /// # Panics + /// + /// Panics if reading from the PTY fails. + pub fn wait_for_exit(&mut self) -> ExitStatus { + let mut discard = Vec::new(); + self.pty.read_to_end(&mut discard).expect("PTY read_to_end failed"); + self.child_handle.wait() + } +} diff --git a/crates/pty_terminal_test/tests/milestone.rs b/crates/pty_terminal_test/tests/milestone.rs new file mode 100644 index 00000000..234519bf --- /dev/null +++ b/crates/pty_terminal_test/tests/milestone.rs @@ -0,0 +1,127 @@ +use std::io::Write; + +use ntest::timeout; +use portable_pty::CommandBuilder; +use pty_terminal::geo::ScreenSize; +use pty_terminal_test::TestTerminal; +use subprocess_test::command_for_fn; + +#[test] +#[timeout(5000)] +fn milestone_raw_mode_keystrokes() { + let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { + use std::io::{Read, Write, stdout}; + + // Enable raw mode (cross-platform via crossterm) + crossterm::terminal::enable_raw_mode().unwrap(); + + // Signal that raw mode is ready + pty_terminal_test_client::mark_milestone("ready"); + + let mut stdin = std::io::stdin(); + let mut stdout = stdout(); + let mut byte = [0u8; 1]; + + loop { + stdin.read_exact(&mut byte).unwrap(); + let ch = byte[0] as char; + + // Clear screen and print the keystroke at top-left + write!(stdout, "\x1b[2J\x1b[H{ch}").unwrap(); + stdout.flush().unwrap(); + + pty_terminal_test_client::mark_milestone("keystroke"); + + if ch == 'q' { + break; + } + } + + crossterm::terminal::disable_raw_mode().unwrap(); + })); + + let TestTerminal { mut writer, mut reader, child_handle: _ } = + TestTerminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + + // Wait for the subprocess to be ready + let _ = reader.expect_milestone("ready"); + + // Write 'a', expect keystroke, verify screen + writer.write_all(b"a").unwrap(); + writer.flush().unwrap(); + let screen = reader.expect_milestone("keystroke"); + assert_eq!(screen.trim(), "a"); + + // Write 'b', expect keystroke, verify screen + writer.write_all(b"b").unwrap(); + writer.flush().unwrap(); + let screen = reader.expect_milestone("keystroke"); + assert_eq!(screen.trim(), "b"); + + // Write 'c', expect keystroke, verify screen + writer.write_all(b"c").unwrap(); + writer.flush().unwrap(); + let screen = reader.expect_milestone("keystroke"); + assert_eq!(screen.trim(), "c"); + + // Write 'q' to quit and wait for the child to exit + writer.write_all(b"q").unwrap(); + writer.flush().unwrap(); + let status = reader.wait_for_exit(); + assert!(status.success()); +} + +/// Verifies that the non-visual milestone fence in `mark_milestone` does not +/// pollute `screen_contents()`. The subprocess appends characters without +/// clearing the screen, so any leftover space would appear between them. +#[test] +#[timeout(5000)] +fn milestone_does_not_pollute_screen() { + let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { + use std::io::{Read, Write, stdout}; + + crossterm::terminal::enable_raw_mode().unwrap(); + pty_terminal_test_client::mark_milestone("ready"); + + let mut stdin = std::io::stdin(); + let mut stdout = stdout(); + let mut byte = [0u8; 1]; + + loop { + stdin.read_exact(&mut byte).unwrap(); + let ch = byte[0] as char; + + // Append the character without clearing the screen + write!(stdout, "{ch}").unwrap(); + stdout.flush().unwrap(); + + pty_terminal_test_client::mark_milestone("keystroke"); + + if ch == 'q' { + break; + } + } + + crossterm::terminal::disable_raw_mode().unwrap(); + })); + + let TestTerminal { mut writer, mut reader, child_handle: _ } = + TestTerminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + + let _ = reader.expect_milestone("ready"); + + writer.write_all(b"a").unwrap(); + writer.flush().unwrap(); + let screen = reader.expect_milestone("keystroke"); + assert_eq!(screen.trim(), "a"); + + writer.write_all(b"b").unwrap(); + writer.flush().unwrap(); + let screen = reader.expect_milestone("keystroke"); + assert_eq!(screen.trim(), "ab"); + + writer.write_all(b"q").unwrap(); + writer.flush().unwrap(); + let status = reader.wait_for_exit(); + assert!(status.success()); +} diff --git a/crates/pty_terminal_test_client/.clippy.toml b/crates/pty_terminal_test_client/.clippy.toml new file mode 120000 index 00000000..c7929b36 --- /dev/null +++ b/crates/pty_terminal_test_client/.clippy.toml @@ -0,0 +1 @@ +../../.non-vite.clippy.toml \ No newline at end of file diff --git a/crates/pty_terminal_test_client/Cargo.toml b/crates/pty_terminal_test_client/Cargo.toml new file mode 100644 index 00000000..497478c4 --- /dev/null +++ b/crates/pty_terminal_test_client/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "pty_terminal_test_client" +version = "0.0.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[features] +default = [] +testing = [] + +[lints] +workspace = true diff --git a/crates/pty_terminal_test_client/README.md b/crates/pty_terminal_test_client/README.md new file mode 100644 index 00000000..8fb964a3 --- /dev/null +++ b/crates/pty_terminal_test_client/README.md @@ -0,0 +1,11 @@ +# pty_terminal_test_client + +`pty_terminal_test_client` is the child-side helper used with +`pty_terminal_test`. + +It provides `mark_milestone("name")`, which emits milestone markers from the +subprocess so the parent test can synchronize on them. + +Reader-side behavior and protocol details are documented in: + +- `crates/pty_terminal_test/README.md` diff --git a/crates/pty_terminal_test_client/src/lib.rs b/crates/pty_terminal_test_client/src/lib.rs new file mode 100644 index 00000000..921f37ab --- /dev/null +++ b/crates/pty_terminal_test_client/src/lib.rs @@ -0,0 +1,107 @@ +/// Prefix for hyperlink URI payload that carries milestone data. +const MILESTONE_URI_PREFIX: &str = "https://milestone.invalid/"; +/// Terminator for OSC sequences using ST (`ESC \`). +const OSC_ST: &str = "\x1b\\"; +/// Invisible hyperlink text anchor. +const MILESTONE_HYPERTEXT: &str = "\u{200b}"; +/// OSC 8 close sequence. +pub const MILESTONE_FENCE: &[u8] = b"\x1b]8;;\x1b\\"; + +/// Builds an OSC 8 marker with milestone name encoded in the hyperlink URI. +/// +/// Format: +/// `OSC 8 ; ; https://milestone.invalid/ ST OSC 8 ; ; ST`. +#[must_use] +pub fn encoded_milestone(name: &str) -> Vec { + use std::fmt::Write as _; + + let mut hex = String::with_capacity(name.len() * 2); + for &byte in name.as_bytes() { + write!(&mut hex, "{byte:02x}").unwrap(); + } + + let mut seq = String::new(); + write!(&mut seq, "\x1b]8;;{MILESTONE_URI_PREFIX}{hex}{OSC_ST}").unwrap(); + seq.push_str(MILESTONE_HYPERTEXT); + write!(&mut seq, "\x1b]8;;{OSC_ST}").unwrap(); + seq.into_bytes() +} + +const fn decode_hex_nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +/// Decodes a milestone name from OSC 8 parameters if present. +/// +/// Expects VT parser parameters for OSC 8 in the form: +/// - open: `["8", "", ""]` +/// - close: `["8", "", ""]` +/// +/// Returns `Some(name)` only when the URI uses the milestone prefix and the +/// suffix is valid hex-encoded UTF-8. +#[must_use] +pub fn decode_milestone_from_osc8_params(params: &[Vec]) -> Option { + if params.first().is_none_or(|p| p.as_slice() != b"8") { + return None; + } + + let uri = params.get(2)?.as_slice(); + let encoded = uri.strip_prefix(MILESTONE_URI_PREFIX.as_bytes())?; + if encoded.is_empty() || encoded.len() % 2 != 0 { + return None; + } + + let mut bytes = Vec::with_capacity(encoded.len() / 2); + for pair in encoded.chunks_exact(2) { + let high = decode_hex_nibble(pair[0])?; + let low = decode_hex_nibble(pair[1])?; + bytes.push((high << 4) | low); + } + + String::from_utf8(bytes).ok() +} + +/// Emits a milestone marker as OSC 8 hyperlink metadata. +/// +/// The child process calls this to signal it has reached a named synchronization +/// point. The test harness (via `pty_terminal_test::Reader::expect_milestone`) +/// detects this marker and returns the screen contents at that point. +/// +/// On Windows, `ConPTY` passes control sequences directly to the +/// output pipe (synchronous, inline with input processing), while rendered +/// character output is generated asynchronously by a separate output thread +/// that polls the console buffer. This means the marker can arrive at the +/// reader before preceding character output has been emitted. +/// +/// Milestones include a zero-width hyperlink anchor (`U+200B`) before closing. +/// This keeps the hyperlink metadata observable in `ConPTY` output paths that can +/// drop zero-length hyperlinks. +/// +/// When the `testing` feature is disabled, this is a no-op. +/// +/// # Panics +/// +/// Panics if writing to stdout fails. +#[cfg(feature = "testing")] +pub fn mark_milestone(name: &str) { + use std::io::{Write, stdout}; + + let milestone = encoded_milestone(name); + let mut stdout = stdout(); + // Flush prior output, then emit milestone sequence. + stdout.flush().unwrap(); + stdout.write_all(&milestone).unwrap(); + + stdout.flush().unwrap(); +} + +/// Emits a milestone marker as a private OSC escape sequence. +/// +/// When the `testing` feature is disabled, this is a no-op. +#[cfg(not(feature = "testing"))] +pub const fn mark_milestone(_name: &str) {} diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml index c4ca6962..6163a3c4 100644 --- a/crates/vite_task_bin/Cargo.toml +++ b/crates/vite_task_bin/Cargo.toml @@ -14,7 +14,9 @@ path = "src/main.rs" anyhow = { workspace = true } async-trait = { workspace = true } clap = { workspace = true, features = ["derive"] } +crossterm = { workspace = true } jsonc-parser = { workspace = true } +pty_terminal_test_client = { workspace = true } rustc-hash = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["full"] } @@ -24,10 +26,12 @@ vite_task = { workspace = true } which = { workspace = true } [dev-dependencies] -copy_dir = { workspace = true } cow-utils = { workspace = true } +cp_r = { workspace = true } insta = { workspace = true, features = ["glob", "json", "redactions", "filters", "ron"] } +pathdiff = { workspace = true } pty_terminal = { workspace = true } +pty_terminal_test = { workspace = true } regex = { workspace = true } serde = { workspace = true, features = ["derive", "rc"] } tempfile = { workspace = true } diff --git a/crates/vite_task_bin/src/lib.rs b/crates/vite_task_bin/src/lib.rs index 392a6df4..20a7ed91 100644 --- a/crates/vite_task_bin/src/lib.rs +++ b/crates/vite_task_bin/src/lib.rs @@ -84,6 +84,7 @@ pub enum Args { name: Str, value: Str, }, + Interact, #[command(flatten)] Task(Command), } @@ -130,6 +131,7 @@ impl vite_task::CommandHandler for CommandHandler { envs: Arc::new(envs), })) } + Args::Interact => Ok(HandledCommand::Verbatim), Args::Task(cli_command) => Ok(HandledCommand::ViteTaskCommand(cli_command)), } } diff --git a/crates/vite_task_bin/src/main.rs b/crates/vite_task_bin/src/main.rs index 4b9a5250..9cd4f95f 100644 --- a/crates/vite_task_bin/src/main.rs +++ b/crates/vite_task_bin/src/main.rs @@ -1,4 +1,8 @@ -use std::{process::ExitCode, sync::Arc}; +use std::{ + io::{IsTerminal, Read, Write}, + process::ExitCode, + sync::Arc, +}; use clap::Parser; use vite_str::Str; @@ -21,6 +25,7 @@ async fn run() -> anyhow::Result { let mut owned_callbacks = OwnedSessionCallbacks::default(); let session = Session::init(owned_callbacks.as_callbacks())?; match args { + Args::Interact => run_interact(), Args::Task(command) => { #[expect(clippy::large_futures, reason = "session.main produces a large future")] { @@ -62,3 +67,155 @@ async fn run() -> anyhow::Result { } } } + +fn write_line(stdout: &mut impl Write, line: &[u8]) -> anyhow::Result<()> { + stdout.write_all(line)?; + stdout.write_all(b"\r\n")?; + stdout.flush()?; + Ok(()) +} + +fn write_milestone(stdout: &mut impl Write, name: &str) -> anyhow::Result<()> { + stdout.write_all(&pty_terminal_test_client::encoded_milestone(name))?; + stdout.flush()?; + Ok(()) +} + +struct RawModeGuard { + enabled: bool, +} + +impl RawModeGuard { + fn new(enabled: bool) -> anyhow::Result { + if enabled { + crossterm::terminal::enable_raw_mode()?; + } + Ok(Self { enabled }) + } + + fn disable(&mut self) -> anyhow::Result<()> { + if self.enabled { + crossterm::terminal::disable_raw_mode()?; + self.enabled = false; + } + Ok(()) + } +} + +impl Drop for RawModeGuard { + fn drop(&mut self) { + if self.enabled { + let _ = crossterm::terminal::disable_raw_mode(); + } + } +} + +fn run_interact() -> anyhow::Result { + let stdin_is_tty = std::io::stdin().is_terminal(); + let enable_raw_mode = if cfg!(windows) { true } else { stdin_is_tty }; + let mut raw_mode = RawModeGuard::new(enable_raw_mode)?; + + let mut stdin = std::io::stdin(); + let mut stdout = std::io::stdout(); + let mut text_buffer = Vec::::new(); + let mut ansi_escape_pending = false; + let mut ansi_csi_pending = false; + let mut windows_extended_key_pending = false; + + write_line(&mut stdout, b"START")?; + write_milestone(&mut stdout, "ready")?; + + loop { + let mut byte = [0u8; 1]; + let read_count = stdin.read(&mut byte)?; + if read_count == 0 { + break; + } + + let byte = byte[0]; + if ansi_escape_pending { + ansi_escape_pending = false; + + if byte == b'[' || byte == b'O' { + ansi_csi_pending = true; + continue; + } + } + + if ansi_csi_pending { + ansi_csi_pending = false; + + if byte == b'A' { + write_milestone(&mut stdout, "after-up")?; + continue; + } + + if byte == b'B' { + write_milestone(&mut stdout, "after-down")?; + continue; + } + } + + if windows_extended_key_pending { + windows_extended_key_pending = false; + + if byte == 72 { + write_milestone(&mut stdout, "after-up")?; + continue; + } + + if byte == 80 { + write_milestone(&mut stdout, "after-down")?; + continue; + } + } + + if byte == 0x1b { + ansi_escape_pending = true; + continue; + } + + if byte == 0x00 || byte == 0xe0 { + windows_extended_key_pending = true; + continue; + } + + if byte == b'\r' { + if text_buffer.is_empty() { + write_line(&mut stdout, b"KEY:ENTER")?; + raw_mode.disable()?; + write_line(&mut stdout, b"DONE")?; + write_milestone(&mut stdout, "after-enter")?; + return Ok(ExitStatus::SUCCESS); + } + + stdout.write_all(b"LINE:")?; + stdout.write_all(&text_buffer)?; + stdout.write_all(b"\r\n")?; + stdout.flush()?; + text_buffer.clear(); + write_milestone(&mut stdout, "after-line")?; + continue; + } + + if byte == b'\n' { + if !text_buffer.is_empty() { + stdout.write_all(b"LINE:")?; + stdout.write_all(&text_buffer)?; + stdout.write_all(b"\r\n")?; + stdout.flush()?; + text_buffer.clear(); + write_milestone(&mut stdout, "after-line")?; + } + continue; + } + + text_buffer.push(byte); + stdout.write_all(b"CHAR:")?; + stdout.write_all(&[byte])?; + stdout.write_all(b"\r\n")?; + stdout.flush()?; + } + + Ok(ExitStatus::SUCCESS) +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/README.md b/crates/vite_task_bin/tests/e2e_snapshots/README.md index 5c324416..0863859f 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/README.md +++ b/crates/vite_task_bin/tests/e2e_snapshots/README.md @@ -17,17 +17,37 @@ Each fixture in `fixtures/` is a self-contained workspace. Tests are defined in [[e2e]] name = "descriptive test name" steps = [ - "vite build", - "vite build", # second run to test caching + "vp run build", + "vp run build", # second run to test caching ] ``` +Steps also support an object form with interactions: + +```toml +[[e2e]] +name = "interactive step" +steps = [ + { command = "vp interact", interactions = [{ expect-milestone = "ready" }, { write = "hello" }, { write-line = "world" }, { write-key = "up" }, { write-key = "down" }, { write-key = "enter" }] }, + "echo -n | node check-stdin.js", +] +``` + +Notes: + +- String steps are shorthand for `{ command = "..." }`. +- `write-key` accepts `up`, `down`, and `enter`. +- Snapshots include every interaction line, and each `expect-milestone` records the screen at that point. +- For stdin pipe scenarios, write the step command with shell piping, for example: `echo -n | command`. + The test runner: 1. Copies the fixture to a temp directory 2. Executes each step using `/bin/sh` (Unix) or `bash` (Windows) -3. Captures stdout/stderr and exit codes -4. Compares against snapshot in `fixtures//snapshots/` +3. Runs each step in PTY mode (`TestTerminal`) +4. Applies configured interactions in order for PTY steps +5. Captures output and exit codes +6. Compares against snapshot in `fixtures//snapshots/` ## Adding a new test diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/package.json new file mode 100644 index 00000000..cdef0840 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/package.json @@ -0,0 +1,4 @@ +{ + "name": "interactions-no-vp", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots.toml new file mode 100644 index 00000000..22fd775a --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots.toml @@ -0,0 +1,6 @@ +[[e2e]] +name = "interactions without vp" +steps = [ + { command = "vp interact", interactions = [{ "expect-milestone" = "ready" }, { "write" = "hello" }, { "write-line" = "hello" }, { "expect-milestone" = "after-line" }, { "write-key" = "up" }, { "write-key" = "down" }, { "write" = "x" }, { "write-key" = "enter" }, { "expect-milestone" = "after-line" }, { "write-key" = "enter" }, { "expect-milestone" = "after-enter" }] }, + "echo -n | node -e \"console.log('PIPE_STDIN_IS_TTY:' + String(Boolean(process.stdin.isTTY)))\"", +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots/interactions without vp.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots/interactions without vp.snap new file mode 100644 index 00000000..91a74e97 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots/interactions without vp.snap @@ -0,0 +1,77 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp interact +@ expect-milestone: ready +START +@ write: hello +@ write-line: hello +@ expect-milestone: after-line +START +CHAR:h +CHAR:e +CHAR:l +CHAR:l +CHAR:o +CHAR:h +CHAR:e +CHAR:l +CHAR:l +CHAR:o +LINE:hellohello +@ write-key: up +@ write-key: down +@ write: x +@ write-key: enter +@ expect-milestone: after-line +START +CHAR:h +CHAR:e +CHAR:l +CHAR:l +CHAR:o +CHAR:h +CHAR:e +CHAR:l +CHAR:l +CHAR:o +LINE:hellohello +CHAR:x +LINE:x +@ write-key: enter +@ expect-milestone: after-enter +START +CHAR:h +CHAR:e +CHAR:l +CHAR:l +CHAR:o +CHAR:h +CHAR:e +CHAR:l +CHAR:l +CHAR:o +LINE:hellohello +CHAR:x +LINE:x +KEY:ENTER +DONE +START +CHAR:h +CHAR:e +CHAR:l +CHAR:l +CHAR:o +CHAR:h +CHAR:e +CHAR:l +CHAR:l +CHAR:o +LINE:hellohello +CHAR:x +LINE:x +KEY:ENTER +DONE +> echo -n | node -e "console.log('PIPE_STDIN_IS_TTY:' + String(Boolean(process.stdin.isTTY)))" +PIPE_STDIN_IS_TTY:false diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 717dcc18..3581c239 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -3,27 +3,29 @@ mod redact; use std::{ env::{self, join_paths, split_paths}, ffi::OsStr, - sync::{Arc, mpsc}, + io::Write, + sync::{Arc, Mutex, mpsc}, time::Duration, }; -use copy_dir::copy_dir; -use pty_terminal::{ - ExitStatus, - geo::ScreenSize, - terminal::{CommandBuilder, Terminal}, -}; +use cp_r::CopyOptions; +use pathdiff::diff_paths; +use pty_terminal::{geo::ScreenSize, terminal::CommandBuilder}; +use pty_terminal_test::TestTerminal; use redact::redact_e2e_output; use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf}; use vite_str::Str; use vite_workspace::find_workspace_root; /// Timeout for each step in e2e tests -const STEP_TIMEOUT: Duration = Duration::from_secs(10); +const STEP_TIMEOUT: Duration = Duration::from_secs(20); /// Screen size for the PTY terminal. Large enough to avoid line wrapping. const SCREEN_SIZE: ScreenSize = ScreenSize { rows: 500, cols: 500 }; +const COMPILE_TIME_VP_PATH: &str = env!("CARGO_BIN_EXE_vp"); +const COMPILE_TIME_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR"); + /// Get the shell executable for running e2e test steps. /// On Unix, uses /bin/sh. /// On Windows, uses BASH env var or falls back to Git Bash. @@ -54,9 +56,130 @@ fn get_shell_exe() -> std::path::PathBuf { } } +#[expect( + clippy::disallowed_types, + reason = "PathBuf required for compile-time/runtime vp path remapping" +)] +fn resolve_runtime_vp_path() -> AbsolutePathBuf { + let compile_time_vp = std::path::PathBuf::from(COMPILE_TIME_VP_PATH); + let compile_time_manifest = std::path::PathBuf::from(COMPILE_TIME_MANIFEST_DIR); + let runtime_manifest = + std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + let compile_time_repo_root = compile_time_manifest.parent().unwrap().parent().unwrap(); + let runtime_repo_root = runtime_manifest.parent().unwrap().parent().unwrap(); + + let relative_vp = diff_paths(&compile_time_vp, compile_time_repo_root).unwrap_or_else(|| { + panic!( + "Failed to diff vp path. vp={} repo_root={}", + compile_time_vp.display(), + compile_time_repo_root.display(), + ) + }); + let runtime_vp = runtime_repo_root.join(&relative_vp); + + assert!( + runtime_vp.exists(), + "Remapped vp path does not exist: {} (relative: {})", + runtime_vp.display(), + relative_vp.display(), + ); + + AbsolutePathBuf::new(runtime_vp).unwrap() +} + +#[derive(serde::Deserialize, Debug)] +#[serde(untagged)] +enum Step { + Command(Str), + Detailed(StepConfig), +} + #[derive(serde::Deserialize, Debug)] -#[serde(transparent)] -struct Step(Str); +#[serde(deny_unknown_fields)] +struct StepConfig { + command: Str, + #[serde(default)] + interactions: Vec, +} + +impl Step { + fn command(&self) -> &str { + match self { + Self::Command(command) => command.as_str(), + Self::Detailed(config) => config.command.as_str(), + } + } + + fn interactions(&self) -> &[Interaction] { + match self { + Self::Command(_) => &[], + Self::Detailed(config) => &config.interactions, + } + } +} + +#[derive(serde::Deserialize, Debug, Clone)] +#[serde(untagged)] +enum Interaction { + ExpectMilestone(ExpectMilestoneInteraction), + Write(WriteInteraction), + WriteLine(WriteLineInteraction), + WriteKey(WriteKeyInteraction), +} + +#[derive(serde::Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +struct ExpectMilestoneInteraction { + #[serde(rename = "expect-milestone")] + expect_milestone: Str, +} + +#[derive(serde::Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +struct WriteInteraction { + write: Str, +} + +#[derive(serde::Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +struct WriteLineInteraction { + #[serde(rename = "write-line")] + write_line: Str, +} + +#[derive(serde::Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +struct WriteKeyInteraction { + #[serde(rename = "write-key")] + write_key: WriteKey, +} + +#[derive(serde::Deserialize, Debug, Clone, Copy)] +#[serde(rename_all = "lowercase")] +enum WriteKey { + Up, + Down, + Enter, +} + +impl WriteKey { + const fn as_str(self) -> &'static str { + match self { + Self::Up => "up", + Self::Down => "down", + Self::Enter => "enter", + } + } + + const fn bytes(self) -> &'static [u8] { + match self { + Self::Up => b"\x1b[A", + Self::Down => b"\x1b[B", + Self::Enter => b"\r", + } + } +} #[derive(serde::Deserialize, Debug)] struct E2e { @@ -102,7 +225,7 @@ fn run_case(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, filter: Optio } enum TerminationState { - Exited(ExitStatus), + Exited(i64), TimedOut, } @@ -117,7 +240,7 @@ enum TerminationState { fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture_name: &str) { // Copy the case directory to a temporary directory to avoid discovering workspace outside of the test case. let stage_path = tmpdir.join(fixture_name); - copy_dir(fixture_path, &stage_path).unwrap(); + CopyOptions::new().copy_tree(fixture_path, stage_path.as_path()).unwrap(); let (workspace_root, _cwd) = find_workspace_root(&stage_path).unwrap(); @@ -133,13 +256,13 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture Err(err) => panic!("Failed to read cases.toml for fixture {fixture_name}: {err}"), }; - // Navigate from CARGO_MANIFEST_DIR to packages/tools at the repo root + // Navigate from runtime CARGO_MANIFEST_DIR to packages/tools at the repo root. #[expect( clippy::disallowed_types, - reason = "Path required for CARGO_MANIFEST_DIR path manipulation via env! macro" + reason = "Path required for CARGO_MANIFEST_DIR path traversal" )] - let repo_root = - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().parent().unwrap(); + let repo_root = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let repo_root = repo_root.parent().unwrap().parent().unwrap(); let test_bin_path = Arc::::from( repo_root.join("packages").join("tools").join("node_modules").join(".bin").into_os_string(), ); @@ -152,7 +275,7 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture [ // Include vp binary path to PATH so that e2e tests can run "vp ..." commands. { - let vp_path = AbsolutePath::new(env!("CARGO_BIN_EXE_vp")).unwrap(); + let vp_path = resolve_runtime_vp_path(); let vp_dir = vp_path.parent().unwrap(); vp_dir.as_path().as_os_str().into() }, @@ -184,15 +307,16 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture let e2e_stage_path = tmpdir.join(vite_str::format!("{fixture_name}_e2e_stage_{e2e_count}")); e2e_count += 1; - assert!(copy_dir(fixture_path, &e2e_stage_path).unwrap().is_empty()); + CopyOptions::new().copy_tree(fixture_path, e2e_stage_path.as_path()).unwrap(); let e2e_stage_path_str = e2e_stage_path.as_path().to_str().unwrap(); let mut e2e_outputs = String::new(); for step in &e2e.steps { + let step_command = step.command(); let mut cmd = CommandBuilder::new(&shell_exe); cmd.arg("-c"); - cmd.arg(step.0.as_str()); + cmd.arg(step_command); cmd.env_clear(); cmd.env("PATH", &e2e_env_path); cmd.env("NO_COLOR", "1"); @@ -206,23 +330,84 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture cmd.env("PATHEXT", pathext); } - let mut terminal = Terminal::spawn(SCREEN_SIZE, cmd).unwrap(); - - // Read to end on a separate thread with timeout via channel - let mut killer = terminal.clone_killer(); + let terminal = TestTerminal::spawn(SCREEN_SIZE, cmd).unwrap(); + let mut killer = terminal.child_handle.clone(); + let interactions = step.interactions().to_vec(); + let output = Arc::new(Mutex::new(String::new())); + let output_for_thread = Arc::clone(&output); let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { - let status = terminal.read_to_end(); - let screen = terminal.screen_contents(); - let _ = tx.send((status, screen)); + let mut terminal = terminal; + + for interaction in interactions { + match interaction { + Interaction::ExpectMilestone(expect) => { + output_for_thread.lock().unwrap().push_str( + vite_str::format!( + "@ expect-milestone: {}\n", + expect.expect_milestone + ) + .as_str(), + ); + let milestone_screen = + terminal.reader.expect_milestone(expect.expect_milestone.as_str()); + let mut output = output_for_thread.lock().unwrap(); + output.push_str(&milestone_screen); + output.push('\n'); + } + Interaction::Write(write) => { + output_for_thread + .lock() + .unwrap() + .push_str(vite_str::format!("@ write: {}\n", write.write).as_str()); + terminal.writer.write_all(write.write.as_str().as_bytes()).unwrap(); + terminal.writer.flush().unwrap(); + } + Interaction::WriteLine(write_line) => { + output_for_thread.lock().unwrap().push_str( + vite_str::format!("@ write-line: {}\n", write_line.write_line) + .as_str(), + ); + terminal + .writer + .write_line(write_line.write_line.as_str().as_bytes()) + .unwrap(); + } + Interaction::WriteKey(write_key) => { + let key_name = write_key.write_key.as_str(); + output_for_thread + .lock() + .unwrap() + .push_str(vite_str::format!("@ write-key: {key_name}\n").as_str()); + terminal.writer.write_all(write_key.write_key.bytes()).unwrap(); + terminal.writer.flush().unwrap(); + } + } + } + + let status = terminal.reader.wait_for_exit(); + let screen = terminal.reader.screen_contents(); + + { + let mut output = output_for_thread.lock().unwrap(); + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + output.push_str(&screen); + } + + let _ = tx.send(i64::from(status.exit_code())); }); - let (termination_state, screen) = match rx.recv_timeout(STEP_TIMEOUT) { - Ok((status, screen)) => (TerminationState::Exited(status.unwrap()), screen), + let (termination_state, output) = match rx.recv_timeout(STEP_TIMEOUT) { + Ok(exit_code) => { + let output = output.lock().unwrap().clone(); + (TerminationState::Exited(exit_code), output) + } Err(mpsc::RecvTimeoutError::Timeout) => { let _ = killer.kill(); - let (_, screen) = rx.recv().unwrap(); - (TerminationState::TimedOut, screen) + let output = output.lock().unwrap().clone(); + (TerminationState::TimedOut, output) } Err(mpsc::RecvTimeoutError::Disconnected) => { panic!("Terminal thread panicked"); @@ -234,19 +419,18 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture TerminationState::TimedOut => { e2e_outputs.push_str("[timeout]"); } - TerminationState::Exited(status) => { - let exit_code = status.exit_code(); - if exit_code != 0 { + TerminationState::Exited(exit_code) => { + if *exit_code != 0 { e2e_outputs.push_str(vite_str::format!("[{exit_code}]").as_str()); } } } e2e_outputs.push_str("> "); - e2e_outputs.push_str(step.0.as_str()); + e2e_outputs.push_str(step_command); e2e_outputs.push('\n'); - e2e_outputs.push_str(&redact_e2e_output(screen, e2e_stage_path_str)); + e2e_outputs.push_str(&redact_e2e_output(output, e2e_stage_path_str)); e2e_outputs.push('\n'); // Skip remaining steps if timed out @@ -264,20 +448,27 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture } } -#[expect(clippy::disallowed_types, reason = "Path required by insta::glob! macro callback")] -#[expect( - clippy::disallowed_methods, - reason = "current_dir needed because insta::glob! requires std PathBuf" -)] fn main() { let filter = std::env::args().nth(1); let tmp_dir = tempfile::tempdir().unwrap(); let tmp_dir_path = AbsolutePathBuf::new(tmp_dir.path().canonicalize().unwrap()).unwrap(); - let tests_dir = std::env::current_dir().unwrap().join("tests"); - - insta::glob!(tests_dir, "e2e_snapshots/fixtures/*", |case_path| { + #[expect( + clippy::disallowed_types, + reason = "Path required for CARGO_MANIFEST_DIR path traversal" + )] + let fixtures_dir = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()) + .join("tests") + .join("e2e_snapshots") + .join("fixtures"); + let mut fixture_paths = std::fs::read_dir(fixtures_dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + fixture_paths.sort(); + + for case_path in &fixture_paths { run_case(&tmp_dir_path, case_path, filter.as_deref()); - }); + } } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/redact.rs b/crates/vite_task_bin/tests/e2e_snapshots/redact.rs index 6467f66a..438f948e 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/redact.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/redact.rs @@ -60,6 +60,10 @@ pub fn redact_e2e_output(mut output: String, workspace_root: &str) -> String { .unwrap(); output = node_trace_warning_regex.replace_all(&output, "").into_owned(); + // Remove nondeterministic mise warnings from shell startup in cross-platform runners. + let mise_warning_regex = regex::Regex::new(r"(?m)^mise WARN\s+.*\n?").unwrap(); + output = mise_warning_regex.replace_all(&output, "").into_owned(); + // Sort consecutive diagnostic blocks to handle non-deterministic tool output // (e.g., oxlint reports warnings in arbitrary order due to multi-threading). // Each block starts with " ! " and ends at the next empty line.