1use std::path::{Path, PathBuf};
45use std::time::Duration;
46
47use async_trait::async_trait;
48use boundgraph_core::{CheckerId, Condensate, Digest, GrantSet, WorkOrder};
49
50use crate::checker::{Checker, Judgement};
51use crate::sandbox::{Command, DEFAULT_TIMEOUT, Sandbox, workspace_grant};
52
53pub const MAX_REASON: usize = 4_000;
55
56pub const MAX_NAMES: usize = 20;
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct TestConfig {
65 pub program: String,
67 pub args: Vec<String>,
69 pub workspace: PathBuf,
71 pub timeout: Duration,
73 pub retries: u8,
78}
79
80impl TestConfig {
81 pub fn new(
83 program: impl Into<String>,
84 args: impl IntoIterator<Item = impl Into<String>>,
85 workspace: impl Into<PathBuf>,
86 ) -> Self {
87 Self {
88 program: program.into(),
89 args: args.into_iter().map(Into::into).collect(),
90 workspace: workspace.into(),
91 timeout: DEFAULT_TIMEOUT,
92 retries: 0,
93 }
94 }
95
96 pub fn cargo(workspace: impl Into<PathBuf>) -> Self {
98 Self::new("cargo", ["test", "--all-targets"], workspace)
99 }
100
101 pub fn pytest(workspace: impl Into<PathBuf>) -> Self {
103 Self::new("pytest", ["-q"], workspace)
104 }
105
106 pub fn go(workspace: impl Into<PathBuf>) -> Self {
108 Self::new("go", ["test", "./..."], workspace)
109 }
110
111 #[must_use]
113 pub fn within(mut self, timeout: Duration) -> Self {
114 self.timeout = timeout;
115 self
116 }
117
118 #[must_use]
121 pub fn retrying(mut self, retries: u8) -> Self {
122 self.retries = retries;
123 self
124 }
125
126 pub fn grant(&self) -> GrantSet {
131 workspace_grant(&self.workspace, &self.program)
132 }
133
134 pub fn attempts(&self) -> u16 {
136 u16::from(self.retries) + 1
137 }
138
139 fn command(&self) -> Command {
140 Command::new(&self.program, self.args.clone(), self.workspace.clone()).within(self.timeout)
141 }
142}
143
144pub fn tail(text: &str, limit: usize) -> String {
149 let count = text.chars().count();
150 if count <= limit {
151 return text.to_string();
152 }
153 let kept: String = text.chars().skip(count - limit).collect();
154 format!("… (output truncated)\n{kept}")
155}
156
157const MARKERS: &[&str] = &[
163 "--- FAIL: ",
165 "FAILED ",
167 "● ",
169 "✕ ",
170 "✗ ",
171];
172
173pub fn failing_tests(output: &str) -> Vec<String> {
183 let mut names: Vec<String> = Vec::new();
184 let mut push = |name: &str| {
185 let name = name.trim();
186 if !name.is_empty() && !names.iter().any(|seen| seen == name) {
187 names.push(name.to_string());
188 }
189 };
190
191 for line in output.lines() {
192 let trimmed = line.trim();
193
194 if let Some(rest) = trimmed.strip_prefix("test ")
196 && let Some(name) = rest.strip_suffix(" ... FAILED")
197 {
198 push(name);
199 continue;
200 }
201
202 for marker in MARKERS {
203 if let Some(rest) = trimmed.strip_prefix(marker) {
204 let name = rest
207 .split(" - ")
208 .next()
209 .unwrap_or(rest)
210 .split(" (")
211 .next()
212 .unwrap_or(rest);
213 push(name);
214 break;
215 }
216 }
217 }
218
219 names
220}
221
222fn summarise(names: &[String]) -> String {
224 if names.is_empty() {
225 return String::new();
226 }
227 let shown: Vec<&str> = names.iter().take(MAX_NAMES).map(String::as_str).collect();
228 let rest = names.len().saturating_sub(shown.len());
229
230 let listed = shown.join(", ");
231 if rest == 0 {
232 format!(
233 "{} test{} failed: {listed}",
234 names.len(),
235 plural(names.len())
236 )
237 } else {
238 format!("{} tests failed: {listed}, and {rest} more", names.len())
239 }
240}
241
242fn plural(n: usize) -> &'static str {
243 if n == 1 { "" } else { "s" }
244}
245
246#[derive(Debug)]
248pub struct SandboxedTests<S: Sandbox> {
249 config: TestConfig,
250 sandbox: S,
251 id: CheckerId,
252}
253
254impl<S: Sandbox> SandboxedTests<S> {
255 pub fn new(sandbox: S, config: TestConfig) -> Self {
257 Self {
258 id: CheckerId::new(format!("test-run[{}]", config.program), 1),
259 config,
260 sandbox,
261 }
262 }
263
264 #[must_use]
266 pub fn named(mut self, name: impl Into<String>, version: u32) -> Self {
267 self.id = CheckerId::new(name, version);
268 self
269 }
270
271 pub fn workspace(&self) -> &Path {
273 &self.config.workspace
274 }
275}
276
277#[derive(Debug, Clone, PartialEq, Eq)]
283struct Attempt {
284 number: u16,
285 exit: Option<i32>,
286 failing: Vec<String>,
287}
288
289impl Attempt {
290 fn line(&self) -> String {
291 let status = match self.exit {
292 Some(0) => "passed".to_string(),
293 Some(code) => format!("failed (exit {code})"),
294 None => "ended without an exit status".to_string(),
295 };
296 let names = if self.failing.is_empty() {
297 String::new()
298 } else {
299 format!(" — {}", summarise(&self.failing))
300 };
301 format!("attempt {}: {status}{names}", self.number)
302 }
303}
304
305#[async_trait]
306impl<S: Sandbox> Checker for SandboxedTests<S> {
307 fn id(&self) -> CheckerId {
308 self.id.clone()
309 }
310
311 fn grants(&self) -> GrantSet {
312 self.config.grant()
313 }
314
315 async fn judge(&self, _order: &WorkOrder, _condensate: &Condensate) -> Judgement {
316 let command = self.config.command();
317 let grant = self.config.grant();
318
319 let mut attempts: Vec<Attempt> = Vec::new();
320 let mut last_output = String::new();
321
322 for number in 1..=self.config.attempts() {
323 let outcome = match self.sandbox.run(&command, &grant).await {
324 Ok(outcome) => outcome,
325 Err(e) => {
327 return Judgement::inconclusive(format!("the tests could not be run: {e}"))
328 .covering(log_digest(&attempts));
329 }
330 };
331
332 if outcome.timed_out {
333 return Judgement::inconclusive(format!(
336 "`{}` did not finish within {:?} on attempt {number}",
337 command.rendered(),
338 self.config.timeout
339 ))
340 .covering(log_digest(&attempts));
341 }
342
343 last_output = outcome.diagnostics();
344 let failing = failing_tests(&last_output);
345 attempts.push(Attempt {
346 number,
347 exit: outcome.exit,
348 failing,
349 });
350
351 if outcome.succeeded() {
352 break;
353 }
354 }
355
356 let digest = log_digest(&attempts);
357 let passed = attempts.last().is_some_and(|a| a.exit == Some(0));
358 let disagreed = attempts.len() > 1 && passed;
359
360 if disagreed {
361 let log: Vec<String> = attempts.iter().map(Attempt::line).collect();
364 return Judgement::inconclusive(format!(
365 "the test suite is flaky: {} attempts disagreed.\n{}",
366 attempts.len(),
367 log.join("\n")
368 ))
369 .covering(digest);
370 }
371
372 if passed {
373 return Judgement::pass().covering(digest);
374 }
375
376 let failing = attempts
377 .last()
378 .map(|a| a.failing.clone())
379 .unwrap_or_default();
380 let named = summarise(&failing);
381 let heading = if attempts.len() > 1 {
382 format!(
383 "the tests failed on all {} attempts. {named}",
384 attempts.len()
385 )
386 } else if named.is_empty() {
387 format!("`{}` reported failing tests.", command.rendered())
388 } else {
389 named
390 };
391
392 let detail = if last_output.trim().is_empty() {
393 "no output was produced".to_string()
394 } else {
395 tail(&last_output, MAX_REASON)
396 };
397
398 Judgement::fail(format!("{heading}\n{detail}")).covering(digest)
399 }
400}
401
402fn log_digest(attempts: &[Attempt]) -> Digest {
405 let log: Vec<String> = attempts.iter().map(Attempt::line).collect();
406 Digest::of(log.join("\n").as_bytes())
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::sandbox::{Outcome, SandboxError};
413 use crate::sign::LocalKey;
414 use boundgraph_core::{CondensatePayload, Intent, ProposalOrigin, SchemaId, SlicerId, Verdict};
415 use std::sync::Mutex;
416
417 #[derive(Debug)]
419 struct Scripted {
420 script: Mutex<Vec<Result<Outcome, SandboxError>>>,
421 runs: Mutex<usize>,
422 }
423
424 impl Scripted {
425 fn of(script: Vec<Result<Outcome, SandboxError>>) -> Self {
426 Self {
427 script: Mutex::new(script),
428 runs: Mutex::new(0),
429 }
430 }
431
432 fn runs(&self) -> usize {
433 *self.runs.lock().unwrap()
434 }
435 }
436
437 #[async_trait]
438 impl Sandbox for Scripted {
439 async fn run(
440 &self,
441 _command: &Command,
442 _grant: &GrantSet,
443 ) -> Result<Outcome, SandboxError> {
444 *self.runs.lock().unwrap() += 1;
445 let mut script = self.script.lock().unwrap();
446 if script.len() == 1 {
447 script[0].clone()
450 } else {
451 script.remove(0)
452 }
453 }
454 }
455
456 fn outcome(exit: Option<i32>, output: &str) -> Outcome {
457 Outcome {
458 exit,
459 stdout: output.to_string(),
460 stderr: String::new(),
461 timed_out: false,
462 truncated: false,
463 duration: Duration::from_millis(10),
464 }
465 }
466
467 fn passed() -> Result<Outcome, SandboxError> {
468 Ok(outcome(Some(0), "test result: ok. 42 passed; 0 failed"))
469 }
470
471 fn failed() -> Result<Outcome, SandboxError> {
472 Ok(outcome(
473 Some(101),
474 "test parse::header ... FAILED\ntest result: FAILED. 41 passed; 1 failed",
475 ))
476 }
477
478 fn timed_out() -> Result<Outcome, SandboxError> {
479 Ok(Outcome {
480 exit: None,
481 stdout: String::new(),
482 stderr: String::new(),
483 timed_out: true,
484 truncated: false,
485 duration: Duration::from_secs(300),
486 })
487 }
488
489 fn config() -> TestConfig {
490 TestConfig::cargo("/work/repo")
491 }
492
493 fn order() -> WorkOrder {
494 WorkOrder::new(
495 Intent::new("patch.apply", &"fix the header parser").unwrap(),
496 vec![],
497 GrantSet::EMPTY,
498 ProposalOrigin::new("claude-opus-5", [], Digest::of(b"prompt")),
499 )
500 }
501
502 fn condensate() -> Condensate {
503 Condensate::new(
504 SchemaId::new("boundgraph.diff.v1", 1),
505 CondensatePayload::inline(&"one file changed").unwrap(),
506 Digest::of(b"the patch"),
507 400,
508 false,
509 SlicerId::new("diff", 1),
510 )
511 }
512
513 fn key() -> LocalKey {
514 LocalKey::from_seed("host-key-1", [7u8; 32])
515 }
516
517 async fn verdict(config: TestConfig, script: Vec<Result<Outcome, SandboxError>>) -> Verdict {
518 SandboxedTests::new(Scripted::of(script), config)
519 .check(&order(), &condensate(), &key())
520 .await
521 .unwrap()
522 .verdict
523 }
524
525 #[tokio::test]
529 async fn a_green_suite_passes() {
530 assert_eq!(verdict(config(), vec![passed()]).await, Verdict::Pass);
531 }
532
533 #[tokio::test]
534 async fn a_failing_suite_fails() {
535 let v = verdict(config(), vec![failed()]).await;
536 assert!(matches!(v, Verdict::Fail(_)), "{v:?}");
537 }
538
539 #[tokio::test]
542 async fn the_failure_reason_names_the_failing_tests() {
543 let v = verdict(config(), vec![failed()]).await;
544 let reason = v.reason().unwrap();
545
546 assert!(reason.contains("parse::header"), "{reason}");
547 assert!(reason.contains("1 test failed"), "{reason}");
548 assert!(reason.contains("41 passed"), "the output too: {reason}");
549 }
550
551 #[tokio::test]
553 async fn a_timeout_is_inconclusive() {
554 let v = verdict(config(), vec![timed_out()]).await;
555
556 assert!(matches!(v, Verdict::Inconclusive(_)), "{v:?}");
557 assert!(!v.is_pass());
558 }
559
560 #[tokio::test]
562 async fn a_timeout_is_not_retried() {
563 let sandbox = Scripted::of(vec![timed_out()]);
564 let checker = SandboxedTests::new(sandbox, config().retrying(3));
565 checker
566 .check(&order(), &condensate(), &key())
567 .await
568 .unwrap();
569
570 assert_eq!(checker.sandbox.runs(), 1);
571 }
572
573 #[tokio::test]
574 async fn a_sandbox_that_refuses_leaves_the_verdict_undecided() {
575 let refused = Err(SandboxError::Unenforceable {
576 atom: "fs:write:/work/repo/**".into(),
577 command: "cargo test".into(),
578 });
579 let v = verdict(config(), vec![refused]).await;
580
581 assert!(matches!(v, Verdict::Inconclusive(_)), "{v:?}");
582 }
583
584 #[tokio::test]
585 async fn a_failure_with_no_output_still_says_something() {
586 let v = verdict(config(), vec![Ok(outcome(Some(1), ""))]).await;
587 let reason = v.reason().unwrap();
588
589 assert!(reason.contains("no output"), "{reason}");
590 }
591
592 #[tokio::test]
597 async fn a_failing_suite_is_retried_as_configured() {
598 let sandbox = Scripted::of(vec![failed()]);
599 let checker = SandboxedTests::new(sandbox, config().retrying(2));
600 checker
601 .check(&order(), &condensate(), &key())
602 .await
603 .unwrap();
604
605 assert_eq!(checker.sandbox.runs(), 3, "one attempt plus two retries");
606 }
607
608 #[tokio::test]
609 async fn a_green_suite_is_not_retried() {
610 let sandbox = Scripted::of(vec![passed()]);
611 let checker = SandboxedTests::new(sandbox, config().retrying(5));
612 checker
613 .check(&order(), &condensate(), &key())
614 .await
615 .unwrap();
616
617 assert_eq!(checker.sandbox.runs(), 1);
618 }
619
620 #[tokio::test]
621 async fn every_attempt_is_named_when_they_all_failed() {
622 let v = verdict(config().retrying(2), vec![failed()]).await;
623 let reason = v.reason().unwrap();
624
625 assert!(reason.contains("all 3 attempts"), "{reason}");
626 }
627
628 #[tokio::test]
631 async fn attempts_that_disagree_are_flaky_rather_than_a_pass() {
632 let v = verdict(config().retrying(1), vec![failed(), passed()]).await;
633
634 assert!(matches!(v, Verdict::Inconclusive(_)), "{v:?}");
635 assert!(!v.is_pass(), "a flaky suite must not commit");
636
637 let reason = v.reason().unwrap();
638 assert!(reason.contains("flaky"), "{reason}");
639 assert!(reason.contains("attempt 1: failed"), "{reason}");
640 assert!(reason.contains("attempt 2: passed"), "{reason}");
641 }
642
643 #[tokio::test]
644 async fn with_no_retries_a_disagreement_cannot_arise() {
645 assert_eq!(config().retries, 0);
646 assert_eq!(config().attempts(), 1);
647
648 let v = verdict(config(), vec![failed(), passed()]).await;
649 assert!(matches!(v, Verdict::Fail(_)), "{v:?}");
650 }
651
652 #[tokio::test]
654 async fn the_attempt_log_is_covered_by_the_evidence() {
655 let once = SandboxedTests::new(Scripted::of(vec![failed()]), config())
656 .check(&order(), &condensate(), &key())
657 .await
658 .unwrap();
659 let thrice = SandboxedTests::new(Scripted::of(vec![failed()]), config().retrying(2))
660 .check(&order(), &condensate(), &key())
661 .await
662 .unwrap();
663
664 assert_ne!(
665 once.inputs, thrice.inputs,
666 "one attempt and three are different evidence"
667 );
668 }
669
670 #[test]
673 fn cargo_test_failures_are_recognised() {
674 let output = "\
675running 3 tests
676test parse::header ... ok
677test parse::trailer ... FAILED
678test emit::escape ... FAILED
679
680failures:
681 parse::trailer
682 emit::escape
683
684test result: FAILED. 1 passed; 2 failed";
685
686 assert_eq!(
687 failing_tests(output),
688 vec!["parse::trailer", "emit::escape"],
689 "and the `failures:` block must not duplicate them"
690 );
691 }
692
693 #[test]
694 fn pytest_failures_are_recognised() {
695 let output = "\
696FAILED tests/test_parse.py::test_header - AssertionError: expected 3
697FAILED tests/test_parse.py::test_trailer - IndexError
6982 failed, 41 passed in 1.20s";
699
700 assert_eq!(
701 failing_tests(output),
702 vec![
703 "tests/test_parse.py::test_header",
704 "tests/test_parse.py::test_trailer"
705 ]
706 );
707 }
708
709 #[test]
710 fn go_test_failures_are_recognised() {
711 let output = "--- FAIL: TestParseHeader (0.00s)\n parse_test.go:12: got 2, want 3\nFAIL";
712 assert_eq!(failing_tests(output), vec!["TestParseHeader"]);
713 }
714
715 #[test]
716 fn jest_family_failures_are_recognised() {
717 let output = " ✕ parses a header (3 ms)\n ● parses a trailer\n ✓ escapes";
718 assert_eq!(
719 failing_tests(output),
720 vec!["parses a header", "parses a trailer"]
721 );
722 }
723
724 #[test]
725 fn a_passing_run_yields_no_names() {
726 assert!(failing_tests("test parse::header ... ok\ntest result: ok.").is_empty());
727 }
728
729 #[test]
732 fn an_unrecognised_runner_yields_no_names_rather_than_a_guess() {
733 assert!(failing_tests("Suite finished with problems in 3 places").is_empty());
734 }
735
736 #[test]
737 fn a_long_list_of_failures_is_counted_rather_than_listed() {
738 let names: Vec<String> = (0..50).map(|n| format!("test_{n}")).collect();
739 let rendered = summarise(&names);
740
741 assert!(rendered.contains("50 tests failed"));
742 assert!(rendered.contains("and 30 more"), "{rendered}");
743 assert!(rendered.contains("test_0"));
744 assert!(!rendered.contains("test_49"));
745 }
746
747 #[test]
748 fn one_failure_is_singular() {
749 assert!(summarise(&["only_one".to_string()]).starts_with("1 test failed"));
750 }
751
752 #[tokio::test]
757 async fn a_huge_test_log_is_cut_from_the_back_where_the_summary_is() {
758 let noise = format!(
759 "{}test result: FAILED. 4000 passed; 1 failed",
760 "test filler ... ok\n".repeat(5_000)
761 );
762 let v = verdict(config(), vec![Ok(outcome(Some(101), &noise))]).await;
763 let reason = v.reason().unwrap();
764
765 assert!(
766 reason.contains("test result: FAILED"),
767 "the summary survives"
768 );
769 assert!(reason.contains("truncated"));
770 }
771
772 #[test]
773 fn tail_keeps_the_end_and_says_when_it_cut() {
774 let text = "first\nmiddle\nlast";
775 assert!(tail(text, 5).ends_with("\nlast"));
776 assert!(tail(text, 5).contains("truncated"));
777 assert_eq!(tail(text, 100), text);
778 }
779
780 #[test]
781 fn truncating_does_not_cut_a_character_in_half() {
782 let text = "é".repeat(100);
783 assert!(!tail(&text, 10).contains('\u{fffd}'));
784 }
785
786 #[tokio::test]
790 async fn the_grant_is_read_and_execute_only() {
791 let grant = config().grant();
792
793 assert!(grant.iter().all(|a| a.action != "write"), "{grant:?}");
794 assert!(grant.iter().all(|a| a.action != "delete"), "{grant:?}");
795 assert!(grant.iter().all(|a| a.domain != "net"), "{grant:?}");
796 assert!(
797 grant
798 .iter()
799 .all(|a| a.action == "read" || a.action == "list" || a.action == "exec")
800 );
801 }
802
803 #[tokio::test]
804 async fn the_grant_is_within_what_a_checker_may_hold() {
805 use boundgraph_grant::{Tri, set_leq};
806 assert_eq!(
807 set_leq(&config().grant(), &crate::checker_ceiling()),
808 Tri::Yes
809 );
810 }
811
812 #[tokio::test]
815 async fn the_same_run_always_gives_the_same_evidence() {
816 let (order, k, key) = (order(), condensate(), key());
817
818 let once = SandboxedTests::new(Scripted::of(vec![failed()]), config())
819 .check(&order, &k, &key)
820 .await
821 .unwrap();
822
823 for _ in 0..4 {
824 let again = SandboxedTests::new(Scripted::of(vec![failed()]), config())
825 .check(&order, &k, &key)
826 .await
827 .unwrap();
828 assert_eq!(again, once);
829 }
830 }
831}