test(e2e): add proper e2e tests and pty-testing utils

This commit is contained in:
alexandre pasmantier 2025-06-20 12:14:55 +02:00 committed by Alex Pasmantier
parent 14804f50a2
commit b780fa1ba5
3 changed files with 417 additions and 232 deletions

42
Cargo.lock generated
View File

@ -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"

View File

@ -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]

View File

@ -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,16 +10,88 @@ 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<dyn portable_pty::Child + Send + Sync + 'static>,
struct PtyTester {
pair: portable_pty::PtyPair,
cwd: std::path::PathBuf,
delay: Duration,
pub reader: Box<dyn std::io::Read + Send>,
pub writer: Box<dyn std::io::Write + Send + 'static>,
void_buffer: Vec<u8>,
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,
}
}
#[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<dyn portable_pty::Child + Send + Sync> {
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<dyn portable_pty::Child + Send + Sync>,
timeout: Duration,
) {
let now = std::time::Instant::now();
@ -45,232 +117,242 @@ fn assert_exit_ok(
panic!("Process did not exit in time");
}
#[test]
fn tv_version() {
let pty_system = native_pty_system();
// Create a new pty
let pair = pty_system.openpty(DEFAULT_PTY_SIZE).unwrap();
const FRAME_STABILITY_TIMEOUT: Duration = Duration::from_millis(1000);
// 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<dyn portable_pty::Child + Send + Sync + 'static> =
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);
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
}
#[test]
fn tv_help() {
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(["--help"]);
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("A cross-platform"));
assert_exit_ok(&mut child, DEFAULT_TIMEOUT);
/// 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
);
}
#[test]
fn tv_list_channels() {
let pty_system = native_pty_system();
// Create a new pty
let pair = pty_system.openpty(DEFAULT_PTY_SIZE).unwrap();
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
);
}
}
// Spawn a tv process in the pty
let mut cmd = CommandBuilder::new("./target/debug/tv");
cmd.cwd(std::env::current_dir().unwrap());
cmd.args([
fn ctrl(c: char) -> String {
((c as u8 & 0x1F) as char).to_string()
}
const ENTER: &str = "\r";
const TV_BIN_PATH: &str = "./target/debug/tv";
const LOCAL_CONFIG_AND_CABLE: &[&str] = &[
"--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);
fn tv() -> CommandBuilder {
CommandBuilder::new(TV_BIN_PATH)
}
#[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
}
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
}
mod e2e {
use super::*;
mod tv_subcommands {
use super::*;
/// Really just a sanity check
#[test]
fn tv_version() {
let mut tester = PtyTester::new();
let mut child = tester.spawn_command(tv_with_args(&["--version"]));
tester.assert_raw_output_contains("television");
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
/// Really just a sanity check
#[test]
fn tv_help() {
let mut tester = PtyTester::new();
let mut child = tester.spawn_command(tv_with_args(&["--help"]));
tester.assert_raw_output_contains("A cross-platform");
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
/// 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",
]));
// 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::<Vec<_>>();
// 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
);
}
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
#[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"]));
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
}
mod general_ui {
use super::*;
#[test]
fn toggle_help() {
let mut tester = PtyTester::new();
let mut child =
tester.spawn_command(tv_local_config_and_cable_with_args(&[]));
tester.write_input(&ctrl('g'));
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 pty_system = native_pty_system();
// Create a new pty
let pair = pty_system.openpty(DEFAULT_PTY_SIZE).unwrap();
let mut tester = PtyTester::new();
let mut child =
tester.spawn_command(tv_local_config_and_cable_with_args(&[
"files",
]));
// 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
tester.write_input(&ctrl('c'));
// Check if the child process exited with a timeout
assert_exit_ok(&mut child, DEFAULT_TIMEOUT);
}
#[test]
fn tv_remote_control() {
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-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));
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();
let s = String::from_utf8_lossy(&buf);
assert!(s.contains("Remote Control"));
// 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));
assert_exit_ok(&mut child, DEFAULT_TIMEOUT);
}
#[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();
// 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));
// 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("bagels"));
// 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);
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);
}
)*
}
@ -286,3 +368,65 @@ test_channel! {
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);
}
}
}