diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..80a5e52 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache + uses: Swatinem/rust-cache@v2 + + - name: Cargo check + run: cargo check + + - name: Cargo test + run: cargo test diff --git a/.gitignore b/.gitignore index 9394ae3..6659b70 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ *.md !README.md !CONTRIBUTION.md - +!/docs/**/*.md # Rust build output target/ diff --git a/README.md b/README.md index 0f4fe47..bd7fa9b 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ piperack --config examples/full_stack.toml - [Usage](docs/usage.md) - [Configuration](docs/configuration.md) - [Architecture](docs/architecture.md) +- [Testing](docs/testing.md) ## Configuration diff --git a/docs/README.md b/docs/README.md index f7a8d2f..3e74bff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ Welcome to the documentation for **Piperack**, the concurrent process runner for - [**Configuration**](configuration.md) - `piperack.toml` reference and options. - [**Usage & Controls**](usage.md) - CLI arguments and TUI keybindings. - [**Architecture**](architecture.md) - High-level overview of how Piperack works. +- [**Testing**](testing.md) - Unit tests, coverage, and TUI smoke checks. ## Quick Links diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..417f64c --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,47 @@ +# Testing + +This project uses Rust's built-in test harness for unit tests and a small set of manual smoke checks for the TUI. + +## Quick commands + +- `cargo check` — fast compile/typecheck +- `cargo test` — run unit tests +- `cargo fmt` — format +- `cargo clippy` — lint + +## Coverage (optional) + +We use `cargo-llvm-cov` for local coverage reports. + +Install: + +```bash +cargo install cargo-llvm-cov +``` + +Run: + +```bash +cargo llvm-cov +``` + +On macOS, you may need to point to Xcode/CommandLineTools LLVM binaries: + +```bash +LLVM_COV=/Library/Developer/CommandLineTools/usr/bin/llvm-cov \ +LLVM_PROFDATA=/Library/Developer/CommandLineTools/usr/bin/llvm-profdata \ +cargo llvm-cov +``` + +## Manual TUI smoke checks + +- Start the full stack example: `cargo run -- --config examples/full_stack.toml` +- Verify shutdown UX: + - `q` shows persistent "shutting down" status and exits cleanly. + - `k` shows "sent SIGINT" immediately for a process. +- Verify clipboard selection: + - Mouse drag selects log lines. + - Ctrl+C copies selection; if none, copies full selected process buffer. +- Verify selection stability: + - Selection remains while logs stream. + - Selection persists when toggling follow. diff --git a/src/ansi.rs b/src/ansi.rs index 0779920..6fa5562 100644 --- a/src/ansi.rs +++ b/src/ansi.rs @@ -290,3 +290,63 @@ fn basic_color(index: i32, bright: bool) -> Option { }; Some(color) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ansi_spans_plain_text() { + let spans = ansi_spans("hello"); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].content, "hello"); + assert_eq!(spans[0].style.fg, None); + } + + #[test] + fn ansi_spans_respects_sgr_color() { + let spans = ansi_spans("\u{1b}[31mred\u{1b}[0m"); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].content, "red"); + assert_eq!(spans[0].style.fg, Some(Color::Red)); + } + + #[test] + fn ansi_spans_skips_osc_sequences() { + let spans = ansi_spans("hi\u{1b}]0;title\u{7}there"); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].content, "hithere"); + } + + #[test] + fn ansi_spans_handles_carriage_return() { + let spans = ansi_spans("abc\rdef"); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].content, "def"); + } + + #[test] + fn parse_params_defaults_to_reset() { + assert_eq!(parse_params(""), vec![0]); + assert_eq!(parse_params(";"), vec![0, 0]); + assert_eq!(parse_params("1;"), vec![1, 0]); + } + + #[test] + fn parse_extended_color_handles_index_and_rgb() { + let indexed = parse_extended_color(&[5, 120]).unwrap(); + assert_eq!(indexed.0, 2); + assert_eq!(indexed.1, Color::Indexed(120)); + + let rgb = parse_extended_color(&[2, 1, 2, 3]).unwrap(); + assert_eq!(rgb.0, 4); + assert_eq!(rgb.1, Color::Rgb(1, 2, 3)); + + assert!(parse_extended_color(&[9]).is_none()); + } + + #[test] + fn basic_color_rejects_out_of_range() { + assert!(basic_color(9, false).is_none()); + } +} diff --git a/src/app.rs b/src/app.rs index be3945b..09fdc80 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1039,6 +1039,122 @@ fn strip_carriage(text: &str) -> String { text.rsplit('\r').next().unwrap_or("").to_string() } +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use crate::output::LogLine; + use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; + + fn make_spec(name: &str) -> ProcessSpec { + ProcessSpec { + name: name.to_string(), + cmd: "echo".to_string(), + args: Vec::new(), + cwd: None, + color: None, + env: HashMap::new(), + restart_on_fail: false, + follow: true, + pre_cmd: None, + watch_paths: Vec::new(), + watch_ignore: Vec::new(), + watch_ignore_gitignore: false, + watch_debounce_ms: 200, + depends_on: Vec::new(), + ready_check: None, + tags: Vec::new(), + } + } + + fn make_app() -> App { + App::new(vec![make_spec("api")], 100, false, true) + } + + #[test] + fn selection_range_normalizes_and_clamps() { + let mut app = make_app(); + app.selection_scope = Some(SelectionScope::Process(0)); + app.selection_start = Some(3); + app.selection_end = Some(1); + let range = app.selection_range_for(2).unwrap(); + assert_eq!(range, (1, 1)); + } + + #[test] + fn selection_text_joins_visible_lines() { + let mut app = make_app(); + app.selection_scope = Some(SelectionScope::Process(0)); + app.selection_start = Some(0); + app.selection_end = Some(1); + app.visible_raw_lines = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + assert_eq!(app.selection_text().unwrap(), "a\nb"); + } + + #[test] + fn selected_process_raw_text_strips_ansi_and_skips_pretty() { + let mut app = make_app(); + if let Some(process) = app.processes.get_mut(0) { + process.logs.push(LogLine { + text: "\u{1b}[31mred\u{1b}[0m".to_string(), + stream: StreamKind::Stdout, + }); + process.logs.push(LogLine { + text: "{\"a\":1}".to_string(), + stream: StreamKind::Stdout, + }); + } + app.json_formatting = true; + assert_eq!(app.selected_process_raw_text().unwrap(), "red\n{\"a\":1}"); + } + + #[test] + fn mouse_selection_freezes_follow() { + let mut app = make_app(); + app.set_log_viewport(LogViewport { + x: 0, + y: 0, + width: 10, + height: 10, + }); + app.process_list_width = 0; + app.processes[0].follow = true; + let mouse = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 1, + row: 1, + modifiers: KeyModifiers::NONE, + }; + app.handle_mouse(mouse); + assert!(!app.processes[0].follow); + assert!(app.selection_active); + } + + #[test] + fn selection_scope_mismatch_returns_none() { + let mut app = make_app(); + app.selection_scope = Some(SelectionScope::Process(0)); + app.selection_start = Some(0); + app.selection_end = Some(1); + app.timeline_view = true; + assert!(app.selection_range().is_none()); + } + + #[test] + fn clear_selection_resets_state() { + let mut app = make_app(); + app.selection_scope = Some(SelectionScope::Process(0)); + app.selection_start = Some(0); + app.selection_end = Some(1); + app.selection_active = true; + app.clear_selection(); + assert!(app.selection_scope.is_none()); + assert!(app.selection_start.is_none()); + assert!(app.selection_end.is_none()); + assert!(!app.selection_active); + } +} + fn format_duration(duration: Duration) -> String { let secs = duration.as_secs(); let minutes = secs / 60; diff --git a/src/events.rs b/src/events.rs index fb5cfa5..15224be 100644 --- a/src/events.rs +++ b/src/events.rs @@ -9,7 +9,7 @@ use crossterm::event::{KeyEvent, MouseEvent}; use crate::output::StreamKind; /// Signals used for graceful process shutdown. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProcessSignal { SigInt, SigTerm, @@ -60,3 +60,14 @@ pub enum Event { /// The terminal window was resized. Resize { width: u16, height: u16 }, } + +#[cfg(test)] +mod tests { + use super::ProcessSignal; + + #[test] + fn process_signal_labels() { + assert_eq!(ProcessSignal::SigInt.label(), "SIGINT"); + assert_eq!(ProcessSignal::SigTerm.label(), "SIGTERM"); + } +} diff --git a/src/main.rs b/src/main.rs index 29f4695..5858a8b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1643,4 +1643,104 @@ mod tests { assert_eq!(specs[0].cmd, "cargo"); assert_eq!(specs[0].args, vec!["run"]); } + + #[test] + fn parse_output_mode_and_success_policy() { + assert!(matches!(parse_output_mode("combined").unwrap(), OutputMode::Combined)); + assert!(matches!(parse_output_mode("grouped").unwrap(), OutputMode::Grouped)); + assert!(matches!(parse_output_mode("raw").unwrap(), OutputMode::Raw)); + assert!(parse_output_mode("nope").is_err()); + + assert!(matches!(parse_success_policy("first").unwrap(), SuccessPolicy::First)); + assert!(matches!(parse_success_policy("last").unwrap(), SuccessPolicy::Last)); + assert!(matches!(parse_success_policy("all").unwrap(), SuccessPolicy::All)); + assert!(parse_success_policy("nope").is_err()); + } + + #[test] + fn split_env_parses_key_value() { + let (k, v) = split_env("A=1").unwrap(); + assert_eq!(k, "A"); + assert_eq!(v, "1"); + assert!(split_env("A").is_err()); + } + + #[test] + fn render_template_and_prefix_length() { + let rendered = render_template("[{name}-{index}-{time}]", "api", 2, "1s"); + assert_eq!(rendered, "[api-2-1s]"); + assert_eq!(apply_prefix_length("abc".to_string(), Some(2)), "ab"); + assert_eq!(apply_prefix_length("abc".to_string(), Some(5)), "abc "); + } + + #[test] + fn format_tool_message_respects_symbols() { + assert_eq!(format_tool_message("hi", true), "◆ piperack: hi"); + assert_eq!(format_tool_message("hi", false), "[piperack] hi"); + } + + #[test] + fn backoff_delay_respects_override() { + let settings = RunSettings { + max_lines: 100, + use_symbols: false, + no_ui: true, + raw: true, + prefix: None, + prefix_length: None, + prefix_colors: false, + timestamp: false, + output_mode: OutputMode::Combined, + success: SuccessPolicy::Last, + kill_others: false, + kill_others_on_fail: false, + restart_tries: None, + restart_delay_ms: Some(250), + shutdown_sigint_ms: 800, + shutdown_sigterm_ms: 800, + input_enabled: false, + log_file: None, + }; + assert_eq!(backoff_delay(1, &settings), Duration::from_millis(250)); + } + + #[test] + fn format_command_joins_args() { + let spec = ProcessSpec { + name: "api".to_string(), + cmd: "cargo".to_string(), + args: vec!["run".to_string(), "--".to_string(), "help".to_string()], + cwd: None, + color: None, + env: HashMap::new(), + restart_on_fail: false, + follow: true, + pre_cmd: None, + watch_paths: Vec::new(), + watch_ignore: Vec::new(), + watch_ignore_gitignore: false, + watch_debounce_ms: 200, + depends_on: Vec::new(), + ready_check: None, + tags: Vec::new(), + }; + assert_eq!(format_command(&spec), "cargo run -- help"); + } + + #[test] + fn apply_color_wraps_when_known() { + let colored = apply_color("[api]", Some("red")); + assert!(colored.contains("[api]")); + assert!(colored.contains("\u{1b}[")); + assert_eq!(apply_color("[api]", None), "[api]"); + } + + #[test] + fn strip_existing_prefix_matches_known_formats() { + assert_eq!(strip_existing_prefix("api", "[api] hello"), "hello"); + assert_eq!(strip_existing_prefix("api", "api \u{203a} hello"), "hello"); + assert_eq!(strip_existing_prefix("api", "api: hello"), "hello"); + assert_eq!(strip_existing_prefix("api", "api - hello"), "hello"); + assert_eq!(strip_existing_prefix("api", "hello"), "hello"); + } } diff --git a/src/output.rs b/src/output.rs index 1bb0a33..a0cd82f 100644 --- a/src/output.rs +++ b/src/output.rs @@ -174,4 +174,19 @@ mod tests { assert_eq!(buffer.len(), 1); assert_eq!(buffer.iter().next().unwrap().text, "y"); } + + #[test] + fn sanitize_text_strips_ansi() { + let raw = "\u{1b}[31mred\u{1b}[0m"; + assert_eq!(sanitize_text(raw, true), "red"); + assert_eq!(sanitize_text(raw, false), raw); + } + + #[test] + fn format_json_pretty_prints_valid_input() { + let pretty = format_json("{\"a\":1}"); + assert!(pretty.contains("\n")); + assert!(pretty.contains("\"a\": 1")); + assert_eq!(format_json("not json"), "not json"); + } } diff --git a/src/process.rs b/src/process.rs index f7d8d3a..2e1ebf0 100644 --- a/src/process.rs +++ b/src/process.rs @@ -104,3 +104,37 @@ impl ProcessState { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn process_state_inherits_follow_and_defaults() { + let spec = ProcessSpec { + name: "api".to_string(), + cmd: "echo".to_string(), + args: Vec::new(), + cwd: None, + color: None, + env: HashMap::new(), + restart_on_fail: false, + follow: true, + pre_cmd: None, + watch_paths: Vec::new(), + watch_ignore: Vec::new(), + watch_ignore_gitignore: false, + watch_debounce_ms: 200, + depends_on: Vec::new(), + ready_check: None, + tags: Vec::new(), + }; + let state = ProcessState::new(spec, 10); + assert_eq!(state.status, ProcessStatus::Idle); + assert!(state.follow); + assert_eq!(state.scroll, 0); + assert!(!state.ready); + assert!(state.pid.is_none()); + assert!(state.started_at.is_none()); + } +} diff --git a/src/runner.rs b/src/runner.rs index 9993a8b..5a38e85 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -731,6 +731,97 @@ async fn wait_for_exit( } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shutdown_stage_prefers_sigint_then_sigterm() { + let shutdown = ShutdownConfig::new(800, 500); + let now = tokio::time::Instant::now(); + let (stage, signal, deadline) = + ProcessManager::initial_shutdown_stage(shutdown, ProcessSignal::SigInt, now); + assert!(matches!(stage, ShutdownStage::SigInt)); + assert_eq!(signal, Some(ProcessSignal::SigInt)); + assert_eq!(deadline, now + Duration::from_millis(800)); + } + + #[test] + fn shutdown_stage_falls_back_when_sigint_disabled() { + let shutdown = ShutdownConfig::new(0, 500); + let now = tokio::time::Instant::now(); + let (stage, signal, deadline) = + ProcessManager::initial_shutdown_stage(shutdown, ProcessSignal::SigInt, now); + assert!(matches!(stage, ShutdownStage::SigTerm)); + assert_eq!(signal, Some(ProcessSignal::SigTerm)); + assert_eq!(deadline, now + Duration::from_millis(500)); + } + + #[test] + fn shutdown_stage_handles_all_disabled() { + let shutdown = ShutdownConfig::new(0, 0); + let now = tokio::time::Instant::now(); + let (stage, signal, deadline) = + ProcessManager::initial_shutdown_stage(shutdown, ProcessSignal::SigTerm, now); + assert!(matches!(stage, ShutdownStage::Kill)); + assert_eq!(signal, None); + assert_eq!(deadline, now); + } + + #[test] + fn shutdown_config_flags() { + let config = ShutdownConfig::new(100, 0); + assert!(config.sigint_enabled()); + assert!(!config.sigterm_enabled()); + assert_eq!(config.sigint_timeout(), Duration::from_millis(100)); + assert_eq!(config.sigterm_timeout(), Duration::from_millis(0)); + } + + #[cfg(unix)] + #[tokio::test] + async fn poll_shutdowns_advances_stage() { + let spec = ProcessSpec { + name: "sleep".to_string(), + cmd: "sleep".to_string(), + args: vec!["5".to_string()], + cwd: None, + color: None, + env: std::collections::HashMap::new(), + restart_on_fail: false, + follow: true, + pre_cmd: None, + watch_paths: Vec::new(), + watch_ignore: Vec::new(), + watch_ignore_gitignore: false, + watch_debounce_ms: 200, + depends_on: Vec::new(), + ready_check: None, + tags: Vec::new(), + }; + let (tx, _rx) = mpsc::channel(4); + let shutdown = ShutdownConfig::new(10, 1000); + let mut manager = ProcessManager::new(vec![spec], tx, shutdown); + let child = tokio::process::Command::new("sleep") + .arg("5") + .spawn() + .unwrap(); + manager.processes[0].child = Some(child); + manager.processes[0].shutdown = Some(ShutdownState { + stage: ShutdownStage::SigInt, + deadline: tokio::time::Instant::now() - Duration::from_millis(1), + }); + + manager.poll_shutdowns().await; + let stage = manager.processes[0].shutdown.unwrap().stage; + assert!(matches!(stage, ShutdownStage::SigTerm | ShutdownStage::Kill)); + + if let Some(mut child) = manager.processes[0].child.take() { + let _ = child.kill().await; + let _ = child.wait().await; + } + } +} + async fn read_stream( id: usize, stream: StreamKind, diff --git a/src/tui.rs b/src/tui.rs index 615019a..67dc2f3 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -709,3 +709,59 @@ fn truncate_spans(spans: Vec>, max: usize) -> Vec> { fn strip_carriage(text: &str) -> String { text.rsplit('\r').next().unwrap_or("").to_string() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_existing_prefix_removes_known_prefixes() { + assert_eq!(strip_existing_prefix("api", "[api] hello"), "hello"); + assert_eq!(strip_existing_prefix("api", "api: hello"), "hello"); + assert_eq!(strip_existing_prefix("api", "api - hello"), "hello"); + assert_eq!(strip_existing_prefix("api", "nope"), "nope"); + } + + #[test] + fn truncate_shortens_and_marks() { + assert_eq!(truncate("abcdef", 4), "abc~"); + assert_eq!(truncate("abc", 4), "abc"); + assert_eq!(truncate("abc", 0), ""); + } + + #[test] + fn truncate_spans_limits_total_width() { + let spans = vec![ + Span::raw("abc"), + Span::raw("def"), + ]; + let truncated = truncate_spans(spans, 4); + let text = truncated.iter().map(|s| s.content.to_string()).collect::(); + assert!(text.ends_with("~")); + assert!(text.len() <= 5); + } + + #[test] + fn truncate_spans_zero_max_returns_empty() { + let spans = vec![Span::raw("abc")]; + let truncated = truncate_spans(spans, 0); + assert!(truncated.is_empty()); + } + + #[test] + fn status_style_reflects_state() { + assert_eq!(status_style(&ProcessStatus::Idle).fg, Some(Color::DarkGray)); + assert_eq!(status_style(&ProcessStatus::Running).fg, Some(Color::Green)); + assert_eq!(status_style(&ProcessStatus::Starting).fg, Some(Color::Yellow)); + assert_eq!( + status_style(&ProcessStatus::Exited { code: Some(1) }).fg, + Some(Color::Red) + ); + } + + #[test] + fn strip_carriage_keeps_last_segment() { + assert_eq!(strip_carriage("abc\rdef"), "def"); + assert_eq!(strip_carriage("abc"), "abc"); + } +} diff --git a/src/watch.rs b/src/watch.rs index 3e18c38..4534a83 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -206,3 +206,38 @@ fn build_gitignore(base: &Path) -> Result { } Ok(builder.build()?) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_watch_paths_handles_absolute_and_relative() { + let base = Path::new("/tmp/piperack-tests"); + let paths = vec!["src".to_string(), "/var/log".to_string()]; + let resolved = resolve_watch_paths(base, &paths); + assert_eq!(resolved[0], base.join("src")); + assert_eq!(resolved[1], PathBuf::from("/var/log")); + } + + #[test] + fn expand_pattern_adds_recursive_glob_for_dirs() { + let patterns = expand_pattern("src"); + assert_eq!(patterns, vec!["src".to_string(), "src/**".to_string()]); + + let trimmed = expand_pattern("src/"); + assert_eq!(trimmed, vec!["src".to_string(), "src/**".to_string()]); + + let globbed = expand_pattern("*.rs"); + assert_eq!(globbed, vec!["*.rs".to_string()]); + } + + #[test] + fn ignore_matcher_respects_globs() { + let base = Path::new("/tmp/piperack-tests"); + let matcher = IgnoreMatcher::new(base, &vec!["target".to_string()], true).unwrap(); + assert!(matcher.is_ignored(&base.join("target"))); + assert!(matcher.is_ignored(&PathBuf::from("target"))); + assert!(!matcher.is_ignored(&base.join("src"))); + } +}