Skip to content
Open
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
7 changes: 7 additions & 0 deletions src-tauri/crates/test-runner/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,15 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
tauri = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true } # CancellationToken for run cancellation
tracing = "0.1"
uuid = { version = "1", features = ["v4"] }

[target.'cfg(unix)'.dependencies]
libc = "0.2" # process-group signalling on cancellation

[target.'cfg(windows)'.dependencies]
app_platform = { path = "../app-platform" } # CREATE_NO_WINDOW for spawned test processes / taskkill

[dev-dependencies]
app_utils = { path = "../app-utils", features = ["testing"] }
122 changes: 122 additions & 0 deletions src-tauri/crates/test-runner/src/capture.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//! Bounded tail capture for child-process output streams.
//!
//! Test processes can emit arbitrarily large output (watch modes, verbose
//! reporters, runaway logging). Captured output must stay within a fixed
//! budget so a single run can never grow app memory without bound: once the
//! budget is exceeded the *oldest* bytes are dropped, keeping the tail —
//! summaries and failures print last, so the tail is the useful part for
//! both parsing and error reporting.

use std::collections::VecDeque;

use tokio::io::{AsyncRead, AsyncReadExt};

/// Output captured from one stream (stdout or stderr) of a test process.
#[derive(Debug)]
pub(crate) struct CapturedOutput {
/// Tail of the stream, lossily decoded as UTF-8. At most `max_bytes`
/// long (a byte or two shorter when truncation split a code point).
pub text: String,
/// True when the stream produced more than `max_bytes` and the head
/// was dropped. Parsers that need the full document (JSON reporters)
/// cannot succeed on truncated output.
pub truncated: bool,
/// Total bytes the stream produced, including dropped bytes.
pub total_bytes: u64,
}

/// Read `reader` to EOF, retaining at most `max_bytes` of the tail.
///
/// Reads in fixed-size chunks (never buffers a whole line), so a single
/// line larger than the budget — e.g. one giant JSON document — still
/// respects the bound.
pub(crate) async fn capture_stream<R: AsyncRead + Unpin>(
mut reader: R,
max_bytes: usize,
) -> CapturedOutput {
let mut tail: VecDeque<u8> = VecDeque::new();
let mut total_bytes: u64 = 0;
let mut chunk = [0u8; 8192];

loop {
match reader.read(&mut chunk).await {
Ok(0) => break,
Ok(n) => {
total_bytes += n as u64;
tail.extend(&chunk[..n]);
if tail.len() > max_bytes {
let excess = tail.len() - max_bytes;
tail.drain(..excess);
}
}
// Pipe errors (e.g. the child was killed mid-write) end the
// capture; whatever arrived so far is still returned.
Err(_) => break,
}
}

let truncated = total_bytes > tail.len() as u64;
let bytes: Vec<u8> = tail.into();
CapturedOutput {
text: String::from_utf8_lossy(&bytes).into_owned(),
truncated,
total_bytes,
}
}

#[cfg(test)]
mod tests {
use super::*;

#[tokio::test]
async fn small_output_is_kept_verbatim() {
let captured = capture_stream(&b"hello\nworld\n"[..], 1024).await;
assert_eq!(captured.text, "hello\nworld\n");
assert!(!captured.truncated);
assert_eq!(captured.total_bytes, 12);
}

#[tokio::test]
async fn oversized_output_keeps_only_the_tail() {
let input: Vec<u8> = (0..10_000u32)
.flat_map(|i| format!("line-{i}\n").into_bytes())
.collect();
let captured = capture_stream(&input[..], 1000).await;
assert!(captured.truncated);
assert_eq!(captured.total_bytes, input.len() as u64);
assert!(captured.text.len() <= 1000);
// The tail must contain the *last* line, not the first.
assert!(captured.text.contains("line-9999"));
assert!(!captured.text.contains("line-0\n"));
}

#[tokio::test]
async fn single_line_larger_than_budget_is_bounded() {
let input = vec![b'a'; 1_000_000];
let captured = capture_stream(&input[..], 4096).await;
assert!(captured.truncated);
assert_eq!(captured.total_bytes, 1_000_000);
assert_eq!(captured.text.len(), 4096);
}

#[tokio::test]
async fn truncation_mid_code_point_is_lossy_not_fatal() {
// 4-byte emoji repeated; a byte-oriented cut can land mid-sequence.
// Each orphaned lead-in byte decodes to one 3-byte U+FFFD, so the
// text may exceed the byte budget by a few replacement chars — the
// point is that decoding stays sane, not byte-exact.
let input: Vec<u8> = "😀".repeat(1000).into_bytes();
let captured = capture_stream(&input[..], 10).await;
assert!(captured.truncated);
assert!(captured.text.len() <= 10 + 3 * "\u{FFFD}".len());
assert!(captured.text.contains('😀'));
}

#[tokio::test]
async fn empty_stream_yields_empty_capture() {
let captured = capture_stream(&b""[..], 1024).await;
assert_eq!(captured.text, "");
assert!(!captured.truncated);
assert_eq!(captured.total_bytes, 0);
}
}
130 changes: 96 additions & 34 deletions src-tauri/crates/test-runner/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,63 @@ use crate::types::*;

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tauri::{AppHandle, State};
use tokio::sync::Mutex;

/// State for tracking running test processes
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, State};
use tokio_util::sync::CancellationToken;

/// Registry of in-flight test runs, keyed by the canonical `run_id`.
///
/// `run_tests` mints one `run_id` per command invocation, registers it here
/// *before* any event is emitted, and hands the same id to the runner — so
/// the id the frontend sees on `run_started` is always a valid key for
/// `stop_tests`. Entries are removed when the run future completes (or is
/// dropped), so the map never retains terminal runs.
pub struct TestRunnerState {
/// Map of run_id to cancellation flag
running: Arc<Mutex<HashMap<String, bool>>>,
running: Mutex<HashMap<String, CancellationToken>>,
}

impl TestRunnerState {
pub fn new() -> Self {
Self {
running: Arc::new(Mutex::new(HashMap::new())),
running: Mutex::new(HashMap::new()),
}
}

/// Register a new run and return the token the runner must observe.
fn begin(&self, run_id: &str) -> CancellationToken {
let token = CancellationToken::new();
self.lock().insert(run_id.to_string(), token.clone());
token
}

/// Remove a run from the registry once its future settles.
fn finish(&self, run_id: &str) {
self.lock().remove(run_id);
}

/// Signal cancellation for `run_id`. Returns `true` when an active run
/// was found and signalled, `false` when it already finished — a benign
/// race for callers, not an error.
fn request_stop(&self, run_id: &str) -> bool {
match self.lock().get(run_id) {
Some(token) => {
token.cancel();
true
}
None => false,
}
}

fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, CancellationToken>> {
self.running
.lock()
.expect("test runner state lock poisoned")
}

#[cfg(test)]
fn active_runs(&self) -> usize {
self.lock().len()
}
}

impl Default for TestRunnerState {
Expand All @@ -31,6 +72,19 @@ impl Default for TestRunnerState {
}
}

/// Deregisters a run when the `run_tests` future settles — including when
/// Tauri drops the future because the invoking webview went away.
struct RunGuard<'a> {
state: &'a TestRunnerState,
run_id: &'a str,
}

impl Drop for RunGuard<'_> {
fn drop(&mut self) {
self.state.finish(self.run_id);
}
}

/// Detect test framework in a project
#[tauri::command]
pub async fn detect_test_framework(workspace_path: String) -> Result<TestFramework, String> {
Expand Down Expand Up @@ -122,36 +176,40 @@ pub async fn run_tests(
return Err("No test framework detected in project".to_string());
}

// Mark as running
// Mint the canonical run id and register it before the runner emits
// anything, so a stop request for the id seen on `run_started` always
// finds this entry.
let run_id = uuid::Uuid::new_v4().to_string();
{
let mut running = state.running.lock().await;
running.insert(run_id.clone(), false);
}

// Run tests
let result = runner::run_tests(app, &path, detected_framework, test_ids).await;

// Remove from running
{
let mut running = state.running.lock().await;
running.remove(&run_id);
}

result
let cancel = state.begin(&run_id);
let _guard = RunGuard {
state: state.inner(),
run_id: &run_id,
};

let emit = move |event: TestEvent| {
let _ = app.emit("test-event", event);
};

runner::run_tests(
run_id.clone(),
&path,
detected_framework,
test_ids,
cancel,
&emit,
)
.await
}

/// Stop a running test
/// Signal cancellation for a running test run.
///
/// Returns `true` when an active run was signalled; the terminated run then
/// reports itself via a `run_cancelled` event. Returns `false` when the run
/// had already finished — callers should treat that as "nothing to stop",
/// not as a failure.
#[tauri::command]
pub async fn stop_tests(run_id: String, state: State<'_, TestRunnerState>) -> Result<(), String> {
let mut running = state.running.lock().await;

if let Some(cancelled) = running.get_mut(&run_id) {
*cancelled = true;
Ok(())
} else {
Err(format!("No running test with id: {}", run_id))
}
pub async fn stop_tests(run_id: String, state: State<'_, TestRunnerState>) -> Result<bool, String> {
Ok(state.request_stop(&run_id))
}

/// Get test patterns for a framework (useful for frontend filtering)
Expand All @@ -162,3 +220,7 @@ pub fn get_test_patterns(framework: TestFramework) -> Vec<String> {
.map(|s| s.to_string())
.collect()
}

#[cfg(test)]
#[path = "tests/commands_tests.rs"]
mod tests;
1 change: 1 addition & 0 deletions src-tauri/crates/test-runner/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
//! - Python: Pytest
//! - Rust: `cargo test`

pub(crate) mod capture;
pub mod commands;
pub mod detection;
pub mod discovery;
Expand Down
Loading
Loading