Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
*.md
!README.md
!CONTRIBUTION.md

!/docs/**/*.md
# Rust build output
target/

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
47 changes: 47 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -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.
60 changes: 60 additions & 0 deletions src/ansi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,63 @@ fn basic_color(index: i32, bright: bool) -> Option<Color> {
};
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());
}
}
116 changes: 116 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 12 additions & 1 deletion src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
}
}
Loading