From b780fa1ba547ac2842bdcab60f963c0870b76626 Mon Sep 17 00:00:00 2001 From: alexandre pasmantier Date: Fri, 20 Jun 2025 12:14:55 +0200 Subject: [PATCH] test(e2e): add proper e2e tests and pty-testing utils --- Cargo.lock | 42 +++- Cargo.toml | 1 + tests/e2e.rs | 606 +++++++++++++++++++++++++++++++-------------------- 3 files changed, 417 insertions(+), 232 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 52050eb..019e5d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,6 +122,12 @@ version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "autocfg" version = "1.4.0" @@ -1866,7 +1872,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" dependencies = [ - "vte", + "vte 0.14.1", ] [[package]] @@ -1949,6 +1955,7 @@ dependencies = [ "tracing-subscriber", "unicode-width 0.2.0", "ureq", + "vt100", "walkdir", "winapi-util", ] @@ -2297,6 +2304,29 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width 0.1.14", + "vte 0.11.1", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + [[package]] name = "vte" version = "0.14.1" @@ -2306,6 +2336,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index a1fb132..dae4191 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,6 +77,7 @@ clipboard-win = "5.4.0" criterion = { version = "0.5", features = ["async_tokio"] } tempfile = "3.16.0" portable-pty = "0.9.0" +vt100 = "0.15" [build-dependencies] diff --git a/tests/e2e.rs b/tests/e2e.rs index d90583b..bfec3ec 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -1,5 +1,5 @@ -use std::thread::sleep; use std::time::Duration; +use std::{io::Write, thread::sleep}; use portable_pty::{CommandBuilder, PtySize, native_pty_system}; @@ -10,279 +10,423 @@ const DEFAULT_CABLE_DIR: &str = "./cable/unix"; const DEFAULT_CABLE_DIR: &str = "./cable/windows"; const DEFAULT_PTY_SIZE: PtySize = PtySize { - rows: 20, - cols: 60, + rows: 30, + cols: 120, pixel_width: 0, pixel_height: 0, }; -const DEFAULT_TIMEOUT: Duration = Duration::from_millis(400); +const DEFAULT_DELAY: Duration = Duration::from_millis(200); -fn assert_exit_ok( - child: &mut Box, - timeout: Duration, -) { - let now = std::time::Instant::now(); - while now.elapsed() < timeout { - match child.try_wait() { - Ok(Some(status)) => { - assert!( - status.success(), - "Process exited with non-zero status: {:?}", - status - ); - return; - } - Ok(None) => { - // Process is still running, continue waiting - sleep(Duration::from_millis(50)); - } - Err(e) => { - panic!("Error waiting for process: {}", e); - } +struct PtyTester { + pair: portable_pty::PtyPair, + cwd: std::path::PathBuf, + delay: Duration, + pub reader: Box, + pub writer: Box, + void_buffer: Vec, + parser: vt100::Parser, +} + +impl PtyTester { + fn new() -> Self { + let pty_system = native_pty_system(); + let pair = pty_system.openpty(DEFAULT_PTY_SIZE).unwrap(); + let reader = pair.master.try_clone_reader().unwrap(); + let writer = pair.master.take_writer().unwrap(); + let cwd = std::env::current_dir().unwrap(); + let delay = DEFAULT_DELAY; + let size = pair.master.get_size().unwrap(); + let parser = vt100::Parser::new(size.rows, size.cols, 0); + PtyTester { + pair, + cwd, + delay, + reader: Box::new(reader), + writer: Box::new(writer), + void_buffer: vec![0; 2usize.pow(20)], // 1 MiB buffer + parser, } } - panic!("Process did not exit in time"); + + #[allow(dead_code)] + pub fn with_delay(mut self, delay: Duration) -> Self { + self.delay = delay; + self + } + + /// Spawns a command in the pty, returning a boxed child process. + pub fn spawn_command( + &mut self, + mut cmd: CommandBuilder, + ) -> Box { + cmd.cwd(&self.cwd); + let child = self.pair.slave.spawn_command(cmd).unwrap(); + sleep(self.delay); + child + } + + fn read_raw_output(&mut self) -> String { + self.void_buffer.fill(0); + let bytes_read = self.reader.read(&mut self.void_buffer).unwrap(); + String::from_utf8_lossy(&self.void_buffer[..bytes_read]).to_string() + } + + /// Reads the output from the child process's pty. + /// This method processes the output using a vt100 parser to handle terminal escape + /// sequences. + fn read_tui_output(&mut self) -> String { + self.void_buffer.fill(0); + let bytes_read = self.reader.read(&mut self.void_buffer).unwrap(); + self.parser.process(&self.void_buffer[..bytes_read]); + self.parser.screen().contents() + } + + /// Writes input to the child process's stdin. + pub fn write_input(&mut self, input: &str) { + write!(self.writer, "{}", input).unwrap(); + self.writer.flush().unwrap(); + sleep(self.delay); + } + + /// Waits for the child process to exit, asserting that it exits with a success status. + pub fn assert_exit_ok( + child: &mut Box, + timeout: Duration, + ) { + let now = std::time::Instant::now(); + while now.elapsed() < timeout { + match child.try_wait() { + Ok(Some(status)) => { + assert!( + status.success(), + "Process exited with non-zero status: {:?}", + status + ); + return; + } + Ok(None) => { + // Process is still running, continue waiting + sleep(Duration::from_millis(50)); + } + Err(e) => { + panic!("Error waiting for process: {}", e); + } + } + } + panic!("Process did not exit in time"); + } + + const FRAME_STABILITY_TIMEOUT: Duration = Duration::from_millis(1000); + + pub fn get_tui_frame(&mut self) -> String { + // wait for the UI to stabilize with a timeout + let mut frame = String::new(); + let start_time = std::time::Instant::now(); + loop { + let new_frame = self.read_tui_output(); + if new_frame == frame { + break; + } + frame = new_frame; + assert!( + start_time.elapsed() < Self::FRAME_STABILITY_TIMEOUT, + "UI did not stabilize within {:?}. Last frame:\n{}", + Self::FRAME_STABILITY_TIMEOUT, + frame + ); + } + frame + } + + /// Asserts that the output contains the expected string. + pub fn assert_tui_output_contains(&mut self, expected: &str) { + let frame = self.get_tui_frame(); + assert!( + frame.contains(expected), + "Expected output to contain\n'{}'\nbut got:\n{}", + expected, + frame + ); + } + + pub fn assert_raw_output_contains(&mut self, expected: &str) { + let output = self.read_raw_output(); + assert!( + output.contains(expected), + "Expected output to contain '{}', but got:\n{}", + expected, + output + ); + } } -#[test] -fn tv_version() { - let pty_system = native_pty_system(); - // Create a new pty - let pair = pty_system.openpty(DEFAULT_PTY_SIZE).unwrap(); - - // Spawn a tv process in the pty - let mut cmd = CommandBuilder::new("./target/debug/tv"); - cmd.cwd(std::env::current_dir().unwrap()); - cmd.args(["--version"]); - let mut child: Box = - pair.slave.spawn_command(cmd).unwrap(); - sleep(Duration::from_millis(200)); - - // Read the output from the pty - let mut buf = [0; 512]; - let mut reader = pair.master.try_clone_reader().unwrap(); - let _ = reader.read(&mut buf).unwrap(); - let output = String::from_utf8_lossy(&buf); - assert!(output.contains("television")); - - assert_exit_ok(&mut child, DEFAULT_TIMEOUT); +fn ctrl(c: char) -> String { + ((c as u8 & 0x1F) as char).to_string() } -#[test] -fn tv_help() { - let pty_system = native_pty_system(); - // Create a new pty - let pair = pty_system.openpty(DEFAULT_PTY_SIZE).unwrap(); +const ENTER: &str = "\r"; - // Spawn a tv process in the pty - let mut cmd = CommandBuilder::new("./target/debug/tv"); - cmd.cwd(std::env::current_dir().unwrap()); - cmd.args(["--help"]); - let mut child = pair.slave.spawn_command(cmd).unwrap(); - sleep(Duration::from_millis(200)); +const TV_BIN_PATH: &str = "./target/debug/tv"; +const LOCAL_CONFIG_AND_CABLE: &[&str] = &[ + "--cable-dir", + DEFAULT_CABLE_DIR, + "--config-file", + DEFAULT_CONFIG_FILE, +]; - // Read the output from the pty - let mut buf = [0; 512]; - let mut reader = pair.master.try_clone_reader().unwrap(); - let _ = reader.read(&mut buf).unwrap(); - let output = String::from_utf8_lossy(&buf); - assert!(output.contains("A cross-platform")); - - assert_exit_ok(&mut child, DEFAULT_TIMEOUT); +fn tv() -> CommandBuilder { + CommandBuilder::new(TV_BIN_PATH) } -#[test] -fn tv_list_channels() { - let pty_system = native_pty_system(); - // Create a new pty - let pair = pty_system.openpty(DEFAULT_PTY_SIZE).unwrap(); - - // Spawn a tv process in the pty - let mut cmd = CommandBuilder::new("./target/debug/tv"); - cmd.cwd(std::env::current_dir().unwrap()); - cmd.args([ - "--cable-dir", - DEFAULT_CABLE_DIR, - "--config-file", - DEFAULT_CONFIG_FILE, - "list-channels", - ]); - let mut child = pair.slave.spawn_command(cmd).unwrap(); - sleep(Duration::from_millis(200)); - - // Read the output from the pty - let mut buf = [0; 512]; - let mut reader = pair.master.try_clone_reader().unwrap(); - let _ = reader.read(&mut buf).unwrap(); - let output = String::from_utf8_lossy(&buf); - assert!(output.contains("files"), "Output: {}", output); - assert!(output.contains("dirs"), "Output: {}", output); - - assert_exit_ok(&mut child, DEFAULT_TIMEOUT); -} - -#[test] -fn tv_init_zsh() { - let pty_system = native_pty_system(); - // Create a new pty - let pair = pty_system.openpty(DEFAULT_PTY_SIZE).unwrap(); - - // Spawn a tv process in the pty - let mut cmd = CommandBuilder::new("./target/debug/tv"); - cmd.cwd(std::env::current_dir().unwrap()); - cmd.args([ - "--cable-dir", - DEFAULT_CABLE_DIR, - "--config-file", - DEFAULT_CONFIG_FILE, - "init", - "zsh", - ]); - let mut child = pair.slave.spawn_command(cmd).unwrap(); - sleep(Duration::from_millis(200)); - - assert_exit_ok(&mut child, DEFAULT_TIMEOUT); -} - -/// Creates a command to run `tv` with the repo's config and cable directory -/// using the cable directory as cwd. -fn tv_command(channel: &str) -> CommandBuilder { - let mut cmd = CommandBuilder::new("./target/debug/tv"); - cmd.cwd(std::env::current_dir().unwrap()); - cmd.args([ - "--cable-dir", - DEFAULT_CABLE_DIR, - "--config-file", - DEFAULT_CONFIG_FILE, - channel, - ]); +fn tv_with_args(args: &[&str]) -> CommandBuilder { + let mut cmd = tv(); + cmd.args(args); cmd } -#[test] -fn tv_ctrl_c() { - let pty_system = native_pty_system(); - // Create a new pty - let pair = pty_system.openpty(DEFAULT_PTY_SIZE).unwrap(); - - // Spawn a tv process in the pty - let mut child = pair.slave.spawn_command(tv_command("files")).unwrap(); - sleep(Duration::from_millis(200)); - - // Send Ctrl-C to the process - let mut writer = pair.master.take_writer().unwrap(); - writeln!(writer, "\x03").unwrap(); // Ctrl-C - - // Check if the child process exited with a timeout - assert_exit_ok(&mut child, DEFAULT_TIMEOUT); +fn tv_local_config_and_cable_with_args(args: &[&str]) -> CommandBuilder { + let mut cmd = tv(); + cmd.args(LOCAL_CONFIG_AND_CABLE); + cmd.args(args); + cmd } -#[test] -fn tv_remote_control() { - let pty_system = native_pty_system(); - // Create a new pty - let pair = pty_system.openpty(DEFAULT_PTY_SIZE).unwrap(); +mod e2e { + use super::*; + mod tv_subcommands { + use super::*; - // Spawn a tv process in the pty - let mut child = pair.slave.spawn_command(tv_command("files")).unwrap(); - sleep(Duration::from_millis(200)); + /// Really just a sanity check + #[test] + fn tv_version() { + let mut tester = PtyTester::new(); + let mut child = tester.spawn_command(tv_with_args(&["--version"])); - // Send Ctrl-T to the process (open remote control mode) - let mut writer = pair.master.take_writer().unwrap(); - writeln!(writer, "\x14").unwrap(); // Ctrl-T - sleep(Duration::from_millis(200)); + tester.assert_raw_output_contains("television"); + PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY); + } - let mut buf = [0; 5096]; // Buffer size for reading output - let mut reader = pair.master.try_clone_reader().unwrap(); - let _ = reader.read(&mut buf).unwrap(); + /// Really just a sanity check + #[test] + fn tv_help() { + let mut tester = PtyTester::new(); + let mut child = tester.spawn_command(tv_with_args(&["--help"])); - let s = String::from_utf8_lossy(&buf); + tester.assert_raw_output_contains("A cross-platform"); + PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY); + } - assert!(s.contains("Remote Control")); + /// Tests the `tv list-channels` command. + /// + /// We expect this to list all available channels in the cable directory. + #[test] + fn tv_list_channels() { + let mut tester = PtyTester::new(); + let mut child = + tester.spawn_command(tv_local_config_and_cable_with_args(&[ + "list-channels", + ])); - // Send Ctrl-c to exit remote control mode - writeln!(writer, "\x03").unwrap(); - sleep(Duration::from_millis(200)); - // resend Ctrl-c to finally exit - writeln!(writer, "\x03").unwrap(); - sleep(Duration::from_millis(200)); + // Check what's in the cable directory + let cable_dir_filenames = std::fs::read_dir(DEFAULT_CABLE_DIR) + .expect("Failed to read cable directory") + .filter_map(Result::ok) + .filter_map(|entry| { + // this is pretty lazy and can be improved later on + entry.path().extension().and_then(|ext| { + if ext == "toml" { + entry.path().file_stem().and_then(|stem| { + stem.to_str().map(String::from) + }) + } else { + None + } + }) + }) + .collect::>(); - assert_exit_ok(&mut child, DEFAULT_TIMEOUT); -} + // Check if the output contains all channel names + let output = tester.read_raw_output(); + for channel in cable_dir_filenames { + assert!( + output.contains(&channel), + "Channel '{}' not found in output: {}", + channel, + output + ); + } -#[test] -fn tv_custom_input_header_and_preview_size() { - let pty_system = native_pty_system(); - // Create a new pty - let pair = pty_system.openpty(DEFAULT_PTY_SIZE).unwrap(); + PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY); + } - // Spawn a tv process in the pty - let mut cmd = tv_command("files"); - cmd.args(["--input-header", "bagels"]); - let mut child = pair.slave.spawn_command(cmd).unwrap(); - sleep(Duration::from_millis(200)); + #[test] + /// This simply tests that the command exits successfully. + fn tv_init_zsh() { + let mut tester = PtyTester::new(); + let mut child = + tester.spawn_command(tv_with_args(&["init", "zsh"])); - // Read the output from the pty - let mut buf = [0; 5096]; - let mut reader = pair.master.try_clone_reader().unwrap(); - let _ = reader.read(&mut buf).unwrap(); - let output = String::from_utf8_lossy(&buf); + PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY); + } + } - assert!(output.contains("bagels")); + mod general_ui { + use super::*; - // Send Ctrl-C to the process - let mut writer = pair.master.take_writer().unwrap(); - writeln!(writer, "\x03").unwrap(); // Ctrl-C - sleep(Duration::from_millis(200)); + #[test] + fn toggle_help() { + let mut tester = PtyTester::new(); + let mut child = + tester.spawn_command(tv_local_config_and_cable_with_args(&[])); - assert_exit_ok(&mut child, DEFAULT_TIMEOUT); -} + tester.write_input(&ctrl('g')); -macro_rules! test_channel { + tester.assert_tui_output_contains("current mode:"); + + // Exit the application + tester.write_input(&ctrl('c')); + PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY); + } + + #[test] + // FIXME: was lazy, this should be more robust + fn toggle_preview() { + let mut tester = PtyTester::new(); + let mut child = + tester.spawn_command(tv_local_config_and_cable_with_args(&[])); + + let with_preview = + "╭───────────────────────── files ──────────────────────────╮"; + tester.assert_tui_output_contains(with_preview); + + // Toggle preview + tester.write_input(&ctrl('o')); + + let without_preview = "╭─────────────────────────────────────────────────────── files ────────────────────────────────────────────────────────╮"; + tester.assert_tui_output_contains(without_preview); + + // Toggle preview + tester.write_input(&ctrl('o')); + + tester.assert_tui_output_contains(with_preview); + + // Exit the application + tester.write_input(&ctrl('c')); + PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY); + } + } + + mod channels { + use super::*; + + #[test] + fn tv_ctrl_c() { + let mut tester = PtyTester::new(); + let mut child = + tester.spawn_command(tv_local_config_and_cable_with_args(&[ + "files", + ])); + + tester.write_input(&ctrl('c')); + + // Check if the child process exited with a timeout + PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY); + } + + /// Test that the various channels open correctly, spawn a UI that contains the + /// expected channel name, and exit cleanly when Ctrl-C is pressed. + macro_rules! test_channel { ($($name:ident: $channel_name:expr,)*) => { $( #[test] fn $name() { - let pty_system = native_pty_system(); - // Create a new pty - let pair = pty_system.openpty(DEFAULT_PTY_SIZE).unwrap(); - let cmd = tv_command(&$channel_name); + let mut tester = PtyTester::new(); + let mut child = tester.spawn_command(tv_local_config_and_cable_with_args(&[ + $channel_name, + ])); - let mut child = pair.slave.spawn_command(cmd).unwrap(); - sleep(Duration::from_millis(300)); + tester.assert_tui_output_contains(&format!( + "── {} ──", + $channel_name + )); - // Read the output from the pty - let mut buf = [0; 5096]; - let mut reader = pair.master.try_clone_reader().unwrap(); - let _ = reader.read(&mut buf).unwrap(); - let output = String::from_utf8_lossy(&buf); - - assert!( - output.contains(&$channel_name), - "Unable to find channel name (\"{}\") in output: {}", - &$channel_name, - output - ); - - // Send Ctrl-C to the process - let mut writer = pair.master.take_writer().unwrap(); - writeln!(writer, "\x03").unwrap(); // Ctrl-C - sleep(Duration::from_millis(200)); - - assert_exit_ok(&mut child, DEFAULT_TIMEOUT); + tester.write_input(&ctrl('c')); + PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY); } )* } } -test_channel! { - test_channel_files: "files", - test_channel_dirs: "dirs", - test_channel_env: "env", - test_channel_git_log: "git-log", - test_channel_git_reflog: "git-reflog", - test_channel_git_branch: "git-branch", - test_channel_text: "text", - test_channel_diff: "git-diff", + test_channel! { + test_channel_files: "files", + test_channel_dirs: "dirs", + test_channel_env: "env", + test_channel_git_log: "git-log", + test_channel_git_reflog: "git-reflog", + test_channel_git_branch: "git-branch", + test_channel_text: "text", + test_channel_diff: "git-diff", + } + } + + mod remote_control { + use super::*; + + #[test] + fn tv_remote_control_shows() { + let mut tester = PtyTester::new(); + let mut child = tester + .spawn_command(tv_local_config_and_cable_with_args(&["dirs"])); + + // open remote control mode + tester.write_input(&ctrl('t')); + + tester.assert_tui_output_contains("──Remote Control──"); + + // exit remote then app + tester.write_input(&ctrl('c')); + tester.write_input(&ctrl('c')); + + PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY); + } + + #[test] + fn tv_remote_control_zaps() { + let mut tester = PtyTester::new(); + let mut child = tester + .spawn_command(tv_local_config_and_cable_with_args(&["dirs"])); + + // open remote control mode + tester.write_input(&ctrl('t')); + tester.write_input("files"); + tester.write_input(ENTER); + + tester.assert_tui_output_contains("── files ──"); + + // exit remote then app + tester.write_input(&ctrl('c')); + tester.write_input(&ctrl('c')); + + PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY); + } + } + + mod cli { + use super::*; + + #[test] + fn tv_custom_input_header_and_preview_size() { + let mut tester = PtyTester::new(); + let mut cmd = tv_local_config_and_cable_with_args(&["files"]); + cmd.args(["--input-header", "toasted bagels"]); + let mut child = tester.spawn_command(cmd); + + tester.assert_tui_output_contains("── toasted bagels ──"); + + tester.write_input(&ctrl('c')); + + PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY); + } + } }