use std::path::{Path, PathBuf};
use std::time::Duration;
use async_trait::async_trait;
use boundgraph_core::{CheckerId, Condensate, Digest, GrantSet, WorkOrder};
use crate::checker::{Checker, Judgement};
use crate::sandbox::{Command, DEFAULT_TIMEOUT, Sandbox, workspace_grant};
pub const MAX_REASON: usize = 4_000;
pub const MAX_NAMES: usize = 20;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TestConfig {
pub program: String,
pub args: Vec<String>,
pub workspace: PathBuf,
pub timeout: Duration,
pub retries: u8,
}
impl TestConfig {
pub fn new(
program: impl Into<String>,
args: impl IntoIterator<Item = impl Into<String>>,
workspace: impl Into<PathBuf>,
) -> Self {
Self {
program: program.into(),
args: args.into_iter().map(Into::into).collect(),
workspace: workspace.into(),
timeout: DEFAULT_TIMEOUT,
retries: 0,
}
}
pub fn cargo(workspace: impl Into<PathBuf>) -> Self {
Self::new("cargo", ["test", "--all-targets"], workspace)
}
pub fn pytest(workspace: impl Into<PathBuf>) -> Self {
Self::new("pytest", ["-q"], workspace)
}
pub fn go(workspace: impl Into<PathBuf>) -> Self {
Self::new("go", ["test", "./..."], workspace)
}
#[must_use]
pub fn within(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn retrying(mut self, retries: u8) -> Self {
self.retries = retries;
self
}
pub fn grant(&self) -> GrantSet {
workspace_grant(&self.workspace, &self.program)
}
pub fn attempts(&self) -> u16 {
u16::from(self.retries) + 1
}
fn command(&self) -> Command {
Command::new(&self.program, self.args.clone(), self.workspace.clone()).within(self.timeout)
}
}
pub fn tail(text: &str, limit: usize) -> String {
let count = text.chars().count();
if count <= limit {
return text.to_string();
}
let kept: String = text.chars().skip(count - limit).collect();
format!("… (output truncated)\n{kept}")
}
const MARKERS: &[&str] = &[
"--- FAIL: ",
"FAILED ",
"● ",
"✕ ",
"✗ ",
];
pub fn failing_tests(output: &str) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
let mut push = |name: &str| {
let name = name.trim();
if !name.is_empty() && !names.iter().any(|seen| seen == name) {
names.push(name.to_string());
}
};
for line in output.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("test ")
&& let Some(name) = rest.strip_suffix(" ... FAILED")
{
push(name);
continue;
}
for marker in MARKERS {
if let Some(rest) = trimmed.strip_prefix(marker) {
let name = rest
.split(" - ")
.next()
.unwrap_or(rest)
.split(" (")
.next()
.unwrap_or(rest);
push(name);
break;
}
}
}
names
}
fn summarise(names: &[String]) -> String {
if names.is_empty() {
return String::new();
}
let shown: Vec<&str> = names.iter().take(MAX_NAMES).map(String::as_str).collect();
let rest = names.len().saturating_sub(shown.len());
let listed = shown.join(", ");
if rest == 0 {
format!(
"{} test{} failed: {listed}",
names.len(),
plural(names.len())
)
} else {
format!("{} tests failed: {listed}, and {rest} more", names.len())
}
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
#[derive(Debug)]
pub struct SandboxedTests<S: Sandbox> {
config: TestConfig,
sandbox: S,
id: CheckerId,
}
impl<S: Sandbox> SandboxedTests<S> {
pub fn new(sandbox: S, config: TestConfig) -> Self {
Self {
id: CheckerId::new(format!("test-run[{}]", config.program), 1),
config,
sandbox,
}
}
#[must_use]
pub fn named(mut self, name: impl Into<String>, version: u32) -> Self {
self.id = CheckerId::new(name, version);
self
}
pub fn workspace(&self) -> &Path {
&self.config.workspace
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Attempt {
number: u16,
exit: Option<i32>,
failing: Vec<String>,
}
impl Attempt {
fn line(&self) -> String {
let status = match self.exit {
Some(0) => "passed".to_string(),
Some(code) => format!("failed (exit {code})"),
None => "ended without an exit status".to_string(),
};
let names = if self.failing.is_empty() {
String::new()
} else {
format!(" — {}", summarise(&self.failing))
};
format!("attempt {}: {status}{names}", self.number)
}
}
#[async_trait]
impl<S: Sandbox> Checker for SandboxedTests<S> {
fn id(&self) -> CheckerId {
self.id.clone()
}
fn grants(&self) -> GrantSet {
self.config.grant()
}
async fn judge(&self, _order: &WorkOrder, _condensate: &Condensate) -> Judgement {
let command = self.config.command();
let grant = self.config.grant();
let mut attempts: Vec<Attempt> = Vec::new();
let mut last_output = String::new();
for number in 1..=self.config.attempts() {
let outcome = match self.sandbox.run(&command, &grant).await {
Ok(outcome) => outcome,
Err(e) => {
return Judgement::inconclusive(format!("the tests could not be run: {e}"))
.covering(log_digest(&attempts));
}
};
if outcome.timed_out {
return Judgement::inconclusive(format!(
"`{}` did not finish within {:?} on attempt {number}",
command.rendered(),
self.config.timeout
))
.covering(log_digest(&attempts));
}
last_output = outcome.diagnostics();
let failing = failing_tests(&last_output);
attempts.push(Attempt {
number,
exit: outcome.exit,
failing,
});
if outcome.succeeded() {
break;
}
}
let digest = log_digest(&attempts);
let passed = attempts.last().is_some_and(|a| a.exit == Some(0));
let disagreed = attempts.len() > 1 && passed;
if disagreed {
let log: Vec<String> = attempts.iter().map(Attempt::line).collect();
return Judgement::inconclusive(format!(
"the test suite is flaky: {} attempts disagreed.\n{}",
attempts.len(),
log.join("\n")
))
.covering(digest);
}
if passed {
return Judgement::pass().covering(digest);
}
let failing = attempts
.last()
.map(|a| a.failing.clone())
.unwrap_or_default();
let named = summarise(&failing);
let heading = if attempts.len() > 1 {
format!(
"the tests failed on all {} attempts. {named}",
attempts.len()
)
} else if named.is_empty() {
format!("`{}` reported failing tests.", command.rendered())
} else {
named
};
let detail = if last_output.trim().is_empty() {
"no output was produced".to_string()
} else {
tail(&last_output, MAX_REASON)
};
Judgement::fail(format!("{heading}\n{detail}")).covering(digest)
}
}
fn log_digest(attempts: &[Attempt]) -> Digest {
let log: Vec<String> = attempts.iter().map(Attempt::line).collect();
Digest::of(log.join("\n").as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sandbox::{Outcome, SandboxError};
use crate::sign::LocalKey;
use boundgraph_core::{CondensatePayload, Intent, ProposalOrigin, SchemaId, SlicerId, Verdict};
use std::sync::Mutex;
#[derive(Debug)]
struct Scripted {
script: Mutex<Vec<Result<Outcome, SandboxError>>>,
runs: Mutex<usize>,
}
impl Scripted {
fn of(script: Vec<Result<Outcome, SandboxError>>) -> Self {
Self {
script: Mutex::new(script),
runs: Mutex::new(0),
}
}
fn runs(&self) -> usize {
*self.runs.lock().unwrap()
}
}
#[async_trait]
impl Sandbox for Scripted {
async fn run(
&self,
_command: &Command,
_grant: &GrantSet,
) -> Result<Outcome, SandboxError> {
*self.runs.lock().unwrap() += 1;
let mut script = self.script.lock().unwrap();
if script.len() == 1 {
script[0].clone()
} else {
script.remove(0)
}
}
}
fn outcome(exit: Option<i32>, output: &str) -> Outcome {
Outcome {
exit,
stdout: output.to_string(),
stderr: String::new(),
timed_out: false,
truncated: false,
duration: Duration::from_millis(10),
}
}
fn passed() -> Result<Outcome, SandboxError> {
Ok(outcome(Some(0), "test result: ok. 42 passed; 0 failed"))
}
fn failed() -> Result<Outcome, SandboxError> {
Ok(outcome(
Some(101),
"test parse::header ... FAILED\ntest result: FAILED. 41 passed; 1 failed",
))
}
fn timed_out() -> Result<Outcome, SandboxError> {
Ok(Outcome {
exit: None,
stdout: String::new(),
stderr: String::new(),
timed_out: true,
truncated: false,
duration: Duration::from_secs(300),
})
}
fn config() -> TestConfig {
TestConfig::cargo("/work/repo")
}
fn order() -> WorkOrder {
WorkOrder::new(
Intent::new("patch.apply", &"fix the header parser").unwrap(),
vec![],
GrantSet::EMPTY,
ProposalOrigin::new("claude-opus-5", [], Digest::of(b"prompt")),
)
}
fn condensate() -> Condensate {
Condensate::new(
SchemaId::new("boundgraph.diff.v1", 1),
CondensatePayload::inline(&"one file changed").unwrap(),
Digest::of(b"the patch"),
400,
false,
SlicerId::new("diff", 1),
)
}
fn key() -> LocalKey {
LocalKey::from_seed("host-key-1", [7u8; 32])
}
async fn verdict(config: TestConfig, script: Vec<Result<Outcome, SandboxError>>) -> Verdict {
SandboxedTests::new(Scripted::of(script), config)
.check(&order(), &condensate(), &key())
.await
.unwrap()
.verdict
}
#[tokio::test]
async fn a_green_suite_passes() {
assert_eq!(verdict(config(), vec![passed()]).await, Verdict::Pass);
}
#[tokio::test]
async fn a_failing_suite_fails() {
let v = verdict(config(), vec![failed()]).await;
assert!(matches!(v, Verdict::Fail(_)), "{v:?}");
}
#[tokio::test]
async fn the_failure_reason_names_the_failing_tests() {
let v = verdict(config(), vec![failed()]).await;
let reason = v.reason().unwrap();
assert!(reason.contains("parse::header"), "{reason}");
assert!(reason.contains("1 test failed"), "{reason}");
assert!(reason.contains("41 passed"), "the output too: {reason}");
}
#[tokio::test]
async fn a_timeout_is_inconclusive() {
let v = verdict(config(), vec![timed_out()]).await;
assert!(matches!(v, Verdict::Inconclusive(_)), "{v:?}");
assert!(!v.is_pass());
}
#[tokio::test]
async fn a_timeout_is_not_retried() {
let sandbox = Scripted::of(vec![timed_out()]);
let checker = SandboxedTests::new(sandbox, config().retrying(3));
checker
.check(&order(), &condensate(), &key())
.await
.unwrap();
assert_eq!(checker.sandbox.runs(), 1);
}
#[tokio::test]
async fn a_sandbox_that_refuses_leaves_the_verdict_undecided() {
let refused = Err(SandboxError::Unenforceable {
atom: "fs:write:/work/repo/**".into(),
command: "cargo test".into(),
});
let v = verdict(config(), vec![refused]).await;
assert!(matches!(v, Verdict::Inconclusive(_)), "{v:?}");
}
#[tokio::test]
async fn a_failure_with_no_output_still_says_something() {
let v = verdict(config(), vec![Ok(outcome(Some(1), ""))]).await;
let reason = v.reason().unwrap();
assert!(reason.contains("no output"), "{reason}");
}
#[tokio::test]
async fn a_failing_suite_is_retried_as_configured() {
let sandbox = Scripted::of(vec![failed()]);
let checker = SandboxedTests::new(sandbox, config().retrying(2));
checker
.check(&order(), &condensate(), &key())
.await
.unwrap();
assert_eq!(checker.sandbox.runs(), 3, "one attempt plus two retries");
}
#[tokio::test]
async fn a_green_suite_is_not_retried() {
let sandbox = Scripted::of(vec![passed()]);
let checker = SandboxedTests::new(sandbox, config().retrying(5));
checker
.check(&order(), &condensate(), &key())
.await
.unwrap();
assert_eq!(checker.sandbox.runs(), 1);
}
#[tokio::test]
async fn every_attempt_is_named_when_they_all_failed() {
let v = verdict(config().retrying(2), vec![failed()]).await;
let reason = v.reason().unwrap();
assert!(reason.contains("all 3 attempts"), "{reason}");
}
#[tokio::test]
async fn attempts_that_disagree_are_flaky_rather_than_a_pass() {
let v = verdict(config().retrying(1), vec![failed(), passed()]).await;
assert!(matches!(v, Verdict::Inconclusive(_)), "{v:?}");
assert!(!v.is_pass(), "a flaky suite must not commit");
let reason = v.reason().unwrap();
assert!(reason.contains("flaky"), "{reason}");
assert!(reason.contains("attempt 1: failed"), "{reason}");
assert!(reason.contains("attempt 2: passed"), "{reason}");
}
#[tokio::test]
async fn with_no_retries_a_disagreement_cannot_arise() {
assert_eq!(config().retries, 0);
assert_eq!(config().attempts(), 1);
let v = verdict(config(), vec![failed(), passed()]).await;
assert!(matches!(v, Verdict::Fail(_)), "{v:?}");
}
#[tokio::test]
async fn the_attempt_log_is_covered_by_the_evidence() {
let once = SandboxedTests::new(Scripted::of(vec![failed()]), config())
.check(&order(), &condensate(), &key())
.await
.unwrap();
let thrice = SandboxedTests::new(Scripted::of(vec![failed()]), config().retrying(2))
.check(&order(), &condensate(), &key())
.await
.unwrap();
assert_ne!(
once.inputs, thrice.inputs,
"one attempt and three are different evidence"
);
}
#[test]
fn cargo_test_failures_are_recognised() {
let output = "\
running 3 tests
test parse::header ... ok
test parse::trailer ... FAILED
test emit::escape ... FAILED
failures:
parse::trailer
emit::escape
test result: FAILED. 1 passed; 2 failed";
assert_eq!(
failing_tests(output),
vec!["parse::trailer", "emit::escape"],
"and the `failures:` block must not duplicate them"
);
}
#[test]
fn pytest_failures_are_recognised() {
let output = "\
FAILED tests/test_parse.py::test_header - AssertionError: expected 3
FAILED tests/test_parse.py::test_trailer - IndexError
2 failed, 41 passed in 1.20s";
assert_eq!(
failing_tests(output),
vec![
"tests/test_parse.py::test_header",
"tests/test_parse.py::test_trailer"
]
);
}
#[test]
fn go_test_failures_are_recognised() {
let output = "--- FAIL: TestParseHeader (0.00s)\n parse_test.go:12: got 2, want 3\nFAIL";
assert_eq!(failing_tests(output), vec!["TestParseHeader"]);
}
#[test]
fn jest_family_failures_are_recognised() {
let output = " ✕ parses a header (3 ms)\n ● parses a trailer\n ✓ escapes";
assert_eq!(
failing_tests(output),
vec!["parses a header", "parses a trailer"]
);
}
#[test]
fn a_passing_run_yields_no_names() {
assert!(failing_tests("test parse::header ... ok\ntest result: ok.").is_empty());
}
#[test]
fn an_unrecognised_runner_yields_no_names_rather_than_a_guess() {
assert!(failing_tests("Suite finished with problems in 3 places").is_empty());
}
#[test]
fn a_long_list_of_failures_is_counted_rather_than_listed() {
let names: Vec<String> = (0..50).map(|n| format!("test_{n}")).collect();
let rendered = summarise(&names);
assert!(rendered.contains("50 tests failed"));
assert!(rendered.contains("and 30 more"), "{rendered}");
assert!(rendered.contains("test_0"));
assert!(!rendered.contains("test_49"));
}
#[test]
fn one_failure_is_singular() {
assert!(summarise(&["only_one".to_string()]).starts_with("1 test failed"));
}
#[tokio::test]
async fn a_huge_test_log_is_cut_from_the_back_where_the_summary_is() {
let noise = format!(
"{}test result: FAILED. 4000 passed; 1 failed",
"test filler ... ok\n".repeat(5_000)
);
let v = verdict(config(), vec![Ok(outcome(Some(101), &noise))]).await;
let reason = v.reason().unwrap();
assert!(
reason.contains("test result: FAILED"),
"the summary survives"
);
assert!(reason.contains("truncated"));
}
#[test]
fn tail_keeps_the_end_and_says_when_it_cut() {
let text = "first\nmiddle\nlast";
assert!(tail(text, 5).ends_with("\nlast"));
assert!(tail(text, 5).contains("truncated"));
assert_eq!(tail(text, 100), text);
}
#[test]
fn truncating_does_not_cut_a_character_in_half() {
let text = "é".repeat(100);
assert!(!tail(&text, 10).contains('\u{fffd}'));
}
#[tokio::test]
async fn the_grant_is_read_and_execute_only() {
let grant = config().grant();
assert!(grant.iter().all(|a| a.action != "write"), "{grant:?}");
assert!(grant.iter().all(|a| a.action != "delete"), "{grant:?}");
assert!(grant.iter().all(|a| a.domain != "net"), "{grant:?}");
assert!(
grant
.iter()
.all(|a| a.action == "read" || a.action == "list" || a.action == "exec")
);
}
#[tokio::test]
async fn the_grant_is_within_what_a_checker_may_hold() {
use boundgraph_grant::{Tri, set_leq};
assert_eq!(
set_leq(&config().grant(), &crate::checker_ceiling()),
Tri::Yes
);
}
#[tokio::test]
async fn the_same_run_always_gives_the_same_evidence() {
let (order, k, key) = (order(), condensate(), key());
let once = SandboxedTests::new(Scripted::of(vec![failed()]), config())
.check(&order, &k, &key)
.await
.unwrap();
for _ in 0..4 {
let again = SandboxedTests::new(Scripted::of(vec![failed()]), config())
.check(&order, &k, &key)
.await
.unwrap();
assert_eq!(again, once);
}
}
}