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
117 changes: 117 additions & 0 deletions crates/karva/tests/it/cancel.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#![cfg(unix)]

use std::process::{Command, Stdio};
use std::time::Duration;

use insta::assert_snapshot;

use crate::common::TestContext;

#[test]
fn test_ctrlc_emits_cancellation_banner() {
// Mix of fast tests (which complete and print PASS lines) and slow
// tests (which keep workers busy when SIGINT arrives) so the snapshot
// exercises both code paths and shows non-trivial output.
let context = TestContext::with_file(
"test_mixed.py",
r"
import time

def test_fast_a(): pass
def test_fast_b(): pass
def test_fast_c(): pass
def test_fast_d(): pass
def test_fast_e(): pass
def test_slow_a(): time.sleep(60)
def test_slow_b(): time.sleep(60)
def test_slow_c(): time.sleep(60)
def test_slow_d(): time.sleep(60)
def test_slow_e(): time.sleep(60)
",
);

let child = context
.command()
.args(["--num-workers", "2"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("Failed to spawn karva");

let pid = child.id();

// Wait long enough for karva to launch its workers, run the fast
// tests, and reach the wait-for-completion loop blocked on the slow
// tests. The slow tests sleep for 60s so karva will still be running
// when we send the signal.
std::thread::sleep(Duration::from_secs(5));

let status = Command::new("kill")
.args(["-s", "INT", &pid.to_string()])
.status()
.expect("Failed to invoke kill");
assert!(status.success(), "kill -s INT {pid} failed");

let output = child
.wait_with_output()
.expect("Failed to wait on karva process");

let mut stdout = String::from_utf8_lossy(&output.stdout).into_owned();
// Which two of the five slow tests are in flight when SIGINT arrives
// depends on partitioning and timing, so collapse the suffix to keep
// the snapshot stable across runs.
stdout = regex::Regex::new(r"test_slow_[a-e]")
.unwrap()
.replace_all(&stdout, "test_slow_X")
.into_owned();
// Worker scheduling means PASS and SIGINT lines can appear in any
// order. Sort each block independently for a deterministic snapshot.
// The ordering of every other line (Starting / Cancelling / summary
// / error) is deterministic.
sort_block_starting_with(&mut stdout, "PASS");
sort_block_starting_with(&mut stdout, "SIGINT");

assert_snapshot!(stdout, @r"
Starting 10 tests across 2 workers
PASS [TIME] test_mixed::test_fast_a
PASS [TIME] test_mixed::test_fast_b
PASS [TIME] test_mixed::test_fast_c
PASS [TIME] test_mixed::test_fast_d
PASS [TIME] test_mixed::test_fast_e
Cancelling due to interrupt: 10 tests still running
SIGINT [TIME] test_mixed::test_slow_X
SIGINT [TIME] test_mixed::test_slow_X
────────────
Summary [TIME] 0 tests run: 0 passed, 0 skipped
error: no tests to run
(hint: use `--no-tests` to customize)
");
}

/// Sort the contiguous block of lines whose first token is `label` so
/// the snapshot is deterministic. Workers run in parallel so PASS- and
/// SIGINT-line ordering is racy, but every other line is emitted by
/// the orchestrator in a fixed order.
fn sort_block_starting_with(stdout: &mut String, label: &str) {
let lines: Vec<&str> = stdout.lines().collect();
let first = lines.iter().position(|l| l.trim_start().starts_with(label));
let Some(start) = first else { return };
let end = start
+ lines[start..]
.iter()
.take_while(|l| l.trim_start().starts_with(label))
.count();
let mut sorted: Vec<String> = lines[start..end].iter().map(ToString::to_string).collect();
sorted.sort();
let mut rebuilt = lines[..start].join("\n");
if !rebuilt.is_empty() {
rebuilt.push('\n');
}
rebuilt.push_str(&sorted.join("\n"));
rebuilt.push('\n');
rebuilt.push_str(&lines[end..].join("\n"));
if stdout.ends_with('\n') {
rebuilt.push('\n');
}
*stdout = rebuilt;
}
1 change: 1 addition & 0 deletions crates/karva/tests/it/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub(crate) mod common;
mod r#async;
mod basic;
mod cache;
mod cancel;
mod configuration;
mod coverage;
mod discovery;
Expand Down
5 changes: 5 additions & 0 deletions crates/karva_cache/src/artifact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ pub enum CacheFile {
FailFastSignal,
/// Cache-root JSON: list of last-run failed test names.
LastFailed,
/// Per-worker JSON: name + start time of the test currently executing,
/// or absent when the worker is between tests. Used by the orchestrator
/// to render per-test `SIGINT` lines on Ctrl+C.
CurrentTest,
}

impl CacheFile {
Expand All @@ -46,6 +50,7 @@ impl CacheFile {
Self::Coverage => "coverage.json",
Self::FailFastSignal => "fail-fast",
Self::LastFailed => "last-failed.json",
Self::CurrentTest => "current_test.json",
}
}

Expand Down
27 changes: 27 additions & 0 deletions crates/karva_cache/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,24 @@ use anyhow::Result;
use camino::{Utf8Path, Utf8PathBuf};
use karva_diagnostic::{FlakyTest, TestResultStats, TestRunResult};
use ruff_db::diagnostic::{DisplayDiagnosticConfig, DisplayDiagnostics, FileResolver};
use serde::{Deserialize, Serialize};

use crate::artifact::{CacheFile, read_json, read_text, write_json, write_json_if_nonempty};
use crate::{RUN_PREFIX, RunHash, WORKER_PREFIX, worker_folder};

/// Snapshot of the test a worker is currently executing.
///
/// Workers update this file at the start of each test and remove it on
/// completion; the orchestrator reads it on Ctrl+C to render per-test
/// `SIGINT` lines.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurrentTest {
/// Fully qualified test name (`module::function[params]`).
pub name: String,
/// Wall-clock start of the test, milliseconds since the Unix epoch.
pub start_unix_ms: u64,
}

/// Aggregated test results collected from all worker processes.
#[derive(Default)]
pub struct AggregatedResults {
Expand Down Expand Up @@ -67,6 +81,19 @@ impl RunCache {
CacheFile::Coverage.path_in(&self.worker_dir(worker_id))
}

/// Path to the per-worker file describing the test currently executing.
pub fn current_test_file(&self, worker_id: usize) -> Utf8PathBuf {
CacheFile::CurrentTest.path_in(&self.worker_dir(worker_id))
}

/// Reads the snapshot of which test the worker is currently running.
/// Returns `None` if the worker is between tests or hasn't started yet.
pub fn read_current_test(&self, worker_id: usize) -> Option<CurrentTest> {
read_json::<CurrentTest>(&self.worker_dir(worker_id), CacheFile::CurrentTest)
.ok()
.flatten()
}

/// Returns paths to every per-worker coverage file that exists for this
/// run, sorted by worker directory. Used to feed the coverage report.
pub fn coverage_files(&self) -> Result<Vec<Utf8PathBuf>> {
Expand Down
4 changes: 2 additions & 2 deletions crates/karva_cache/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ pub(crate) mod cache;
pub(crate) mod hash;

pub use cache::{
AggregatedResults, PruneResult, RunCache, clean_cache, prune_cache, read_last_failed,
read_recent_durations, write_last_failed,
AggregatedResults, CurrentTest, PruneResult, RunCache, clean_cache, prune_cache,
read_last_failed, read_recent_durations, write_last_failed,
};
pub use hash::RunHash;
pub use karva_diagnostic::{DisplayFlakyTests, FlakyTest};
Expand Down
81 changes: 79 additions & 2 deletions crates/karva_diagnostic/src/reporter.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::fmt::Write;
use std::time::Duration;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use camino::Utf8PathBuf;
use colored::Colorize;
use karva_logging::time::format_duration_bracketed;
use karva_logging::{Printer, StatusLevel};
Expand Down Expand Up @@ -41,6 +42,21 @@ pub trait Reporter: Send + Sync {
fn report_test_slow(&self, test_name: &QualifiedTestName, duration: Duration) {
let _ = (test_name, duration);
}

/// Called immediately before a test starts executing.
///
/// Used by reporters that track in-flight tests for cancellation
/// reporting; default is a no-op.
fn report_test_started(&self, test_name: &QualifiedTestName) {
let _ = test_name;
}

/// Called when a test finishes (passed, failed, or skipped) so the
/// reporter can clear any in-flight state recorded by
/// [`Self::report_test_started`]. Default no-op.
fn report_test_finished(&self, test_name: &QualifiedTestName) {
let _ = test_name;
}
}

fn show_for_status_level(level: StatusLevel, kind: &IndividualTestResultKind) -> bool {
Expand Down Expand Up @@ -77,11 +93,27 @@ impl Reporter for DummyReporter {
/// A reporter that outputs test results to stdout as they complete.
pub struct TestCaseReporter {
printer: Printer,
/// Optional path to a JSON file describing the test currently
/// executing. The orchestrator reads this on Ctrl+C to render
/// per-test `SIGINT` lines.
progress_file: Option<Utf8PathBuf>,
}

impl TestCaseReporter {
pub fn new(printer: Printer) -> Self {
Self { printer }
Self {
printer,
progress_file: None,
}
}

/// Direct the reporter to write the currently running test's name and
/// start time to `path` whenever a test begins, and remove the file
/// when it ends.
#[must_use]
pub fn with_progress_file(mut self, path: Utf8PathBuf) -> Self {
self.progress_file = Some(path);
self
}
}

Expand Down Expand Up @@ -163,6 +195,51 @@ impl Reporter for TestCaseReporter {
)
.ok();
}

fn report_test_started(&self, test_name: &QualifiedTestName) {
let Some(path) = self.progress_file.as_ref() else {
return;
};
let start_unix_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(0);
// Avoid pulling in `karva_cache::CurrentTest` here (would be a
// circular dep). The cache crate deserialises the same JSON shape.
let body = format!(
"{{\"name\":{},\"start_unix_ms\":{start_unix_ms}}}",
json_string(&test_name.to_string()),
);
let _ = std::fs::write(path, body);
}

fn report_test_finished(&self, _test_name: &QualifiedTestName) {
if let Some(path) = self.progress_file.as_ref() {
let _ = std::fs::remove_file(path);
}
}
}

/// Quote a string for JSON. Stays in this crate so we don't take a hard
/// dependency on `serde_json` just for one field.
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}

/// The width that result labels (`PASS`, `FAIL`, `SKIP`, `SLOW`, `TRY N PASS`,
Expand Down
Loading
Loading