From bf17c111287b54fc7de12397091f91483bbdb113 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 11 Feb 2026 14:25:31 +0800 Subject: [PATCH 01/24] refactor: split Terminal into PtyStream + ChildHandle Decompose the monolithic Terminal struct into focused components: - PtyStream: combined reader/writer (impl Read + Write) with vt100 parser, screen_contents(), resize(), send_ctrl_c(), write_line() - ChildHandle: cloneable child process handle with wait() and kill() - Terminal: thin wrapper with pub pty_stream and child_handle fields Drop custom read_until/read_to_end methods in favor of std::io traits. Remove 7 read_until-specific tests; adapt remaining 9 tests to use BufReader<&mut PtyStream>::read_until for synchronization. Co-Authored-By: Claude Opus 4.6 --- crates/pty_terminal/src/terminal.rs | 348 ++++++++---------- crates/pty_terminal/tests/terminal.rs | 308 ++++++---------- .../vite_task_bin/tests/e2e_snapshots/main.rs | 12 +- 3 files changed, 267 insertions(+), 401 deletions(-) diff --git a/crates/pty_terminal/src/terminal.rs b/crates/pty_terminal/src/terminal.rs index 31a2262d..f96a43a9 100644 --- a/crates/pty_terminal/src/terminal.rs +++ b/crates/pty_terminal/src/terminal.rs @@ -9,21 +9,41 @@ use portable_pty::{ChildKiller, ExitStatus, MasterPty}; use crate::geo::ScreenSize; -/// A headless terminal -pub struct Terminal { - master: Box, - parser: vt100::Parser, - child_killer: Box, +/// A combined PTY reader/writer that implements [`Read`] and [`Write`]. +/// +/// Reading feeds data through an internal vt100 parser, keeping `screen_contents()` +/// up-to-date with parsed terminal output. +/// +/// 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 PtyStream { reader: Box, writer: Arc>>>, + parser: vt100::Parser, + master: Box, +} - /// Unprocessed data buffer for `read_until` - read_until_buffer: Vec, - - /// 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 stream and a child process handle. +pub struct Terminal { + pub pty_stream: PtyStream, + pub child_handle: ChildHandle, +} + struct Vt100Callbacks { writer: Arc>>>, } @@ -53,198 +73,59 @@ 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 PtyStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let n = self.reader.read(buf)?; + if n > 0 { + self.parser.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)?; - - if n == 0 { - // EOF - expected string not found - return Err(anyhow::anyhow!("Expected string not found: {expected}")); - } - - let data = &buf[..n]; - // Feed data to parser, which updates screen state and handles control sequence queries. - self.parser.process(data); +impl Write for PtyStream { + 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}")))?; - self.read_until_buffer.extend_from_slice(data); - } + guard.as_mut().map_or_else( + || Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "Child process has exited")), + |writer| writer.write(buf), + ) } - /// Kills the child process. - /// - /// # Errors - /// - /// Returns an error if the child process cannot be killed. - pub fn kill(&mut self) -> anyhow::Result<()> { - self.child_killer.kill()?; - Ok(()) + fn flush(&mut self) -> std::io::Result<()> { + let mut guard = + self.writer.lock().map_err(|e| std::io::Error::other(format!("Poisoned lock: {e}")))?; + + guard.as_mut().map_or(Ok(()), Write::flush) } +} - /// Reads all remaining output until the child process exits. +impl PtyStream { + /// Writes `line` followed by a platform-appropriate line ending to the child process. /// - /// Returns the exit status of the child process. + /// On Unix, appends `\n`. On Windows `ConPTY`, appends `\r\n` for proper line handling. /// /// # Errors /// - /// Returns an error if: - /// - Reading from the PTY fails - /// - The exit status is not available (should not happen in normal operation) - /// - /// # 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; - - Ok(status) - } + /// 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)?; - /// Writes data to the child process's stdin. - /// - /// # 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 - }; + #[cfg(not(target_os = "windows"))] + self.write_all(b"\n")?; #[cfg(target_os = "windows")] - let data_to_write: &[u8] = &converted; - - #[cfg(not(target_os = "windows"))] - let data_to_write: &[u8] = data; + self.write_all(b"\r\n")?; - let mut writer_guard = self - .writer - .lock() - .map_err(|e| anyhow::anyhow!("Failed to acquire writer lock: {e}"))?; + self.flush() + } - 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")) - } + /// Returns the current terminal screen contents as a string (parsed by the vt100 emulator). + #[must_use] + pub fn screen_contents(&self) -> String { + self.parser.screen().contents() } /// Sends Ctrl+C (SIGINT) to the child process. @@ -256,28 +137,19 @@ 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]) - } - - /// Clones the child process killer for use from another thread. - #[must_use] - pub fn clone_killer(&self) -> Box { - self.child_killer.clone_killer() - } - - #[must_use] - pub fn screen_contents(&self) -> String { - self.parser.screen().contents() + pub fn send_ctrl_c(&mut self) -> std::io::Result<()> { + self.write_all(&[0x03])?; + self.flush() } /// 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. 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 { rows: size.rows, cols: size.cols, @@ -285,9 +157,85 @@ impl Terminal { pixel_height: 0, })?; - // Update the vt100 parser's internal screen dimensions self.parser.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 wait(&self) -> ExitStatus { + self.exit_status.wait().clone() + } + + /// Kills the child process. + /// + /// # Errors + /// + /// 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; + } + }); + + Ok(Self { + pty_stream: PtyStream { + parser: vt100::Parser::new_with_callbacks( + size.rows, + size.cols, + 0, + Vt100Callbacks { writer: Arc::clone(&writer) }, + ), + reader, + writer, + 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..6a25dbdd 100644 --- a/crates/pty_terminal/tests/terminal.rs +++ b/crates/pty_terminal/tests/terminal.rs @@ -1,4 +1,4 @@ -use std::io::{IsTerminal, Write, stderr, stdin, stdout}; +use std::io::{BufRead, BufReader, IsTerminal, Read, Write, stderr, stdin, stdout}; use ntest::timeout; use portable_pty::CommandBuilder; @@ -13,150 +13,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_stream, child_handle } = + Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + let mut discard = Vec::new(); + pty_stream.read_to_end(&mut discard).unwrap(); + let _ = child_handle.wait(); + let output = pty_stream.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 +37,16 @@ fn write_basic_echo() { } })); - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + let Terminal { mut pty_stream, child_handle } = + Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); - // Write data to the terminal - terminal.write(b"hello world\n").unwrap(); + pty_stream.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_stream.read_to_end(&mut discard).unwrap(); + let _ = child_handle.wait(); - let output = terminal.screen_contents(); + let output = pty_stream.screen_contents(); // PTY echoes the input, so we see "hello world\nhello world" assert_eq!(output.trim(), "hello world\nhello world"); } @@ -195,7 +60,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 +68,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_stream, child_handle } = + Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + + pty_stream.write_line(b"first").unwrap(); + { + let mut buf_reader = BufReader::new(&mut pty_stream); + 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_stream.write_line(b"second").unwrap(); + { + let mut buf_reader = BufReader::new(&mut pty_stream); + 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_stream.write_line(b"third").unwrap(); + + let mut discard = Vec::new(); + pty_stream.read_to_end(&mut discard).unwrap(); + let _ = child_handle.wait(); + + let output = pty_stream.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 +110,18 @@ fn write_after_exit() { print!("exiting"); })); - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + let Terminal { mut pty_stream, 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(); + let mut discard = Vec::new(); + pty_stream.read_to_end(&mut discard).unwrap(); + let _ = child_handle.wait(); // 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"); + // Writing should fail with BrokenPipe + let result = pty_stream.write_all(b"too late\n"); assert!(result.is_err()); } @@ -256,19 +141,25 @@ fn write_interactive_prompt() { stdout.flush().unwrap(); })); - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + let Terminal { mut pty_stream, 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_stream); + 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_stream.write_line(b"Alice").unwrap(); - // Wait for greeting - terminal.read_until("Hello, Alice").unwrap(); + let mut discard = Vec::new(); + pty_stream.read_to_end(&mut discard).unwrap(); + let _ = child_handle.wait(); - let _ = terminal.read_to_end().unwrap(); - let output = terminal.screen_contents(); + let output = pty_stream.screen_contents(); assert_eq!(output.trim(), "Name: Alice\nHello, Alice"); } @@ -341,24 +232,32 @@ fn resize_terminal() { stdout().flush().unwrap(); })); - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + let Terminal { mut pty_stream, 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_stream); + 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_stream.resize(ScreenSize { rows: 40, cols: 40 }).unwrap(); // Signal the process to continue and check resize - terminal.write(b"\n").unwrap(); + pty_stream.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_stream.read_to_end(&mut discard).unwrap(); + let output = pty_stream.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 +303,27 @@ fn send_ctrl_c_interrupts_process() { } })); - let mut terminal = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + let Terminal { mut pty_stream, 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_stream); + 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_stream.send_ctrl_c().unwrap(); - // Verify interruption was detected - terminal.read_until("INTERRUPTED").unwrap(); + // Read remaining output + let mut discard = Vec::new(); + pty_stream.read_to_end(&mut discard).unwrap(); - let _ = terminal.read_to_end().unwrap(); + let output = pty_stream.screen_contents(); + // Verify interruption was detected + assert!(output.contains("INTERRUPTED")); } #[test] @@ -426,8 +334,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_stream, child_handle } = + Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + let mut discard = Vec::new(); + pty_stream.read_to_end(&mut discard).unwrap(); + let status = child_handle.wait(); assert!(status.success()); assert_eq!(status.exit_code(), 0); } @@ -439,8 +350,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_stream, child_handle } = + Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + let mut discard = Vec::new(); + pty_stream.read_to_end(&mut discard).unwrap(); + let status = child_handle.wait(); assert!(!status.success()); assert_eq!(status.exit_code(), 42); } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 717dcc18..fe19dec8 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -3,6 +3,7 @@ mod redact; use std::{ env::{self, join_paths, split_paths}, ffi::OsStr, + io::Read, sync::{Arc, mpsc}, time::Duration, }; @@ -206,14 +207,17 @@ 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(); + let 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 mut killer = terminal.child_handle.clone(); let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { - let status = terminal.read_to_end(); - let screen = terminal.screen_contents(); + let Terminal { mut pty_stream, child_handle } = terminal; + let mut discard = Vec::new(); + let read_result = pty_stream.read_to_end(&mut discard); + let screen = pty_stream.screen_contents(); + let status = read_result.map(|_| child_handle.wait()); let _ = tx.send((status, screen)); }); From 9f87de8d6581b20bf1fab7c202c16c584bdb9a79 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 11 Feb 2026 15:27:43 +0800 Subject: [PATCH 02/24] refactor: split PtyStream into PtyReader + PtyWriter with shared parser Share vt100 parser via Arc> between reader and writer, allowing independent mutable borrows in tests without scoped BufReader workarounds. Co-Authored-By: Claude Opus 4.6 --- crates/pty_terminal/src/terminal.rs | 76 +++++++++++-------- crates/pty_terminal/tests/terminal.rs | 76 +++++++++---------- .../vite_task_bin/tests/e2e_snapshots/main.rs | 6 +- 3 files changed, 86 insertions(+), 72 deletions(-) diff --git a/crates/pty_terminal/src/terminal.rs b/crates/pty_terminal/src/terminal.rs index f96a43a9..ab1b1f25 100644 --- a/crates/pty_terminal/src/terminal.rs +++ b/crates/pty_terminal/src/terminal.rs @@ -9,17 +9,22 @@ use portable_pty::{ChildKiller, ExitStatus, MasterPty}; use crate::geo::ScreenSize; -/// A combined PTY reader/writer that implements [`Read`] and [`Write`]. +/// The read half of a PTY connection. Implements [`Read`]. /// -/// Reading feeds data through an internal vt100 parser, keeping `screen_contents()` -/// up-to-date with parsed terminal output. +/// 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, + parser: Arc>>, +} + +/// 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 PtyStream { - reader: Box, +pub struct PtyWriter { writer: Arc>>>, - parser: vt100::Parser, + parser: Arc>>, master: Box, } @@ -38,9 +43,10 @@ impl Clone for ChildHandle { } } -/// A headless terminal consisting of a PTY stream and a child process handle. +/// A headless terminal consisting of a PTY reader, writer, and a child process handle. pub struct Terminal { - pub pty_stream: PtyStream, + pub pty_reader: PtyReader, + pub pty_writer: PtyWriter, pub child_handle: ChildHandle, } @@ -73,17 +79,17 @@ impl vt100::Callbacks for Vt100Callbacks { } } -impl Read for PtyStream { +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.process(&buf[..n]); + self.parser.lock().unwrap().process(&buf[..n]); } Ok(n) } } -impl Write for PtyStream { +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}")))?; @@ -102,7 +108,19 @@ impl Write for PtyStream { } } -impl PtyStream { +impl PtyReader { + /// Returns the current terminal screen contents as a string (parsed by the vt100 emulator). + /// + /// # Panics + /// + /// Panics if the parser lock is poisoned. + #[must_use] + pub fn screen_contents(&self) -> String { + self.parser.lock().unwrap().screen().contents() + } +} + +impl PtyWriter { /// 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. @@ -122,12 +140,6 @@ impl PtyStream { self.flush() } - /// Returns the current terminal screen contents as a string (parsed by the vt100 emulator). - #[must_use] - pub fn screen_contents(&self) -> String { - self.parser.screen().contents() - } - /// Sends Ctrl+C (SIGINT) to the child process. /// /// Writes ETX (0x03) to the PTY. On Unix, the terminal driver converts this @@ -149,7 +161,11 @@ impl PtyStream { /// # Errors /// /// Returns an error if the PTY cannot be resized. - pub fn resize(&mut self, size: ScreenSize) -> anyhow::Result<()> { + /// + /// # 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, @@ -157,7 +173,7 @@ impl PtyStream { pixel_height: 0, })?; - self.parser.screen_mut().set_size(size.rows, size.cols); + self.parser.lock().unwrap().screen_mut().set_size(size.rows, size.cols); Ok(()) } @@ -223,18 +239,16 @@ impl Terminal { } }); + let parser = Arc::new(Mutex::new(vt100::Parser::new_with_callbacks( + size.rows, + size.cols, + 0, + Vt100Callbacks { writer: Arc::clone(&writer) }, + ))); + Ok(Self { - pty_stream: PtyStream { - parser: vt100::Parser::new_with_callbacks( - size.rows, - size.cols, - 0, - Vt100Callbacks { writer: Arc::clone(&writer) }, - ), - reader, - writer, - master, - }, + 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 6a25dbdd..2db77220 100644 --- a/crates/pty_terminal/tests/terminal.rs +++ b/crates/pty_terminal/tests/terminal.rs @@ -13,12 +13,12 @@ fn is_terminal() { println!("{} {} {}", stdin().is_terminal(), stdout().is_terminal(), stderr().is_terminal()); })); - let Terminal { mut pty_stream, child_handle } = + 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_stream.read_to_end(&mut discard).unwrap(); + pty_reader.read_to_end(&mut discard).unwrap(); let _ = child_handle.wait(); - let output = pty_stream.screen_contents(); + let output = pty_reader.screen_contents(); assert_eq!(output.trim(), "true true true"); } @@ -37,16 +37,16 @@ fn write_basic_echo() { } })); - let Terminal { mut pty_stream, child_handle } = + let Terminal { mut pty_reader, mut pty_writer, child_handle } = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); - pty_stream.write_line(b"hello world").unwrap(); + pty_writer.write_line(b"hello world").unwrap(); let mut discard = Vec::new(); - pty_stream.read_to_end(&mut discard).unwrap(); + pty_reader.read_to_end(&mut discard).unwrap(); let _ = child_handle.wait(); - let output = pty_stream.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"); } @@ -68,12 +68,12 @@ fn write_multiple_lines() { } })); - let Terminal { mut pty_stream, child_handle } = + let Terminal { mut pty_reader, mut pty_writer, child_handle } = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); - pty_stream.write_line(b"first").unwrap(); + pty_writer.write_line(b"first").unwrap(); { - let mut buf_reader = BufReader::new(&mut pty_stream); + 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(); @@ -82,22 +82,22 @@ fn write_multiple_lines() { buf_reader.read_until(b'\n', &mut line).unwrap(); } - pty_stream.write_line(b"second").unwrap(); + pty_writer.write_line(b"second").unwrap(); { - let mut buf_reader = BufReader::new(&mut pty_stream); + 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_stream.write_line(b"third").unwrap(); + pty_writer.write_line(b"third").unwrap(); let mut discard = Vec::new(); - pty_stream.read_to_end(&mut discard).unwrap(); + pty_reader.read_to_end(&mut discard).unwrap(); let _ = child_handle.wait(); - let output = pty_stream.screen_contents(); + 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"); } @@ -110,18 +110,18 @@ fn write_after_exit() { print!("exiting"); })); - let Terminal { mut pty_stream, child_handle } = + 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 mut discard = Vec::new(); - pty_stream.read_to_end(&mut discard).unwrap(); + pty_reader.read_to_end(&mut discard).unwrap(); let _ = child_handle.wait(); // 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 BrokenPipe - let result = pty_stream.write_all(b"too late\n"); + let result = pty_writer.write_all(b"too late\n"); assert!(result.is_err()); } @@ -141,25 +141,25 @@ fn write_interactive_prompt() { stdout.flush().unwrap(); })); - let Terminal { mut pty_stream, child_handle } = + let Terminal { mut pty_reader, mut pty_writer, child_handle } = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); // Wait for prompt "Name: " (read until the space after colon) { - let mut buf_reader = BufReader::new(&mut pty_stream); + 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 - pty_stream.write_line(b"Alice").unwrap(); + pty_writer.write_line(b"Alice").unwrap(); let mut discard = Vec::new(); - pty_stream.read_to_end(&mut discard).unwrap(); + pty_reader.read_to_end(&mut discard).unwrap(); let _ = child_handle.wait(); - let output = pty_stream.screen_contents(); + let output = pty_reader.screen_contents(); assert_eq!(output.trim(), "Name: Alice\nHello, Alice"); } @@ -232,28 +232,28 @@ fn resize_terminal() { stdout().flush().unwrap(); })); - let Terminal { mut pty_stream, child_handle: _ } = + let Terminal { mut pty_reader, mut pty_writer, child_handle: _ } = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); // Wait for initial size line (synchronize before resizing) { - let mut buf_reader = BufReader::new(&mut pty_stream); + 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 - pty_stream.resize(ScreenSize { rows: 40, cols: 40 }).unwrap(); + pty_writer.resize(ScreenSize { rows: 40, cols: 40 }).unwrap(); // Signal the process to continue and check resize - pty_stream.write_line(b"").unwrap(); + pty_writer.write_line(b"").unwrap(); // Read remaining output let mut discard = Vec::new(); - pty_stream.read_to_end(&mut discard).unwrap(); + pty_reader.read_to_end(&mut discard).unwrap(); - let output = pty_stream.screen_contents(); + 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 @@ -303,25 +303,25 @@ fn send_ctrl_c_interrupts_process() { } })); - let Terminal { mut pty_stream, child_handle: _ } = + 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 { - let mut buf_reader = BufReader::new(&mut pty_stream); + 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 - pty_stream.send_ctrl_c().unwrap(); + pty_writer.send_ctrl_c().unwrap(); // Read remaining output let mut discard = Vec::new(); - pty_stream.read_to_end(&mut discard).unwrap(); + pty_reader.read_to_end(&mut discard).unwrap(); - let output = pty_stream.screen_contents(); + let output = pty_reader.screen_contents(); // Verify interruption was detected assert!(output.contains("INTERRUPTED")); } @@ -334,10 +334,10 @@ fn read_to_end_returns_exit_status_success() { println!("success"); })); - let Terminal { mut pty_stream, child_handle } = + 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_stream.read_to_end(&mut discard).unwrap(); + pty_reader.read_to_end(&mut discard).unwrap(); let status = child_handle.wait(); assert!(status.success()); assert_eq!(status.exit_code(), 0); @@ -350,10 +350,10 @@ fn read_to_end_returns_exit_status_nonzero() { std::process::exit(42); })); - let Terminal { mut pty_stream, child_handle } = + 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_stream.read_to_end(&mut discard).unwrap(); + 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/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index fe19dec8..db7e2ffb 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -213,10 +213,10 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture let mut killer = terminal.child_handle.clone(); let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { - let Terminal { mut pty_stream, child_handle } = terminal; + let Terminal { mut pty_reader, pty_writer: _pty_writer, child_handle } = terminal; let mut discard = Vec::new(); - let read_result = pty_stream.read_to_end(&mut discard); - let screen = pty_stream.screen_contents(); + let read_result = pty_reader.read_to_end(&mut discard); + let screen = pty_reader.screen_contents(); let status = read_result.map(|_| child_handle.wait()); let _ = tx.send((status, screen)); }); From 052c3fec5f7a70b409723cdce4017ac78f631ff1 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 11 Feb 2026 17:38:18 +0800 Subject: [PATCH 03/24] feat: add milestone-based PTY test infrastructure Add pty_terminal_test and pty_terminal_test_client crates for robust PTY test synchronization using OSC escape sequences. The child process emits named milestones via OSC 9999, and the test harness waits for them to return screen contents at that point. On Windows, ConPTY delivers unrecognized OSC sequences via a fast pass-through path that may arrive before rendered character output. Each milestone also moves the cursor to a counter-based unique column on the last row; since cursor movement goes through ConPTY's rendering pipeline, waiting for the cursor position guarantees all preceding character output has been consumed. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 18 +++ Cargo.toml | 2 + crates/pty_terminal/src/terminal.rs | 35 +++++- crates/pty_terminal_test/.clippy.toml | 1 + crates/pty_terminal_test/Cargo.toml | 24 ++++ crates/pty_terminal_test/src/lib.rs | 110 +++++++++++++++++++ crates/pty_terminal_test/tests/milestone.rs | 72 ++++++++++++ crates/pty_terminal_test_client/.clippy.toml | 1 + crates/pty_terminal_test_client/Cargo.toml | 15 +++ crates/pty_terminal_test_client/src/lib.rs | 47 ++++++++ 10 files changed, 324 insertions(+), 1 deletion(-) create mode 120000 crates/pty_terminal_test/.clippy.toml create mode 100644 crates/pty_terminal_test/Cargo.toml create mode 100644 crates/pty_terminal_test/src/lib.rs create mode 100644 crates/pty_terminal_test/tests/milestone.rs create mode 120000 crates/pty_terminal_test_client/.clippy.toml create mode 100644 crates/pty_terminal_test_client/Cargo.toml create mode 100644 crates/pty_terminal_test_client/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 4ced9c80..21cec4a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2492,6 +2492,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" diff --git a/Cargo.toml b/Cargo.toml index 1e85701b..9cfb3a79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,6 +94,8 @@ 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 ab1b1f25..b68a4625 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, @@ -52,9 +53,15 @@ pub struct Terminal { 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, @@ -118,6 +125,29 @@ impl PtyReader { pub fn screen_contents(&self) -> String { self.parser.lock().unwrap().screen().contents() } + + /// Drains and returns all unhandled OSC sequences received since the last call. + /// + /// Each entry is a list of byte-vector parameters from a single OSC sequence + /// (`ESC ] param1 ; param2 ; ... ST`). + /// + /// # Panics + /// + /// 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 parser lock is poisoned. + #[must_use] + pub fn cursor_position(&self) -> (u16, u16) { + self.parser.lock().unwrap().screen().cursor_position() + } } impl PtyWriter { @@ -243,7 +273,10 @@ impl Terminal { size.rows, size.cols, 0, - Vt100Callbacks { writer: Arc::clone(&writer) }, + Vt100Callbacks { + writer: Arc::clone(&writer), + unhandled_osc_sequences: VecDeque::new(), + }, ))); Ok(Self { 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/src/lib.rs b/crates/pty_terminal_test/src/lib.rs new file mode 100644 index 00000000..393669d7 --- /dev/null +++ b/crates/pty_terminal_test/src/lib.rs @@ -0,0 +1,110 @@ +use std::io::Read; + +pub use portable_pty::CommandBuilder; +use pty_terminal::terminal::{PtyReader, Terminal}; +pub use pty_terminal::{ + ExitStatus, + geo::ScreenSize, + terminal::{ChildHandle, PtyWriter}, +}; + +/// The OSC parameter ID that identifies milestone sequences. +const MILESTONE_OSC_ID: &[u8] = pty_terminal_test_client::MILESTONE_OSC_ID; + +/// 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, +} + +/// The read half of a test terminal, wrapping [`PtyReader`] with milestone support. +pub struct Reader { + pty: PtyReader, + child_handle: ChildHandle, + /// Counts milestones to match the child's counter-based cursor column. + milestone_counter: u16, +} + +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: pty_reader, child_handle, milestone_counter: 0 }, + }) + } +} + +impl Reader { + /// 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. + /// + /// On Windows, `ConPTY` delivers unrecognized OSC sequences via a fast + /// pass-through path that may arrive before the rendered character output. + /// Each milestone also moves the cursor to a unique position on the last + /// row; since this cursor movement goes through `ConPTY`'s rendering + /// pipeline, waiting for the cursor to reach the expected position + /// guarantees all preceding character output has been consumed. + /// + /// # 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 { + self.milestone_counter += 1; + let expected_col = self.milestone_counter - 1; // 0-indexed + + let name_bytes = name.as_bytes(); + let mut buf = [0u8; 4096]; + let mut found = false; + loop { + for seq in self.pty.take_unhandled_osc_sequences() { + if seq.first().is_some_and(|id| id == MILESTONE_OSC_ID) + && seq.get(1).is_some_and(|n| n == name_bytes) + { + found = true; + } + } + if found { + let (_, col) = self.pty.cursor_position(); + if col == expected_col { + return self.pty.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() + } + + /// Returns the current terminal screen contents. + /// + /// # Panics + /// + /// Panics if the parser lock is poisoned. + #[must_use] + pub fn screen_contents(&self) -> String { + self.pty.screen_contents() + } +} diff --git a/crates/pty_terminal_test/tests/milestone.rs b/crates/pty_terminal_test/tests/milestone.rs new file mode 100644 index 00000000..0e5000a7 --- /dev/null +++ b/crates/pty_terminal_test/tests/milestone.rs @@ -0,0 +1,72 @@ +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 } = + 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()); +} 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/src/lib.rs b/crates/pty_terminal_test_client/src/lib.rs new file mode 100644 index 00000000..9ec7dc1a --- /dev/null +++ b/crates/pty_terminal_test_client/src/lib.rs @@ -0,0 +1,47 @@ +/// The OSC parameter ID used for the milestone protocol. +/// +/// Milestone OSC format: `ESC ] 9999 ; BEL` +/// +/// The vt100 parser splits by `;`, so `unhandled_osc` receives +/// `params = [b"9999", b""]`. +pub const MILESTONE_OSC_ID: &[u8] = b"9999"; + +/// Emits a milestone marker as a private OSC escape sequence. +/// +/// 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` delivers unrecognized OSC sequences via a fast +/// pass-through path that may arrive before preceding rendered character output. +/// To allow the reader to detect when rendering has caught up, each milestone +/// also moves the cursor to a unique position on the last row (column = call +/// count). The cursor movement goes through `ConPTY`'s rendering pipeline, +/// guaranteeing it arrives after all preceding character output. +/// +/// 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}, + sync::atomic::{AtomicU16, Ordering}, + }; + + static COUNTER: AtomicU16 = AtomicU16::new(0); + let col = COUNTER.fetch_add(1, Ordering::Relaxed) + 1; // 1-based for VT + + let mut stdout = stdout(); + // OSC milestone (pass-through on ConPTY) + cursor move to last row (rendering pipeline) + write!(stdout, "\x1b]9999;{name}\x07\x1b[999;{col}H").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) {} From d8ccfca50aa462c4c7822e51a2c7a7ec8b84e437 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 11 Feb 2026 22:16:47 +0800 Subject: [PATCH 04/24] Use cursor-visibility milestone fence for ConPTY ordering --- crates/pty_terminal_test/src/lib.rs | 70 +++++++++++++-------- crates/pty_terminal_test/tests/milestone.rs | 55 ++++++++++++++++ crates/pty_terminal_test_client/src/lib.rs | 32 ++++++---- 3 files changed, 121 insertions(+), 36 deletions(-) diff --git a/crates/pty_terminal_test/src/lib.rs b/crates/pty_terminal_test/src/lib.rs index 393669d7..04ee4991 100644 --- a/crates/pty_terminal_test/src/lib.rs +++ b/crates/pty_terminal_test/src/lib.rs @@ -25,8 +25,7 @@ pub struct TestTerminal { pub struct Reader { pty: PtyReader, child_handle: ChildHandle, - /// Counts milestones to match the child's counter-based cursor column. - milestone_counter: u16, + cursor_hidden: bool, } impl TestTerminal { @@ -39,7 +38,7 @@ impl TestTerminal { let Terminal { pty_reader, pty_writer, child_handle } = Terminal::spawn(size, cmd)?; Ok(Self { writer: pty_writer, - reader: Reader { pty: pty_reader, child_handle, milestone_counter: 0 }, + reader: Reader { pty: pty_reader, child_handle, cursor_hidden: false }, }) } } @@ -49,12 +48,11 @@ impl Reader { /// /// Returns the terminal screen contents at the moment the milestone is detected. /// - /// On Windows, `ConPTY` delivers unrecognized OSC sequences via a fast - /// pass-through path that may arrive before the rendered character output. - /// Each milestone also moves the cursor to a unique position on the last - /// row; since this cursor movement goes through `ConPTY`'s rendering - /// pipeline, waiting for the cursor to reach the expected position - /// guarantees all preceding character output has been consumed. + /// Milestones use a uniform protocol across platforms: OSC marker followed + /// by an alternating cursor-visibility fence (`CSI ?25l` / `CSI ?25h`). + /// On Windows, `ConPTY` may deliver unrecognized OSC before rendered output; + /// waiting for the expected fence guarantees prior rendered output has been + /// consumed. /// /// # Panics /// @@ -62,28 +60,50 @@ impl Reader { /// or if a read error occurs. #[must_use] pub fn expect_milestone(&mut self, name: &str) -> String { - self.milestone_counter += 1; - let expected_col = self.milestone_counter - 1; // 0-indexed + let mut milestone = Vec::with_capacity(8 + MILESTONE_OSC_ID.len() + name.len()); + milestone.extend_from_slice(b"\x1b]"); + milestone.extend_from_slice(MILESTONE_OSC_ID); + milestone.extend_from_slice(b";"); + milestone.extend_from_slice(name.as_bytes()); + milestone.push(0x07); // BEL terminator + + let fence: &[u8] = { + self.cursor_hidden = !self.cursor_hidden; + if self.cursor_hidden { b"\x1b[?25l" } else { b"\x1b[?25h" } + }; - let name_bytes = name.as_bytes(); let mut buf = [0u8; 4096]; - let mut found = false; + let mut milestone_match = 0usize; + let mut found_milestone = false; + let mut fence_match = 0usize; + loop { - for seq in self.pty.take_unhandled_osc_sequences() { - if seq.first().is_some_and(|id| id == MILESTONE_OSC_ID) - && seq.get(1).is_some_and(|n| n == name_bytes) - { - found = true; + let n = self.pty.read(&mut buf).expect("PTY read failed"); + assert!(n > 0, "EOF reached before milestone '{name}'"); + + for byte in &buf[..n] { + if !found_milestone { + if *byte == milestone[milestone_match] { + milestone_match += 1; + if milestone_match == milestone.len() { + found_milestone = true; + fence_match = 0; + } + } else { + milestone_match = usize::from(*byte == milestone[0]); + } + continue; } - } - if found { - let (_, col) = self.pty.cursor_position(); - if col == expected_col { - return self.pty.screen_contents(); + + if *byte == fence[fence_match] { + fence_match += 1; + if fence_match == fence.len() { + return self.pty.screen_contents(); + } + } else { + fence_match = usize::from(*byte == fence[0]); } } - let n = self.pty.read(&mut buf).expect("PTY read failed"); - assert!(n > 0, "EOF reached before milestone '{name}'"); } } diff --git a/crates/pty_terminal_test/tests/milestone.rs b/crates/pty_terminal_test/tests/milestone.rs index 0e5000a7..5bc92690 100644 --- a/crates/pty_terminal_test/tests/milestone.rs +++ b/crates/pty_terminal_test/tests/milestone.rs @@ -70,3 +70,58 @@ fn milestone_raw_mode_keystrokes() { let status = reader.wait_for_exit(); assert!(status.success()); } + +/// Verifies that the cursor-visibility 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 } = + 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/src/lib.rs b/crates/pty_terminal_test_client/src/lib.rs index 9ec7dc1a..69365205 100644 --- a/crates/pty_terminal_test_client/src/lib.rs +++ b/crates/pty_terminal_test_client/src/lib.rs @@ -12,12 +12,16 @@ pub const MILESTONE_OSC_ID: &[u8] = b"9999"; /// 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` delivers unrecognized OSC sequences via a fast -/// pass-through path that may arrive before preceding rendered character output. -/// To allow the reader to detect when rendering has caught up, each milestone -/// also moves the cursor to a unique position on the last row (column = call -/// count). The cursor movement goes through `ConPTY`'s rendering pipeline, -/// guaranteeing it arrives after all preceding character output. +/// On Windows, `ConPTY` passes unrecognized OSC 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 OSC can arrive at the +/// reader before preceding character output has been emitted. +/// +/// Each milestone also toggles cursor visibility (`CSI ?25l` / `CSI ?25h`). +/// This keeps a uniform protocol across platforms. On Windows, this persistent +/// terminal-state change is emitted on the rendered output path, so waiting for +/// the expected toggle confirms prior rendered output has been consumed. /// /// When the `testing` feature is disabled, this is a no-op. /// @@ -28,15 +32,21 @@ pub const MILESTONE_OSC_ID: &[u8] = b"9999"; pub fn mark_milestone(name: &str) { use std::{ io::{Write, stdout}, - sync::atomic::{AtomicU16, Ordering}, + sync::atomic::{AtomicBool, Ordering}, }; - static COUNTER: AtomicU16 = AtomicU16::new(0); - let col = COUNTER.fetch_add(1, Ordering::Relaxed) + 1; // 1-based for VT + static CURSOR_HIDDEN: AtomicBool = AtomicBool::new(false); let mut stdout = stdout(); - // OSC milestone (pass-through on ConPTY) + cursor move to last row (rendering pipeline) - write!(stdout, "\x1b]9999;{name}\x07\x1b[999;{col}H").unwrap(); + write!(stdout, "\x1b]9999;{name}\x07").unwrap(); + + let was_hidden = CURSOR_HIDDEN.fetch_xor(true, Ordering::Relaxed); + if was_hidden { + write!(stdout, "\x1b[?25h").unwrap(); + } else { + write!(stdout, "\x1b[?25l").unwrap(); + } + stdout.flush().unwrap(); } From c63f566a47d4e849ab601ca2f45154a38ff6b871 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 11 Feb 2026 22:25:15 +0800 Subject: [PATCH 05/24] Use zero-width milestone fence with sanitized screen contents --- crates/pty_terminal_test/src/lib.rs | 23 +++++++++---------- crates/pty_terminal_test/tests/milestone.rs | 2 +- crates/pty_terminal_test_client/src/lib.rs | 25 +++++++-------------- 3 files changed, 20 insertions(+), 30 deletions(-) diff --git a/crates/pty_terminal_test/src/lib.rs b/crates/pty_terminal_test/src/lib.rs index 04ee4991..2f90aa10 100644 --- a/crates/pty_terminal_test/src/lib.rs +++ b/crates/pty_terminal_test/src/lib.rs @@ -10,6 +10,8 @@ pub use pty_terminal::{ /// The OSC parameter ID that identifies milestone sequences. const MILESTONE_OSC_ID: &[u8] = pty_terminal_test_client::MILESTONE_OSC_ID; +/// The zero-width fence character appended by `mark_milestone`. +const MILESTONE_FENCE_CHAR: char = '\u{200b}'; /// A test-oriented terminal that provides milestone-based synchronization. /// @@ -25,7 +27,6 @@ pub struct TestTerminal { pub struct Reader { pty: PtyReader, child_handle: ChildHandle, - cursor_hidden: bool, } impl TestTerminal { @@ -36,20 +37,21 @@ impl TestTerminal { /// 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: pty_reader, child_handle, cursor_hidden: false }, - }) + Ok(Self { writer: pty_writer, reader: Reader { pty: pty_reader, child_handle } }) } } impl Reader { + fn sanitized_screen_contents(&self) -> String { + self.pty.screen_contents().replace(MILESTONE_FENCE_CHAR, "") + } + /// 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: OSC marker followed - /// by an alternating cursor-visibility fence (`CSI ?25l` / `CSI ?25h`). + /// by a non-visual zero-width-space fence. /// On Windows, `ConPTY` may deliver unrecognized OSC before rendered output; /// waiting for the expected fence guarantees prior rendered output has been /// consumed. @@ -67,10 +69,7 @@ impl Reader { milestone.extend_from_slice(name.as_bytes()); milestone.push(0x07); // BEL terminator - let fence: &[u8] = { - self.cursor_hidden = !self.cursor_hidden; - if self.cursor_hidden { b"\x1b[?25l" } else { b"\x1b[?25h" } - }; + let fence: &[u8] = pty_terminal_test_client::MILESTONE_FENCE; let mut buf = [0u8; 4096]; let mut milestone_match = 0usize; @@ -98,7 +97,7 @@ impl Reader { if *byte == fence[fence_match] { fence_match += 1; if fence_match == fence.len() { - return self.pty.screen_contents(); + return self.sanitized_screen_contents(); } } else { fence_match = usize::from(*byte == fence[0]); @@ -125,6 +124,6 @@ impl Reader { /// Panics if the parser lock is poisoned. #[must_use] pub fn screen_contents(&self) -> String { - self.pty.screen_contents() + self.sanitized_screen_contents() } } diff --git a/crates/pty_terminal_test/tests/milestone.rs b/crates/pty_terminal_test/tests/milestone.rs index 5bc92690..807c2f52 100644 --- a/crates/pty_terminal_test/tests/milestone.rs +++ b/crates/pty_terminal_test/tests/milestone.rs @@ -71,7 +71,7 @@ fn milestone_raw_mode_keystrokes() { assert!(status.success()); } -/// Verifies that the cursor-visibility fence in `mark_milestone` does not +/// 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] diff --git a/crates/pty_terminal_test_client/src/lib.rs b/crates/pty_terminal_test_client/src/lib.rs index 69365205..eb0b1ae6 100644 --- a/crates/pty_terminal_test_client/src/lib.rs +++ b/crates/pty_terminal_test_client/src/lib.rs @@ -5,6 +5,8 @@ /// The vt100 parser splits by `;`, so `unhandled_osc` receives /// `params = [b"9999", b""]`. pub const MILESTONE_OSC_ID: &[u8] = b"9999"; +/// A non-visual UTF-8 fence appended after each milestone OSC. +pub const MILESTONE_FENCE: &[u8] = "\u{200b}".as_bytes(); /// Emits a milestone marker as a private OSC escape sequence. /// @@ -18,10 +20,10 @@ pub const MILESTONE_OSC_ID: &[u8] = b"9999"; /// that polls the console buffer. This means the OSC can arrive at the /// reader before preceding character output has been emitted. /// -/// Each milestone also toggles cursor visibility (`CSI ?25l` / `CSI ?25h`). -/// This keeps a uniform protocol across platforms. On Windows, this persistent -/// terminal-state change is emitted on the rendered output path, so waiting for -/// the expected toggle confirms prior rendered output has been consumed. +/// Each milestone also appends a non-visual zero-width-space fence (`U+200B`, +/// UTF-8 `E2 80 8B`). This keeps a uniform protocol across platforms. On +/// Windows, waiting for this rendered character after the OSC confirms prior +/// rendered output has been consumed. /// /// When the `testing` feature is disabled, this is a no-op. /// @@ -30,22 +32,11 @@ pub const MILESTONE_OSC_ID: &[u8] = b"9999"; /// Panics if writing to stdout fails. #[cfg(feature = "testing")] pub fn mark_milestone(name: &str) { - use std::{ - io::{Write, stdout}, - sync::atomic::{AtomicBool, Ordering}, - }; - - static CURSOR_HIDDEN: AtomicBool = AtomicBool::new(false); + use std::io::{Write, stdout}; let mut stdout = stdout(); write!(stdout, "\x1b]9999;{name}\x07").unwrap(); - - let was_hidden = CURSOR_HIDDEN.fetch_xor(true, Ordering::Relaxed); - if was_hidden { - write!(stdout, "\x1b[?25h").unwrap(); - } else { - write!(stdout, "\x1b[?25l").unwrap(); - } + stdout.write_all(MILESTONE_FENCE).unwrap(); stdout.flush().unwrap(); } From db71e6dca350eb6a66278c5626de6588f8b0950a Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 11 Feb 2026 23:28:03 +0800 Subject: [PATCH 06/24] Simplify milestone read_until buffer handling --- crates/pty_terminal_test/src/lib.rs | 56 +++++----------------- crates/pty_terminal_test_client/src/lib.rs | 3 +- 2 files changed, 15 insertions(+), 44 deletions(-) diff --git a/crates/pty_terminal_test/src/lib.rs b/crates/pty_terminal_test/src/lib.rs index 2f90aa10..13d09c44 100644 --- a/crates/pty_terminal_test/src/lib.rs +++ b/crates/pty_terminal_test/src/lib.rs @@ -1,4 +1,4 @@ -use std::io::Read; +use std::io::{BufRead, BufReader, Read}; pub use portable_pty::CommandBuilder; use pty_terminal::terminal::{PtyReader, Terminal}; @@ -25,7 +25,7 @@ pub struct TestTerminal { /// The read half of a test terminal, wrapping [`PtyReader`] with milestone support. pub struct Reader { - pty: PtyReader, + pty: BufReader, child_handle: ChildHandle, } @@ -37,13 +37,16 @@ impl TestTerminal { /// 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: pty_reader, child_handle } }) + Ok(Self { + writer: pty_writer, + reader: Reader { pty: BufReader::new(pty_reader), child_handle }, + }) } } impl Reader { fn sanitized_screen_contents(&self) -> String { - self.pty.screen_contents().replace(MILESTONE_FENCE_CHAR, "") + self.pty.get_ref().screen_contents().replace(MILESTONE_FENCE_CHAR, "") } /// Reads from the PTY until a milestone with the given name is encountered. @@ -69,39 +72,16 @@ impl Reader { milestone.extend_from_slice(name.as_bytes()); milestone.push(0x07); // BEL terminator - let fence: &[u8] = pty_terminal_test_client::MILESTONE_FENCE; - - let mut buf = [0u8; 4096]; - let mut milestone_match = 0usize; - let mut found_milestone = false; - let mut fence_match = 0usize; + let fence = pty_terminal_test_client::MILESTONE_FENCE; + let fence_last_byte = fence[fence.len() - 1]; + let mut buf = Vec::new(); loop { - let n = self.pty.read(&mut buf).expect("PTY read failed"); + let n = self.pty.read_until(fence_last_byte, &mut buf).expect("PTY read failed"); assert!(n > 0, "EOF reached before milestone '{name}'"); - for byte in &buf[..n] { - if !found_milestone { - if *byte == milestone[milestone_match] { - milestone_match += 1; - if milestone_match == milestone.len() { - found_milestone = true; - fence_match = 0; - } - } else { - milestone_match = usize::from(*byte == milestone[0]); - } - continue; - } - - if *byte == fence[fence_match] { - fence_match += 1; - if fence_match == fence.len() { - return self.sanitized_screen_contents(); - } - } else { - fence_match = usize::from(*byte == fence[0]); - } + if buf.ends_with(fence) && buf.windows(milestone.len()).any(|w| w == milestone) { + return self.sanitized_screen_contents(); } } } @@ -116,14 +96,4 @@ impl Reader { self.pty.read_to_end(&mut discard).expect("PTY read_to_end failed"); self.child_handle.wait() } - - /// Returns the current terminal screen contents. - /// - /// # Panics - /// - /// Panics if the parser lock is poisoned. - #[must_use] - pub fn screen_contents(&self) -> String { - self.sanitized_screen_contents() - } } diff --git a/crates/pty_terminal_test_client/src/lib.rs b/crates/pty_terminal_test_client/src/lib.rs index eb0b1ae6..5c8294bc 100644 --- a/crates/pty_terminal_test_client/src/lib.rs +++ b/crates/pty_terminal_test_client/src/lib.rs @@ -23,7 +23,8 @@ pub const MILESTONE_FENCE: &[u8] = "\u{200b}".as_bytes(); /// Each milestone also appends a non-visual zero-width-space fence (`U+200B`, /// UTF-8 `E2 80 8B`). This keeps a uniform protocol across platforms. On /// Windows, waiting for this rendered character after the OSC confirms prior -/// rendered output has been consumed. +/// rendered output has been consumed. This marker was also confirmed not to +/// advance cursor position on both macOS and Windows in PTY probe runs. /// /// When the `testing` feature is disabled, this is a no-op. /// From c73140050e02ceff5d7dbd478d79073385c931b8 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 11 Feb 2026 23:41:53 +0800 Subject: [PATCH 07/24] Fix milestone screen sanitization clippy lint --- crates/pty_terminal_test/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/pty_terminal_test/src/lib.rs b/crates/pty_terminal_test/src/lib.rs index 13d09c44..7c2f1dd7 100644 --- a/crates/pty_terminal_test/src/lib.rs +++ b/crates/pty_terminal_test/src/lib.rs @@ -46,7 +46,9 @@ impl TestTerminal { impl Reader { fn sanitized_screen_contents(&self) -> String { - self.pty.get_ref().screen_contents().replace(MILESTONE_FENCE_CHAR, "") + let mut screen = self.pty.get_ref().screen_contents(); + screen.retain(|ch| ch != MILESTONE_FENCE_CHAR); + screen } /// Reads from the PTY until a milestone with the given name is encountered. From e0c67a977be18029dc5a053879568e12fef07ea5 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 08:50:21 +0800 Subject: [PATCH 08/24] chore: restore AGENTS.md --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) create mode 120000 AGENTS.md 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 From a6d84026b0c8048708cd47a065757e096dc24e5e Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 08:52:43 +0800 Subject: [PATCH 09/24] test: switch milestone fence to DECSET and drop cpr wait --- crates/pty_terminal_test/src/lib.rs | 17 ++++------------- crates/pty_terminal_test_client/src/lib.rs | 14 +++++++------- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/crates/pty_terminal_test/src/lib.rs b/crates/pty_terminal_test/src/lib.rs index 7c2f1dd7..6846c3a4 100644 --- a/crates/pty_terminal_test/src/lib.rs +++ b/crates/pty_terminal_test/src/lib.rs @@ -10,8 +10,6 @@ pub use pty_terminal::{ /// The OSC parameter ID that identifies milestone sequences. const MILESTONE_OSC_ID: &[u8] = pty_terminal_test_client::MILESTONE_OSC_ID; -/// The zero-width fence character appended by `mark_milestone`. -const MILESTONE_FENCE_CHAR: char = '\u{200b}'; /// A test-oriented terminal that provides milestone-based synchronization. /// @@ -45,21 +43,14 @@ impl TestTerminal { } impl Reader { - fn sanitized_screen_contents(&self) -> String { - let mut screen = self.pty.get_ref().screen_contents(); - screen.retain(|ch| ch != MILESTONE_FENCE_CHAR); - screen - } - /// 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: OSC marker followed - /// by a non-visual zero-width-space fence. - /// On Windows, `ConPTY` may deliver unrecognized OSC before rendered output; - /// waiting for the expected fence guarantees prior rendered output has been - /// consumed. + /// by a non-visual DECSET 2026 on/off fence sequence. + /// The fence is used as a stream delimiter so milestone parsing can detect + /// marker boundaries without polluting screen contents. /// /// # Panics /// @@ -83,7 +74,7 @@ impl Reader { assert!(n > 0, "EOF reached before milestone '{name}'"); if buf.ends_with(fence) && buf.windows(milestone.len()).any(|w| w == milestone) { - return self.sanitized_screen_contents(); + return self.pty.get_ref().screen_contents(); } } } diff --git a/crates/pty_terminal_test_client/src/lib.rs b/crates/pty_terminal_test_client/src/lib.rs index 5c8294bc..743db5ca 100644 --- a/crates/pty_terminal_test_client/src/lib.rs +++ b/crates/pty_terminal_test_client/src/lib.rs @@ -1,12 +1,13 @@ /// The OSC parameter ID used for the milestone protocol. /// /// Milestone OSC format: `ESC ] 9999 ; BEL` +/// followed by the fence sequence `ESC [ ? 2026 h ESC [ ? 2026 l`. /// /// The vt100 parser splits by `;`, so `unhandled_osc` receives /// `params = [b"9999", b""]`. pub const MILESTONE_OSC_ID: &[u8] = b"9999"; -/// A non-visual UTF-8 fence appended after each milestone OSC. -pub const MILESTONE_FENCE: &[u8] = "\u{200b}".as_bytes(); +/// A non-visual fence appended after each milestone OSC sequence. +pub const MILESTONE_FENCE: &[u8] = b"\x1b[?2026h\x1b[?2026l"; /// Emits a milestone marker as a private OSC escape sequence. /// @@ -20,11 +21,8 @@ pub const MILESTONE_FENCE: &[u8] = "\u{200b}".as_bytes(); /// that polls the console buffer. This means the OSC can arrive at the /// reader before preceding character output has been emitted. /// -/// Each milestone also appends a non-visual zero-width-space fence (`U+200B`, -/// UTF-8 `E2 80 8B`). This keeps a uniform protocol across platforms. On -/// Windows, waiting for this rendered character after the OSC confirms prior -/// rendered output has been consumed. This marker was also confirmed not to -/// advance cursor position on both macOS and Windows in PTY probe runs. +/// Milestones append a non-visual fence sequence after OSC so the reader can +/// delimit milestone boundaries from the raw PTY stream. /// /// When the `testing` feature is disabled, this is a no-op. /// @@ -36,6 +34,8 @@ pub fn mark_milestone(name: &str) { use std::io::{Write, stdout}; let mut stdout = stdout(); + // Flush prior output, then emit milestone sequence. + stdout.flush().unwrap(); write!(stdout, "\x1b]9999;{name}\x07").unwrap(); stdout.write_all(MILESTONE_FENCE).unwrap(); From 5f4cb6a60372a6e7828b13cbc2b308f5deb791da Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 11:02:35 +0800 Subject: [PATCH 10/24] Use OSC8 parser state for milestone detection --- crates/pty_terminal_test/src/lib.rs | 47 ++++++------ crates/pty_terminal_test_client/src/lib.rs | 88 ++++++++++++++++++---- 2 files changed, 97 insertions(+), 38 deletions(-) diff --git a/crates/pty_terminal_test/src/lib.rs b/crates/pty_terminal_test/src/lib.rs index 6846c3a4..04abc43b 100644 --- a/crates/pty_terminal_test/src/lib.rs +++ b/crates/pty_terminal_test/src/lib.rs @@ -1,4 +1,4 @@ -use std::io::{BufRead, BufReader, Read}; +use std::io::{BufReader, Read}; pub use portable_pty::CommandBuilder; use pty_terminal::terminal::{PtyReader, Terminal}; @@ -8,9 +8,6 @@ pub use pty_terminal::{ terminal::{ChildHandle, PtyWriter}, }; -/// The OSC parameter ID that identifies milestone sequences. -const MILESTONE_OSC_ID: &[u8] = pty_terminal_test_client::MILESTONE_OSC_ID; - /// A test-oriented terminal that provides milestone-based synchronization. /// /// Wraps a PTY terminal, splitting it into a [`PtyWriter`] for sending input @@ -47,10 +44,11 @@ impl Reader { /// /// Returns the terminal screen contents at the moment the milestone is detected. /// - /// Milestones use a uniform protocol across platforms: OSC marker followed - /// by a non-visual DECSET 2026 on/off fence sequence. - /// The fence is used as a stream delimiter so milestone parsing can detect - /// marker boundaries without polluting screen contents. + /// 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 /// @@ -58,24 +56,27 @@ impl Reader { /// or if a read error occurs. #[must_use] pub fn expect_milestone(&mut self, name: &str) -> String { - let mut milestone = Vec::with_capacity(8 + MILESTONE_OSC_ID.len() + name.len()); - milestone.extend_from_slice(b"\x1b]"); - milestone.extend_from_slice(MILESTONE_OSC_ID); - milestone.extend_from_slice(b";"); - milestone.extend_from_slice(name.as_bytes()); - milestone.push(0x07); // BEL terminator - - let fence = pty_terminal_test_client::MILESTONE_FENCE; - let fence_last_byte = fence[fence.len() - 1]; - let mut buf = Vec::new(); + const MILESTONE_HYPERTEXT: char = '\u{200b}'; + let mut buf = [0u8; 4096]; loop { - let n = self.pty.read_until(fence_last_byte, &mut buf).expect("PTY read failed"); - assert!(n > 0, "EOF reached before milestone '{name}'"); - - if buf.ends_with(fence) && buf.windows(milestone.len()).any(|w| w == milestone) { - return self.pty.get_ref().screen_contents(); + 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 { + let mut contents = self.pty.get_ref().screen_contents(); + contents.retain(|ch| ch != MILESTONE_HYPERTEXT); + return contents; } + + let n = self.pty.read(&mut buf).expect("PTY read failed"); + assert!(n > 0, "EOF reached before milestone '{name}'"); } } diff --git a/crates/pty_terminal_test_client/src/lib.rs b/crates/pty_terminal_test_client/src/lib.rs index 743db5ca..c82b2448 100644 --- a/crates/pty_terminal_test_client/src/lib.rs +++ b/crates/pty_terminal_test_client/src/lib.rs @@ -1,28 +1,86 @@ -/// The OSC parameter ID used for the milestone protocol. +/// 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. /// -/// Milestone OSC format: `ESC ] 9999 ; BEL` -/// followed by the fence sequence `ESC [ ? 2026 h ESC [ ? 2026 l`. +/// 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() +} + +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. /// -/// The vt100 parser splits by `;`, so `unhandled_osc` receives -/// `params = [b"9999", b""]`. -pub const MILESTONE_OSC_ID: &[u8] = b"9999"; -/// A non-visual fence appended after each milestone OSC sequence. -pub const MILESTONE_FENCE: &[u8] = b"\x1b[?2026h\x1b[?2026l"; +/// 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; + } -/// Emits a milestone marker as a private OSC escape sequence. + 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 unrecognized OSC sequences directly to the +/// 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 OSC can arrive at the +/// that polls the console buffer. This means the marker can arrive at the /// reader before preceding character output has been emitted. /// -/// Milestones append a non-visual fence sequence after OSC so the reader can -/// delimit milestone boundaries from the raw PTY stream. +/// 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. /// @@ -33,11 +91,11 @@ pub const MILESTONE_FENCE: &[u8] = b"\x1b[?2026h\x1b[?2026l"; 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(); - write!(stdout, "\x1b]9999;{name}\x07").unwrap(); - stdout.write_all(MILESTONE_FENCE).unwrap(); + stdout.write_all(&milestone).unwrap(); stdout.flush().unwrap(); } From e568f8438293d2b1e2cba41b9f090ed87b562b93 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 11:36:18 +0800 Subject: [PATCH 11/24] Add readmes for pty terminal test crates --- crates/pty_terminal_test/README.md | 86 +++++++++++++++++++++++ crates/pty_terminal_test_client/README.md | 11 +++ 2 files changed, 97 insertions(+) create mode 100644 crates/pty_terminal_test/README.md create mode 100644 crates/pty_terminal_test_client/README.md diff --git a/crates/pty_terminal_test/README.md b/crates/pty_terminal_test/README.md new file mode 100644 index 00000000..c07eee84 --- /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 } = + 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_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` From dff5443bb14158dcc4ff8bdf0b4bafec8ad96ac5 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 12:19:04 +0800 Subject: [PATCH 12/24] feat: add vp interact for e2e interaction snapshots --- Cargo.lock | 3 + crates/pty_terminal_test/README.md | 2 +- crates/pty_terminal_test/src/lib.rs | 19 +- crates/pty_terminal_test/tests/milestone.rs | 4 +- crates/pty_terminal_test_client/src/lib.rs | 4 +- crates/vite_task_bin/Cargo.toml | 3 + crates/vite_task_bin/src/lib.rs | 2 + crates/vite_task_bin/src/main.rs | 124 ++++++- .../tests/e2e_snapshots/README.md | 28 +- .../fixtures/interactions-no-vp/package.json | 4 + .../interactions-no-vp/snapshots.toml | 6 + .../snapshots/interactions without vp.snap | 90 +++++ .../vite_task_bin/tests/e2e_snapshots/main.rs | 328 +++++++++++++++--- 13 files changed, 551 insertions(+), 66 deletions(-) create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots/interactions without vp.snap diff --git a/Cargo.lock b/Cargo.lock index 21cec4a2..80566910 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3836,9 +3836,12 @@ dependencies = [ "clap", "copy_dir", "cow-utils", + "crossterm", "insta", "jsonc-parser", "pty_terminal", + "pty_terminal_test", + "pty_terminal_test_client", "regex", "rustc-hash", "serde", diff --git a/crates/pty_terminal_test/README.md b/crates/pty_terminal_test/README.md index c07eee84..ab97238c 100644 --- a/crates/pty_terminal_test/README.md +++ b/crates/pty_terminal_test/README.md @@ -27,7 +27,7 @@ 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 } = +let TestTerminal { mut writer, mut reader, child_handle: _ } = TestTerminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd)?; // Wait until child reaches a known point. diff --git a/crates/pty_terminal_test/src/lib.rs b/crates/pty_terminal_test/src/lib.rs index 04abc43b..3f53c69d 100644 --- a/crates/pty_terminal_test/src/lib.rs +++ b/crates/pty_terminal_test/src/lib.rs @@ -8,6 +8,8 @@ pub use pty_terminal::{ 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 @@ -16,6 +18,7 @@ pub use pty_terminal::{ 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. @@ -34,12 +37,21 @@ impl TestTerminal { 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 }, + 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. @@ -56,7 +68,6 @@ impl Reader { /// or if a read error occurs. #[must_use] pub fn expect_milestone(&mut self, name: &str) -> String { - const MILESTONE_HYPERTEXT: char = '\u{200b}'; let mut buf = [0u8; 4096]; loop { @@ -70,9 +81,7 @@ impl Reader { }) .any(|decoded| decoded == name); if found { - let mut contents = self.pty.get_ref().screen_contents(); - contents.retain(|ch| ch != MILESTONE_HYPERTEXT); - return contents; + return self.screen_contents(); } let n = self.pty.read(&mut buf).expect("PTY read failed"); diff --git a/crates/pty_terminal_test/tests/milestone.rs b/crates/pty_terminal_test/tests/milestone.rs index 807c2f52..234519bf 100644 --- a/crates/pty_terminal_test/tests/milestone.rs +++ b/crates/pty_terminal_test/tests/milestone.rs @@ -40,7 +40,7 @@ fn milestone_raw_mode_keystrokes() { crossterm::terminal::disable_raw_mode().unwrap(); })); - let TestTerminal { mut writer, mut reader } = + let TestTerminal { mut writer, mut reader, child_handle: _ } = TestTerminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); // Wait for the subprocess to be ready @@ -105,7 +105,7 @@ fn milestone_does_not_pollute_screen() { crossterm::terminal::disable_raw_mode().unwrap(); })); - let TestTerminal { mut writer, mut reader } = + let TestTerminal { mut writer, mut reader, child_handle: _ } = TestTerminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); let _ = reader.expect_milestone("ready"); diff --git a/crates/pty_terminal_test_client/src/lib.rs b/crates/pty_terminal_test_client/src/lib.rs index c82b2448..921f37ab 100644 --- a/crates/pty_terminal_test_client/src/lib.rs +++ b/crates/pty_terminal_test_client/src/lib.rs @@ -27,7 +27,7 @@ pub fn encoded_milestone(name: &str) -> Vec { seq.into_bytes() } -fn decode_hex_nibble(byte: u8) -> Option { +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), @@ -79,7 +79,7 @@ pub fn decode_milestone_from_osc8_params(params: &[Vec]) -> Option { /// 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 +/// 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. diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml index c4ca6962..f08ebbbb 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"] } @@ -28,6 +30,7 @@ copy_dir = { workspace = true } cow-utils = { workspace = true } insta = { workspace = true, features = ["glob", "json", "redactions", "filters", "ron"] } 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..b5f29300 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,120 @@ 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 mut raw_mode = RawModeGuard::new(stdin_is_tty)?; + + let mut stdin = std::io::stdin(); + let mut stdout = std::io::stdout(); + let mut text_buffer = Vec::::new(); + + 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 byte == 0x1b { + let mut seq = [0u8; 2]; + if stdin.read_exact(&mut seq).is_err() { + break; + } + + if seq == [b'[', b'A'] { + write_line(&mut stdout, b"KEY:UP")?; + write_milestone(&mut stdout, "after-up")?; + } else if seq == [b'[', b'B'] { + write_line(&mut stdout, b"KEY:DOWN")?; + write_milestone(&mut stdout, "after-down")?; + } + 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..c80895d6 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 PTY and 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" }] }, + { command = "node check-stdin.js", pty = false }, +] +``` + +Notes: + +- String steps are shorthand for `{ command = "...", pty = true }`. +- `interactions` are only valid when `pty = true`. +- `write-key` accepts `up`, `down`, and `enter`. +- Snapshots include every interaction line, and each `expect-milestone` records the screen at that point. + 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 by default (`TestTerminal`) unless `pty = false` (pipe stdio) +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..9a212f6d --- /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" }, { "expect-milestone" = "after-up" }, { "write-key" = "down" }, { "expect-milestone" = "after-down" }, { "write-key" = "enter" }, { "expect-milestone" = "after-enter" }] }, + { command = "node -e \"console.log('PIPE_STDIN_IS_TTY:' + String(Boolean(process.stdin.isTTY)))\"", pty = false }, +] 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..2abd1c58 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots/interactions without vp.snap @@ -0,0 +1,90 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +input_file: crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp +--- +> 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 +@ expect-milestone: after-up +START +CHAR:h +CHAR:e +CHAR:l +CHAR:l +CHAR:o +CHAR:h +CHAR:e +CHAR:l +CHAR:l +CHAR:o +LINE:hellohello +KEY:UP +@ write-key: down +@ expect-milestone: after-down +START +CHAR:h +CHAR:e +CHAR:l +CHAR:l +CHAR:o +CHAR:h +CHAR:e +CHAR:l +CHAR:l +CHAR:o +LINE:hellohello +KEY:UP +KEY:DOWN +@ 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 +KEY:UP +KEY:DOWN +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 +KEY:UP +KEY:DOWN +KEY:ENTER +DONE +> 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 db7e2ffb..0594a269 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -3,17 +3,15 @@ mod redact; use std::{ env::{self, join_paths, split_paths}, ffi::OsStr, - io::Read, + io::Write, + process::Stdio, sync::{Arc, mpsc}, time::Duration, }; use copy_dir::copy_dir; -use pty_terminal::{ - ExitStatus, - geo::ScreenSize, - terminal::{CommandBuilder, Terminal}, -}; +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; @@ -55,9 +53,111 @@ fn get_shell_exe() -> std::path::PathBuf { } } +const fn default_true() -> bool { + true +} + #[derive(serde::Deserialize, Debug)] -#[serde(transparent)] -struct Step(Str); +#[serde(untagged)] +enum Step { + Command(Str), + Detailed(StepConfig), +} + +#[derive(serde::Deserialize, Debug)] +#[serde(deny_unknown_fields)] +struct StepConfig { + command: Str, + #[serde(default = "default_true")] + pty: bool, + #[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(), + } + } + + const fn pty(&self) -> bool { + match self { + Self::Command(_) => true, + Self::Detailed(config) => config.pty, + } + } + + 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 { @@ -103,10 +203,26 @@ fn run_case(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, filter: Optio } enum TerminationState { - Exited(ExitStatus), + Exited(i64), TimedOut, } +fn kill_process(pid: u32) { + let pid_arg = vite_str::format!("{pid}"); + + #[cfg(unix)] + { + let _ = std::process::Command::new("kill").arg("-9").arg(pid_arg.as_str()).status(); + } + + #[cfg(windows)] + { + let _ = std::process::Command::new("taskkill") + .args(["/PID", pid_arg.as_str(), "/T", "/F"]) + .status(); + } +} + #[expect( clippy::too_many_lines, reason = "e2e test runner with process management necessarily has many lines" @@ -191,45 +307,156 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture let mut e2e_outputs = String::new(); for step in &e2e.steps { - let mut cmd = CommandBuilder::new(&shell_exe); - cmd.arg("-c"); - cmd.arg(step.0.as_str()); - cmd.env_clear(); - cmd.env("PATH", &e2e_env_path); - cmd.env("NO_COLOR", "1"); - cmd.env("TERM", "dumb"); - cmd.cwd(e2e_stage_path.join(&e2e.cwd).as_path()); - - // On Windows, inherit PATHEXT for executable lookup - if cfg!(windows) - && let Ok(pathext) = std::env::var("PATHEXT") - { - cmd.env("PATHEXT", pathext); - } + assert!( + !(!step.pty() && !step.interactions().is_empty()), + "Step '{}' sets pty = false but also defines interactions; interactions require pty = true", + step.command() + ); + + let step_command = step.command(); + let (termination_state, output) = if step.pty() { + let mut cmd = CommandBuilder::new(&shell_exe); + cmd.arg("-c"); + cmd.arg(step_command); + cmd.env_clear(); + cmd.env("PATH", &e2e_env_path); + cmd.env("NO_COLOR", "1"); + cmd.env("TERM", "dumb"); + cmd.cwd(e2e_stage_path.join(&e2e.cwd).as_path()); + + // On Windows, inherit PATHEXT for executable lookup + if cfg!(windows) + && let Ok(pathext) = std::env::var("PATHEXT") + { + cmd.env("PATHEXT", pathext); + } - let terminal = Terminal::spawn(SCREEN_SIZE, cmd).unwrap(); - - // Read to end on a separate thread with timeout via channel - let mut killer = terminal.child_handle.clone(); - let (tx, rx) = mpsc::channel(); - std::thread::spawn(move || { - let Terminal { mut pty_reader, pty_writer: _pty_writer, child_handle } = terminal; - let mut discard = Vec::new(); - let read_result = pty_reader.read_to_end(&mut discard); - let screen = pty_reader.screen_contents(); - let status = read_result.map(|_| child_handle.wait()); - let _ = tx.send((status, screen)); - }); - - let (termination_state, screen) = match rx.recv_timeout(STEP_TIMEOUT) { - Ok((status, screen)) => (TerminationState::Exited(status.unwrap()), screen), - Err(mpsc::RecvTimeoutError::Timeout) => { - let _ = killer.kill(); - let (_, screen) = rx.recv().unwrap(); - (TerminationState::TimedOut, screen) + let terminal = TestTerminal::spawn(SCREEN_SIZE, cmd).unwrap(); + let mut killer = terminal.child_handle.clone(); + let interactions = step.interactions().to_vec(); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut terminal = terminal; + let mut interaction_output = String::new(); + + for interaction in interactions { + match interaction { + Interaction::ExpectMilestone(expect) => { + interaction_output.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()); + interaction_output.push_str(&milestone_screen); + interaction_output.push('\n'); + } + Interaction::Write(write) => { + interaction_output.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) => { + interaction_output.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(); + interaction_output.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 = interaction_output; + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + output.push_str(&screen); + + let _ = tx.send((i64::from(status.exit_code()), output)); + }); + + match rx.recv_timeout(STEP_TIMEOUT) { + Ok((exit_code, output)) => (TerminationState::Exited(exit_code), output), + Err(mpsc::RecvTimeoutError::Timeout) => { + let _ = killer.kill(); + let (_, output) = rx.recv().unwrap(); + (TerminationState::TimedOut, output) + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + panic!("Terminal thread panicked"); + } } - Err(mpsc::RecvTimeoutError::Disconnected) => { - panic!("Terminal thread panicked"); + } else { + let mut cmd = std::process::Command::new(&shell_exe); + cmd.arg("-c"); + cmd.arg(step_command); + cmd.env_clear(); + cmd.env("PATH", &e2e_env_path); + cmd.env("NO_COLOR", "1"); + cmd.env("TERM", "dumb"); + cmd.current_dir(e2e_stage_path.join(&e2e.cwd).as_path()); + cmd.stdin(Stdio::piped()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + // On Windows, inherit PATHEXT for executable lookup + if cfg!(windows) + && let Ok(pathext) = std::env::var("PATHEXT") + { + cmd.env("PATHEXT", pathext); + } + + let child = cmd.spawn().unwrap(); + let pid = child.id(); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let output = child.wait_with_output(); + let _ = tx.send(output); + }); + + match rx.recv_timeout(STEP_TIMEOUT) { + Ok(output) => { + let output = output.unwrap(); + let mut combined_output = + String::from_utf8_lossy(&output.stdout).into_owned(); + combined_output.push_str(String::from_utf8_lossy(&output.stderr).as_ref()); + + let exit_code = i64::from(output.status.code().unwrap_or(1)); + (TerminationState::Exited(exit_code), combined_output) + } + Err(mpsc::RecvTimeoutError::Timeout) => { + kill_process(pid); + let output = rx.recv().unwrap().unwrap(); + + let mut combined_output = + String::from_utf8_lossy(&output.stdout).into_owned(); + combined_output.push_str(String::from_utf8_lossy(&output.stderr).as_ref()); + + (TerminationState::TimedOut, combined_output) + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + panic!("Process wait thread panicked"); + } } }; @@ -238,19 +465,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 From 0ab892bec6a6edf2b95b44729d6e2cd4c7f7a82d Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 12:36:29 +0800 Subject: [PATCH 13/24] test: stabilize write_after_exit on CI --- crates/pty_terminal/tests/terminal.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/pty_terminal/tests/terminal.rs b/crates/pty_terminal/tests/terminal.rs index 2db77220..4015fb69 100644 --- a/crates/pty_terminal/tests/terminal.rs +++ b/crates/pty_terminal/tests/terminal.rs @@ -1,4 +1,7 @@ -use std::io::{BufRead, BufReader, IsTerminal, Read, 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; @@ -118,11 +121,16 @@ fn write_after_exit() { pty_reader.read_to_end(&mut discard).unwrap(); let _ = child_handle.wait(); - // 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 BrokenPipe - let result = pty_writer.write_all(b"too late\n"); - assert!(result.is_err()); + // Writer shutdown is done by a background thread after child wait returns. + // Poll briefly to avoid a race where write occurs before shutdown is observed. + let deadline = Instant::now() + Duration::from_millis(300); + loop { + if pty_writer.write_all(b"too late\n").is_err() { + break; + } + assert!(Instant::now() <= deadline, "writer did not close after child exit"); + std::thread::yield_now(); + } } #[test] From ceaa62704cb7d718ac4d446146adb5e3c20f6bbe Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 13:10:36 +0800 Subject: [PATCH 14/24] fix: avoid blocking writes in write_after_exit test --- crates/pty_terminal/src/terminal.rs | 10 ++++++++++ crates/pty_terminal/tests/terminal.rs | 10 +++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/pty_terminal/src/terminal.rs b/crates/pty_terminal/src/terminal.rs index b68a4625..9e44a10c 100644 --- a/crates/pty_terminal/src/terminal.rs +++ b/crates/pty_terminal/src/terminal.rs @@ -151,6 +151,16 @@ impl PtyReader { } 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 `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. diff --git a/crates/pty_terminal/tests/terminal.rs b/crates/pty_terminal/tests/terminal.rs index 4015fb69..76435d91 100644 --- a/crates/pty_terminal/tests/terminal.rs +++ b/crates/pty_terminal/tests/terminal.rs @@ -122,15 +122,15 @@ fn write_after_exit() { let _ = child_handle.wait(); // Writer shutdown is done by a background thread after child wait returns. - // Poll briefly to avoid a race where write occurs before shutdown is observed. + // Poll briefly for the writer state to flip to closed before asserting write failure. let deadline = Instant::now() + Duration::from_millis(300); - loop { - if pty_writer.write_all(b"too late\n").is_err() { - break; - } + 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()); } #[test] From 432d5ac290243d14a36aa69c07ddfa8d5f6a265e Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 13:41:33 +0800 Subject: [PATCH 15/24] fix: avoid blocking on e2e step timeout --- .../vite_task_bin/tests/e2e_snapshots/main.rs | 146 +++++++++++------- 1 file changed, 91 insertions(+), 55 deletions(-) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 0594a269..90c55a33 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -3,10 +3,10 @@ mod redact; use std::{ env::{self, join_paths, split_paths}, ffi::OsStr, - io::Write, + io::{Read, Write}, process::Stdio, - sync::{Arc, mpsc}, - time::Duration, + sync::{Arc, Mutex, mpsc}, + time::{Duration, Instant}, }; use copy_dir::copy_dir; @@ -207,20 +207,8 @@ enum TerminationState { TimedOut, } -fn kill_process(pid: u32) { - let pid_arg = vite_str::format!("{pid}"); - - #[cfg(unix)] - { - let _ = std::process::Command::new("kill").arg("-9").arg(pid_arg.as_str()).status(); - } - - #[cfg(windows)] - { - let _ = std::process::Command::new("taskkill") - .args(["/PID", pid_arg.as_str(), "/T", "/F"]) - .status(); - } +fn kill_process(child: &mut std::process::Child) { + let _ = child.kill(); } #[expect( @@ -334,15 +322,16 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture 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 mut terminal = terminal; - let mut interaction_output = String::new(); for interaction in interactions { match interaction { Interaction::ExpectMilestone(expect) => { - interaction_output.push_str( + output_for_thread.lock().unwrap().push_str( vite_str::format!( "@ expect-milestone: {}\n", expect.expect_milestone @@ -352,18 +341,19 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture let milestone_screen = terminal .reader .expect_milestone(expect.expect_milestone.as_str()); - interaction_output.push_str(&milestone_screen); - interaction_output.push('\n'); + let mut output = output_for_thread.lock().unwrap(); + output.push_str(&milestone_screen); + output.push('\n'); } Interaction::Write(write) => { - interaction_output.push_str( + 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) => { - interaction_output.push_str( + output_for_thread.lock().unwrap().push_str( vite_str::format!("@ write-line: {}\n", write_line.write_line) .as_str(), ); @@ -374,7 +364,7 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture } Interaction::WriteKey(write_key) => { let key_name = write_key.write_key.as_str(); - interaction_output.push_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(); @@ -386,20 +376,25 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture let status = terminal.reader.wait_for_exit(); let screen = terminal.reader.screen_contents(); - let mut output = interaction_output; - if !output.is_empty() && !output.ends_with('\n') { - output.push('\n'); + { + let mut output = output_for_thread.lock().unwrap(); + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + output.push_str(&screen); } - output.push_str(&screen); - let _ = tx.send((i64::from(status.exit_code()), output)); + let _ = tx.send(i64::from(status.exit_code())); }); match rx.recv_timeout(STEP_TIMEOUT) { - Ok((exit_code, output)) => (TerminationState::Exited(exit_code), output), + Ok(exit_code) => { + let output = output.lock().unwrap().clone(); + (TerminationState::Exited(exit_code), output) + } Err(mpsc::RecvTimeoutError::Timeout) => { let _ = killer.kill(); - let (_, output) = rx.recv().unwrap(); + let output = output.lock().unwrap().clone(); (TerminationState::TimedOut, output) } Err(mpsc::RecvTimeoutError::Disconnected) => { @@ -426,37 +421,78 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture cmd.env("PATHEXT", pathext); } - let child = cmd.spawn().unwrap(); - let pid = child.id(); - let (tx, rx) = mpsc::channel(); - std::thread::spawn(move || { - let output = child.wait_with_output(); - let _ = tx.send(output); + let mut child = cmd.spawn().unwrap(); + + let stdout_output = Arc::new(Mutex::new(Vec::::new())); + let stderr_output = Arc::new(Mutex::new(Vec::::new())); + + let stdout = child.stdout.take().unwrap(); + let stdout_output_for_thread = Arc::clone(&stdout_output); + let stdout_thread = std::thread::spawn(move || { + let mut stdout = stdout; + let mut buf = [0u8; 4096]; + loop { + match stdout.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + stdout_output_for_thread + .lock() + .unwrap() + .extend_from_slice(&buf[..n]); + } + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => break, + } + } }); - match rx.recv_timeout(STEP_TIMEOUT) { - Ok(output) => { - let output = output.unwrap(); - let mut combined_output = - String::from_utf8_lossy(&output.stdout).into_owned(); - combined_output.push_str(String::from_utf8_lossy(&output.stderr).as_ref()); - - let exit_code = i64::from(output.status.code().unwrap_or(1)); - (TerminationState::Exited(exit_code), combined_output) + let stderr = child.stderr.take().unwrap(); + let stderr_output_for_thread = Arc::clone(&stderr_output); + let stderr_thread = std::thread::spawn(move || { + let mut stderr = stderr; + let mut buf = [0u8; 4096]; + loop { + match stderr.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + stderr_output_for_thread + .lock() + .unwrap() + .extend_from_slice(&buf[..n]); + } + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => break, + } } - Err(mpsc::RecvTimeoutError::Timeout) => { - kill_process(pid); - let output = rx.recv().unwrap().unwrap(); + }); - let mut combined_output = - String::from_utf8_lossy(&output.stdout).into_owned(); - combined_output.push_str(String::from_utf8_lossy(&output.stderr).as_ref()); + let snapshot_output = || { + let stdout = { stdout_output.lock().unwrap().clone() }; + let stderr = { stderr_output.lock().unwrap().clone() }; - (TerminationState::TimedOut, combined_output) + let mut combined_output = String::from_utf8_lossy(&stdout).into_owned(); + combined_output.push_str(String::from_utf8_lossy(&stderr).as_ref()); + combined_output + }; + + let start = Instant::now(); + loop { + if let Some(status) = child.try_wait().unwrap() { + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + let combined_output = snapshot_output(); + + let exit_code = i64::from(status.code().unwrap_or(1)); + break (TerminationState::Exited(exit_code), combined_output); } - Err(mpsc::RecvTimeoutError::Disconnected) => { - panic!("Process wait thread panicked"); + + if start.elapsed() >= STEP_TIMEOUT { + kill_process(&mut child); + let combined_output = snapshot_output(); + break (TerminationState::TimedOut, combined_output); } + + std::thread::park_timeout(Duration::from_millis(10)); } }; From f2f93516adf8930874f8d457fe5f7f33bde18b9b Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 13:54:34 +0800 Subject: [PATCH 16/24] test: increase e2e step timeout for windows stability --- crates/vite_task_bin/tests/e2e_snapshots/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 90c55a33..cddf2b46 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -18,7 +18,7 @@ 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 }; From 7cb26abe6112d51e1fd6bf7621e01e44fee6dc1f Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 14:38:19 +0800 Subject: [PATCH 17/24] fix: make interactions-no-vp work in cargo xtest --- crates/vite_task_bin/src/main.rs | 53 ++++++-- .../interactions-no-vp/snapshots.toml | 2 +- .../snapshots/interactions without vp.snap | 31 ++--- .../vite_task_bin/tests/e2e_snapshots/main.rs | 120 +++++++++++++++--- .../tests/e2e_snapshots/redact.rs | 4 + 5 files changed, 158 insertions(+), 52 deletions(-) diff --git a/crates/vite_task_bin/src/main.rs b/crates/vite_task_bin/src/main.rs index b5f29300..9cd4f95f 100644 --- a/crates/vite_task_bin/src/main.rs +++ b/crates/vite_task_bin/src/main.rs @@ -112,11 +112,15 @@ impl Drop for RawModeGuard { fn run_interact() -> anyhow::Result { let stdin_is_tty = std::io::stdin().is_terminal(); - let mut raw_mode = RawModeGuard::new(stdin_is_tty)?; + 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")?; @@ -129,19 +133,50 @@ fn run_interact() -> anyhow::Result { } let byte = byte[0]; - if byte == 0x1b { - let mut seq = [0u8; 2]; - if stdin.read_exact(&mut seq).is_err() { - break; + 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 seq == [b'[', b'A'] { - write_line(&mut stdout, b"KEY:UP")?; + if byte == 72 { write_milestone(&mut stdout, "after-up")?; - } else if seq == [b'[', b'B'] { - write_line(&mut stdout, b"KEY:DOWN")?; + 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; } 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 index 9a212f6d..0f757307 100644 --- 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 @@ -1,6 +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" }, { "expect-milestone" = "after-up" }, { "write-key" = "down" }, { "expect-milestone" = "after-down" }, { "write-key" = "enter" }, { "expect-milestone" = "after-enter" }] }, + { 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" }] }, { command = "node -e \"console.log('PIPE_STDIN_IS_TTY:' + String(Boolean(process.stdin.isTTY)))\"", pty = false }, ] 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 index 2abd1c58..6b7b1f3e 100644 --- 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 @@ -1,7 +1,6 @@ --- source: crates/vite_task_bin/tests/e2e_snapshots/main.rs expression: e2e_outputs -input_file: crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp --- > vp interact @ expect-milestone: ready @@ -22,22 +21,10 @@ CHAR:l CHAR:o LINE:hellohello @ write-key: up -@ expect-milestone: after-up -START -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -LINE:hellohello -KEY:UP @ write-key: down -@ expect-milestone: after-down +@ write: x +@ write-key: enter +@ expect-milestone: after-line START CHAR:h CHAR:e @@ -50,8 +37,8 @@ CHAR:l CHAR:l CHAR:o LINE:hellohello -KEY:UP -KEY:DOWN +CHAR:x +LINE:x @ write-key: enter @ expect-milestone: after-enter START @@ -66,8 +53,8 @@ CHAR:l CHAR:l CHAR:o LINE:hellohello -KEY:UP -KEY:DOWN +CHAR:x +LINE:x KEY:ENTER DONE START @@ -82,8 +69,8 @@ CHAR:l CHAR:l CHAR:o LINE:hellohello -KEY:UP -KEY:DOWN +CHAR:x +LINE:x KEY:ENTER DONE > node -e "console.log('PIPE_STDIN_IS_TTY:' + String(Boolean(process.stdin.isTTY)))" diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index cddf2b46..1f868e88 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -9,7 +9,6 @@ use std::{ time::{Duration, Instant}, }; -use copy_dir::copy_dir; use pty_terminal::{geo::ScreenSize, terminal::CommandBuilder}; use pty_terminal_test::TestTerminal; use redact::redact_e2e_output; @@ -23,6 +22,9 @@ 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_CARGO_BIN_EXE_VP: &str = env!("CARGO_BIN_EXE_vp"); +const COMPILE_TIME_CARGO_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. @@ -53,6 +55,65 @@ fn get_shell_exe() -> std::path::PathBuf { } } +#[expect( + clippy::disallowed_types, + reason = "Path types required for runtime path remapping between compile and runtime roots" +)] +fn runtime_manifest_dir() -> std::path::PathBuf { + std::env::var_os("CARGO_MANIFEST_DIR").map_or_else( + || std::path::PathBuf::from(COMPILE_TIME_CARGO_MANIFEST_DIR), + std::path::PathBuf::from, + ) +} + +#[expect( + clippy::disallowed_types, + reason = "Path types required for runtime path remapping between compile and runtime roots" +)] +fn relative_path_from(path: &std::path::Path, base: &std::path::Path) -> std::path::PathBuf { + use std::path::{Component, PathBuf}; + + let path_components = path.components().collect::>(); + let base_components = base.components().collect::>(); + + let common_prefix_len = path_components + .iter() + .zip(base_components.iter()) + .take_while(|(path_comp, base_comp)| path_comp == base_comp) + .count(); + + let mut relative_path = PathBuf::new(); + + for base_comp in &base_components[common_prefix_len..] { + match base_comp { + Component::Normal(_) | Component::CurDir | Component::ParentDir => { + relative_path.push(".."); + } + Component::RootDir | Component::Prefix(_) => {} + } + } + + for path_comp in &path_components[common_prefix_len..] { + relative_path.push(path_comp.as_os_str()); + } + + relative_path +} + +#[expect( + clippy::disallowed_types, + reason = "Path types required for runtime path remapping between compile and runtime roots" +)] +fn resolve_runtime_vp_path() -> AbsolutePathBuf { + let compile_time_vp = std::path::Path::new(COMPILE_TIME_CARGO_BIN_EXE_VP); + let compile_time_manifest = std::path::Path::new(COMPILE_TIME_CARGO_MANIFEST_DIR); + let vp_relative_path = relative_path_from(compile_time_vp, compile_time_manifest); + + let runtime_manifest = runtime_manifest_dir(); + let runtime_vp = runtime_manifest.join(vp_relative_path); + AbsolutePathBuf::new(runtime_vp).unwrap() +} + const fn default_true() -> bool { true } @@ -207,6 +268,29 @@ enum TerminationState { TimedOut, } +#[expect( + clippy::disallowed_types, + reason = "Path required for recursive fixture copy in test harness" +)] +fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let source_path = entry.path(); + let target_path = dst.join(entry.file_name()); + let file_type = entry.file_type()?; + + if file_type.is_dir() { + copy_dir_recursive(&source_path, &target_path)?; + } else if file_type.is_file() { + std::fs::copy(&source_path, &target_path)?; + } + } + + Ok(()) +} + fn kill_process(child: &mut std::process::Child) { let _ = child.kill(); } @@ -222,7 +306,7 @@ fn kill_process(child: &mut std::process::Child) { 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(); + copy_dir_recursive(fixture_path, stage_path.as_path()).unwrap(); let (workspace_root, _cwd) = find_workspace_root(&stage_path).unwrap(); @@ -238,13 +322,9 @@ 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 - #[expect( - clippy::disallowed_types, - reason = "Path required for CARGO_MANIFEST_DIR path manipulation via env! macro" - )] - let repo_root = - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().parent().unwrap(); + // Navigate from runtime CARGO_MANIFEST_DIR to packages/tools at the repo root. + let repo_root = runtime_manifest_dir(); + 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(), ); @@ -257,7 +337,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() }, @@ -289,7 +369,7 @@ 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()); + copy_dir_recursive(fixture_path, e2e_stage_path.as_path()).unwrap(); let e2e_stage_path_str = e2e_stage_path.as_path().to_str().unwrap(); @@ -530,20 +610,20 @@ 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 tmp_dir_path = AbsolutePathBuf::new(tmp_dir.path().to_path_buf()).unwrap(); - let tests_dir = std::env::current_dir().unwrap().join("tests"); + let fixtures_dir = runtime_manifest_dir().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(); - insta::glob!(tests_dir, "e2e_snapshots/fixtures/*", |case_path| { + 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. From bad7f2b04021069cd2c5bbc6888880eb6bb96bcf Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 14:41:09 +0800 Subject: [PATCH 18/24] chore: remove unused copy_dir dev-dependency --- crates/vite_task_bin/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml index f08ebbbb..5a820fd8 100644 --- a/crates/vite_task_bin/Cargo.toml +++ b/crates/vite_task_bin/Cargo.toml @@ -26,7 +26,6 @@ vite_task = { workspace = true } which = { workspace = true } [dev-dependencies] -copy_dir = { workspace = true } cow-utils = { workspace = true } insta = { workspace = true, features = ["glob", "json", "redactions", "filters", "ron"] } pty_terminal = { workspace = true } From 67ed310afb4b604c32ccfe6658ac2dee4a57767c Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 14:42:42 +0800 Subject: [PATCH 19/24] chore: update lockfile after dependency cleanup --- Cargo.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 80566910..a417ba11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3834,7 +3834,6 @@ dependencies = [ "anyhow", "async-trait", "clap", - "copy_dir", "cow-utils", "crossterm", "insta", From 5057ba45dfc0e096512ce7c613c2da76c76384c6 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 15:00:16 +0800 Subject: [PATCH 20/24] fix: fallback to compile-time vp path when remap misses --- crates/vite_task_bin/tests/e2e_snapshots/main.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 1f868e88..cd5a604f 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -111,7 +111,9 @@ fn resolve_runtime_vp_path() -> AbsolutePathBuf { let runtime_manifest = runtime_manifest_dir(); let runtime_vp = runtime_manifest.join(vp_relative_path); - AbsolutePathBuf::new(runtime_vp).unwrap() + let resolved_vp = if runtime_vp.exists() { runtime_vp } else { compile_time_vp.to_path_buf() }; + let resolved_vp = resolved_vp.canonicalize().unwrap_or(resolved_vp); + AbsolutePathBuf::new(resolved_vp).unwrap() } const fn default_true() -> bool { From 5d27fe8e311acdfcfbea1e1aa717137b103ae72f Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 15:21:17 +0800 Subject: [PATCH 21/24] fix: resolve e2e vp path from runtime executable --- .../vite_task_bin/tests/e2e_snapshots/main.rs | 68 +++++++------------ 1 file changed, 23 insertions(+), 45 deletions(-) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index cd5a604f..046ac2b7 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -22,7 +22,6 @@ 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_CARGO_BIN_EXE_VP: &str = env!("CARGO_BIN_EXE_vp"); const COMPILE_TIME_CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR"); /// Get the shell executable for running e2e test steps. @@ -60,60 +59,39 @@ fn get_shell_exe() -> std::path::PathBuf { reason = "Path types required for runtime path remapping between compile and runtime roots" )] fn runtime_manifest_dir() -> std::path::PathBuf { - std::env::var_os("CARGO_MANIFEST_DIR").map_or_else( + let manifest_dir = std::env::var_os("CARGO_MANIFEST_DIR").map_or_else( || std::path::PathBuf::from(COMPILE_TIME_CARGO_MANIFEST_DIR), std::path::PathBuf::from, - ) -} - -#[expect( - clippy::disallowed_types, - reason = "Path types required for runtime path remapping between compile and runtime roots" -)] -fn relative_path_from(path: &std::path::Path, base: &std::path::Path) -> std::path::PathBuf { - use std::path::{Component, PathBuf}; - - let path_components = path.components().collect::>(); - let base_components = base.components().collect::>(); - - let common_prefix_len = path_components - .iter() - .zip(base_components.iter()) - .take_while(|(path_comp, base_comp)| path_comp == base_comp) - .count(); - - let mut relative_path = PathBuf::new(); + ); - for base_comp in &base_components[common_prefix_len..] { - match base_comp { - Component::Normal(_) | Component::CurDir | Component::ParentDir => { - relative_path.push(".."); + #[cfg(windows)] + { + // In cargo-xtest with a Windows target executed via wine on Unix hosts, + // runtime CARGO_MANIFEST_DIR can be Unix-style (e.g. "/Volumes/..."). + // Map it to wine's Z: drive for Windows-native path resolution. + if manifest_dir.to_string_lossy().starts_with('/') { + let mut mapped = std::path::PathBuf::from(r"Z:\"); + for segment in manifest_dir.to_string_lossy().trim_start_matches('/').split('/') { + if !segment.is_empty() { + mapped.push(segment); + } } - Component::RootDir | Component::Prefix(_) => {} + return mapped; } } - for path_comp in &path_components[common_prefix_len..] { - relative_path.push(path_comp.as_os_str()); - } - - relative_path + manifest_dir } -#[expect( - clippy::disallowed_types, - reason = "Path types required for runtime path remapping between compile and runtime roots" -)] fn resolve_runtime_vp_path() -> AbsolutePathBuf { - let compile_time_vp = std::path::Path::new(COMPILE_TIME_CARGO_BIN_EXE_VP); - let compile_time_manifest = std::path::Path::new(COMPILE_TIME_CARGO_MANIFEST_DIR); - let vp_relative_path = relative_path_from(compile_time_vp, compile_time_manifest); - - let runtime_manifest = runtime_manifest_dir(); - let runtime_vp = runtime_manifest.join(vp_relative_path); - let resolved_vp = if runtime_vp.exists() { runtime_vp } else { compile_time_vp.to_path_buf() }; - let resolved_vp = resolved_vp.canonicalize().unwrap_or(resolved_vp); - AbsolutePathBuf::new(resolved_vp).unwrap() + // Locate `vp` next to the test binary's debug directory. + // tests/.exe is in target//debug/deps/, and vp(.exe) is in target//debug/. + let current_exe = std::env::current_exe().unwrap(); + let current_exe = current_exe.canonicalize().unwrap_or(current_exe); + let debug_dir = current_exe.parent().unwrap().parent().unwrap(); + let runtime_vp = debug_dir.join(if cfg!(windows) { "vp.exe" } else { "vp" }); + let runtime_vp = runtime_vp.canonicalize().unwrap_or(runtime_vp); + AbsolutePathBuf::new(runtime_vp).unwrap() } const fn default_true() -> bool { From ae8018a013e0b9d82be53c17656bf13eba3af9be Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 15:39:38 +0800 Subject: [PATCH 22/24] fix: canonicalize e2e temp root for windows cache tracking --- crates/vite_task_bin/tests/e2e_snapshots/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 046ac2b7..92781c07 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -594,7 +594,7 @@ fn main() { let filter = std::env::args().nth(1); let tmp_dir = tempfile::tempdir().unwrap(); - let tmp_dir_path = AbsolutePathBuf::new(tmp_dir.path().to_path_buf()).unwrap(); + let tmp_dir_path = AbsolutePathBuf::new(tmp_dir.path().canonicalize().unwrap()).unwrap(); let fixtures_dir = runtime_manifest_dir().join("tests").join("e2e_snapshots").join("fixtures"); let mut fixture_paths = std::fs::read_dir(fixtures_dir) From ae581a6117f8e6cf0d19ff998a0ae793b5ba9ab7 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 17:16:00 +0800 Subject: [PATCH 23/24] Use cp_r for e2e fixture staging and remap vp path --- Cargo.lock | 10 + Cargo.toml | 1 + crates/vite_task_bin/Cargo.toml | 1 + .../tests/e2e_snapshots/README.md | 10 +- .../interactions-no-vp/snapshots.toml | 2 +- .../snapshots/interactions without vp.snap | 2 +- .../vite_task_bin/tests/e2e_snapshots/main.rs | 396 ++++++------------ 7 files changed, 149 insertions(+), 273 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a417ba11..2403c810 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" @@ -3835,6 +3844,7 @@ dependencies = [ "async-trait", "clap", "cow-utils", + "cp_r", "crossterm", "insta", "jsonc-parser", diff --git a/Cargo.toml b/Cargo.toml index 9cfb3a79..114410e7 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" diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml index 5a820fd8..ac558c4f 100644 --- a/crates/vite_task_bin/Cargo.toml +++ b/crates/vite_task_bin/Cargo.toml @@ -27,6 +27,7 @@ which = { workspace = true } [dev-dependencies] cow-utils = { workspace = true } +cp_r = { workspace = true } insta = { workspace = true, features = ["glob", "json", "redactions", "filters", "ron"] } pty_terminal = { workspace = true } pty_terminal_test = { workspace = true } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/README.md b/crates/vite_task_bin/tests/e2e_snapshots/README.md index c80895d6..0863859f 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/README.md +++ b/crates/vite_task_bin/tests/e2e_snapshots/README.md @@ -22,29 +22,29 @@ steps = [ ] ``` -Steps also support an object form with PTY and interactions: +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" }] }, - { command = "node check-stdin.js", pty = false }, + "echo -n | node check-stdin.js", ] ``` Notes: -- String steps are shorthand for `{ command = "...", pty = true }`. -- `interactions` are only valid when `pty = true`. +- 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. Runs each step in PTY mode by default (`TestTerminal`) unless `pty = false` (pipe stdio) +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/` 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 index 0f757307..22fd775a 100644 --- 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 @@ -2,5 +2,5 @@ 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" }] }, - { command = "node -e \"console.log('PIPE_STDIN_IS_TTY:' + String(Boolean(process.stdin.isTTY)))\"", pty = false }, + "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 index 6b7b1f3e..91a74e97 100644 --- 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 @@ -73,5 +73,5 @@ CHAR:x LINE:x KEY:ENTER DONE -> node -e "console.log('PIPE_STDIN_IS_TTY:' + String(Boolean(process.stdin.isTTY)))" +> 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 92781c07..4b0a1931 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -3,12 +3,12 @@ mod redact; use std::{ env::{self, join_paths, split_paths}, ffi::OsStr, - io::{Read, Write}, - process::Stdio, + io::Write, sync::{Arc, Mutex, mpsc}, - time::{Duration, Instant}, + time::Duration, }; +use cp_r::CopyOptions; use pty_terminal::{geo::ScreenSize, terminal::CommandBuilder}; use pty_terminal_test::TestTerminal; use redact::redact_e2e_output; @@ -22,7 +22,8 @@ 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_CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR"); +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. @@ -56,48 +57,36 @@ fn get_shell_exe() -> std::path::PathBuf { #[expect( clippy::disallowed_types, - reason = "Path types required for runtime path remapping between compile and runtime roots" + reason = "PathBuf required for compile-time/runtime vp path remapping" )] -fn runtime_manifest_dir() -> std::path::PathBuf { - let manifest_dir = std::env::var_os("CARGO_MANIFEST_DIR").map_or_else( - || std::path::PathBuf::from(COMPILE_TIME_CARGO_MANIFEST_DIR), - std::path::PathBuf::from, +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 = compile_time_vp.strip_prefix(compile_time_repo_root).unwrap_or_else(|_| { + panic!( + "Failed to resolve vp relative 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(), ); - #[cfg(windows)] - { - // In cargo-xtest with a Windows target executed via wine on Unix hosts, - // runtime CARGO_MANIFEST_DIR can be Unix-style (e.g. "/Volumes/..."). - // Map it to wine's Z: drive for Windows-native path resolution. - if manifest_dir.to_string_lossy().starts_with('/') { - let mut mapped = std::path::PathBuf::from(r"Z:\"); - for segment in manifest_dir.to_string_lossy().trim_start_matches('/').split('/') { - if !segment.is_empty() { - mapped.push(segment); - } - } - return mapped; - } - } - - manifest_dir -} - -fn resolve_runtime_vp_path() -> AbsolutePathBuf { - // Locate `vp` next to the test binary's debug directory. - // tests/.exe is in target//debug/deps/, and vp(.exe) is in target//debug/. - let current_exe = std::env::current_exe().unwrap(); - let current_exe = current_exe.canonicalize().unwrap_or(current_exe); - let debug_dir = current_exe.parent().unwrap().parent().unwrap(); - let runtime_vp = debug_dir.join(if cfg!(windows) { "vp.exe" } else { "vp" }); - let runtime_vp = runtime_vp.canonicalize().unwrap_or(runtime_vp); AbsolutePathBuf::new(runtime_vp).unwrap() } -const fn default_true() -> bool { - true -} - #[derive(serde::Deserialize, Debug)] #[serde(untagged)] enum Step { @@ -109,8 +98,6 @@ enum Step { #[serde(deny_unknown_fields)] struct StepConfig { command: Str, - #[serde(default = "default_true")] - pty: bool, #[serde(default)] interactions: Vec, } @@ -123,13 +110,6 @@ impl Step { } } - const fn pty(&self) -> bool { - match self { - Self::Command(_) => true, - Self::Detailed(config) => config.pty, - } - } - fn interactions(&self) -> &[Interaction] { match self { Self::Command(_) => &[], @@ -248,33 +228,6 @@ enum TerminationState { TimedOut, } -#[expect( - clippy::disallowed_types, - reason = "Path required for recursive fixture copy in test harness" -)] -fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { - std::fs::create_dir_all(dst)?; - - for entry in std::fs::read_dir(src)? { - let entry = entry?; - let source_path = entry.path(); - let target_path = dst.join(entry.file_name()); - let file_type = entry.file_type()?; - - if file_type.is_dir() { - copy_dir_recursive(&source_path, &target_path)?; - } else if file_type.is_file() { - std::fs::copy(&source_path, &target_path)?; - } - } - - Ok(()) -} - -fn kill_process(child: &mut std::process::Child) { - let _ = child.kill(); -} - #[expect( clippy::too_many_lines, reason = "e2e test runner with process management necessarily has many lines" @@ -286,7 +239,7 @@ fn kill_process(child: &mut std::process::Child) { 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_recursive(fixture_path, stage_path.as_path()).unwrap(); + CopyOptions::new().copy_tree(fixture_path, stage_path.as_path()).unwrap(); let (workspace_root, _cwd) = find_workspace_root(&stage_path).unwrap(); @@ -303,7 +256,11 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture }; // Navigate from runtime CARGO_MANIFEST_DIR to packages/tools at the repo root. - let repo_root = runtime_manifest_dir(); + #[expect( + clippy::disallowed_types, + reason = "Path required for CARGO_MANIFEST_DIR path traversal" + )] + 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(), @@ -349,210 +306,110 @@ 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; - copy_dir_recursive(fixture_path, e2e_stage_path.as_path()).unwrap(); + 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 { - assert!( - !(!step.pty() && !step.interactions().is_empty()), - "Step '{}' sets pty = false but also defines interactions; interactions require pty = true", - step.command() - ); - let step_command = step.command(); - let (termination_state, output) = if step.pty() { - let mut cmd = CommandBuilder::new(&shell_exe); - cmd.arg("-c"); - cmd.arg(step_command); - cmd.env_clear(); - cmd.env("PATH", &e2e_env_path); - cmd.env("NO_COLOR", "1"); - cmd.env("TERM", "dumb"); - cmd.cwd(e2e_stage_path.join(&e2e.cwd).as_path()); - - // On Windows, inherit PATHEXT for executable lookup - if cfg!(windows) - && let Ok(pathext) = std::env::var("PATHEXT") - { - cmd.env("PATHEXT", pathext); - } + let mut cmd = CommandBuilder::new(&shell_exe); + cmd.arg("-c"); + cmd.arg(step_command); + cmd.env_clear(); + cmd.env("PATH", &e2e_env_path); + cmd.env("NO_COLOR", "1"); + cmd.env("TERM", "dumb"); + cmd.cwd(e2e_stage_path.join(&e2e.cwd).as_path()); + + // On Windows, inherit PATHEXT for executable lookup + if cfg!(windows) + && let Ok(pathext) = std::env::var("PATHEXT") + { + cmd.env("PATHEXT", pathext); + } - 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 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 - ) + 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 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(), - ); - 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(); - } + ); + terminal + .writer + .write_line(write_line.write_line.as_str().as_bytes()) + .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'); + 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(); } - output.push_str(&screen); } + } - let _ = tx.send(i64::from(status.exit_code())); - }); + let status = terminal.reader.wait_for_exit(); + let screen = terminal.reader.screen_contents(); - 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 output = output.lock().unwrap().clone(); - (TerminationState::TimedOut, output) - } - Err(mpsc::RecvTimeoutError::Disconnected) => { - panic!("Terminal thread panicked"); - } - } - } else { - let mut cmd = std::process::Command::new(&shell_exe); - cmd.arg("-c"); - cmd.arg(step_command); - cmd.env_clear(); - cmd.env("PATH", &e2e_env_path); - cmd.env("NO_COLOR", "1"); - cmd.env("TERM", "dumb"); - cmd.current_dir(e2e_stage_path.join(&e2e.cwd).as_path()); - cmd.stdin(Stdio::piped()); - cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::piped()); - - // On Windows, inherit PATHEXT for executable lookup - if cfg!(windows) - && let Ok(pathext) = std::env::var("PATHEXT") { - cmd.env("PATHEXT", pathext); - } - - let mut child = cmd.spawn().unwrap(); - - let stdout_output = Arc::new(Mutex::new(Vec::::new())); - let stderr_output = Arc::new(Mutex::new(Vec::::new())); - - let stdout = child.stdout.take().unwrap(); - let stdout_output_for_thread = Arc::clone(&stdout_output); - let stdout_thread = std::thread::spawn(move || { - let mut stdout = stdout; - let mut buf = [0u8; 4096]; - loop { - match stdout.read(&mut buf) { - Ok(0) => break, - Ok(n) => { - stdout_output_for_thread - .lock() - .unwrap() - .extend_from_slice(&buf[..n]); - } - Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {} - Err(_) => break, - } - } - }); - - let stderr = child.stderr.take().unwrap(); - let stderr_output_for_thread = Arc::clone(&stderr_output); - let stderr_thread = std::thread::spawn(move || { - let mut stderr = stderr; - let mut buf = [0u8; 4096]; - loop { - match stderr.read(&mut buf) { - Ok(0) => break, - Ok(n) => { - stderr_output_for_thread - .lock() - .unwrap() - .extend_from_slice(&buf[..n]); - } - Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {} - Err(_) => break, - } - } - }); - - let snapshot_output = || { - let stdout = { stdout_output.lock().unwrap().clone() }; - let stderr = { stderr_output.lock().unwrap().clone() }; - - let mut combined_output = String::from_utf8_lossy(&stdout).into_owned(); - combined_output.push_str(String::from_utf8_lossy(&stderr).as_ref()); - combined_output - }; - - let start = Instant::now(); - loop { - if let Some(status) = child.try_wait().unwrap() { - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - let combined_output = snapshot_output(); - - let exit_code = i64::from(status.code().unwrap_or(1)); - break (TerminationState::Exited(exit_code), combined_output); + let mut output = output_for_thread.lock().unwrap(); + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); } + output.push_str(&screen); + } - if start.elapsed() >= STEP_TIMEOUT { - kill_process(&mut child); - let combined_output = snapshot_output(); - break (TerminationState::TimedOut, combined_output); - } + let _ = tx.send(i64::from(status.exit_code())); + }); - std::thread::park_timeout(Duration::from_millis(10)); + 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 output = output.lock().unwrap().clone(); + (TerminationState::TimedOut, output) + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + panic!("Terminal thread panicked"); } }; @@ -596,7 +453,14 @@ fn main() { let tmp_dir = tempfile::tempdir().unwrap(); let tmp_dir_path = AbsolutePathBuf::new(tmp_dir.path().canonicalize().unwrap()).unwrap(); - let fixtures_dir = runtime_manifest_dir().join("tests").join("e2e_snapshots").join("fixtures"); + #[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()) From 307e9ce23c7f5245fb6b338eaeffb05d61088bda Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 17:24:14 +0800 Subject: [PATCH 24/24] Use pathdiff for vp path remapping --- Cargo.lock | 7 +++++++ Cargo.toml | 1 + crates/vite_task_bin/Cargo.toml | 1 + crates/vite_task_bin/tests/e2e_snapshots/main.rs | 7 ++++--- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2403c810..a253c5c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2231,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" @@ -3848,6 +3854,7 @@ dependencies = [ "crossterm", "insta", "jsonc-parser", + "pathdiff", "pty_terminal", "pty_terminal_test", "pty_terminal_test_client", diff --git a/Cargo.toml b/Cargo.toml index 114410e7..65153612 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,7 @@ 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" diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml index ac558c4f..6163a3c4 100644 --- a/crates/vite_task_bin/Cargo.toml +++ b/crates/vite_task_bin/Cargo.toml @@ -29,6 +29,7 @@ which = { 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 } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 4b0a1931..3581c239 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -9,6 +9,7 @@ use std::{ }; 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; @@ -68,14 +69,14 @@ fn resolve_runtime_vp_path() -> AbsolutePathBuf { 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 = compile_time_vp.strip_prefix(compile_time_repo_root).unwrap_or_else(|_| { + let relative_vp = diff_paths(&compile_time_vp, compile_time_repo_root).unwrap_or_else(|| { panic!( - "Failed to resolve vp relative path. vp={} repo_root={}", + "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); + let runtime_vp = runtime_repo_root.join(&relative_vp); assert!( runtime_vp.exists(),