Skip to main content

boundgraph_check/
testrun.rs

1//! "I fixed the bug", made checkable.
2//!
3//! This is the canonical `repair` verifier: try, run the tests, retry on
4//! failure, bounded by `k`. Without it a self-correction loop has nothing to
5//! correct against, and the model is grading its own work.
6//!
7//! # Retrying does not launder a failure into a pass
8//!
9//! Every other test runner treats a retry as a second chance: run it again, and
10//! if it passes this time, it passed. That is the right default for CI, where
11//! the cost of a false failure is a re-run. It is the wrong default here,
12//! because this verdict gates a **commit**, and a suite that failed once and
13//! passed once has not shown the change to be correct. It has shown the suite
14//! to be unreliable, which is a different fact and a more important one.
15//!
16//! So retries exist to *detect* flakiness, not to survive it:
17//!
18//! | What happened | Verdict |
19//! |---|---|
20//! | the first run passed | `Pass` |
21//! | every attempt failed | `Fail`, with the failing test names |
22//! | attempts disagreed | `Inconclusive` — the suite is flaky |
23//! | any attempt timed out | `Inconclusive` |
24//! | the sandbox refused, or the runner would not start | `Inconclusive` |
25//!
26//! With the default of no retries the third row cannot happen and the checker
27//! behaves exactly as expected. Turning retries on buys the ability to tell
28//! "broken" from "flaky", and pays for it by refusing to commit on the latter.
29//! That is the trade the whole project makes everywhere else: what cannot be
30//! proved is not assumed.
31//!
32//! # Failing test names, then the tail of the output
33//!
34//! A repair loop needs to know *which* tests failed before it needs to know
35//! what they printed, so the names are extracted first and lead the reason.
36//! [`failing_tests`] recognises the formats `cargo test`, `pytest`, `go test`,
37//! and the `jest`-family runners emit.
38//!
39//! What follows is the **tail** of the output, not the head — the opposite of
40//! the compile checker. A test runner puts its summary at the end; a compiler
41//! puts its cause at the start. Getting this backwards produces a reason full
42//! of passing tests with the failures cut off.
43
44use 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
53/// How much of a test runner's output reaches the failure reason.
54pub const MAX_REASON: usize = 4_000;
55
56/// How many failing test names are listed before the rest are counted.
57///
58/// A change that broke two hundred tests broke one thing; listing all two
59/// hundred spends a context window restating it.
60pub const MAX_NAMES: usize = 20;
61
62/// A test command to run.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct TestConfig {
65    /// The program to run.
66    pub program: String,
67    /// Its arguments.
68    pub args: Vec<String>,
69    /// The directory to run in.
70    pub workspace: PathBuf,
71    /// How long a single attempt may run before it is killed.
72    pub timeout: Duration,
73    /// How many further attempts to make after a failing one.
74    ///
75    /// Zero by default. See the module documentation: these detect flakiness,
76    /// they do not excuse it.
77    pub retries: u8,
78}
79
80impl TestConfig {
81    /// A test command, with the default timeout and no retries.
82    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    /// `cargo test`.
97    pub fn cargo(workspace: impl Into<PathBuf>) -> Self {
98        Self::new("cargo", ["test", "--all-targets"], workspace)
99    }
100
101    /// `pytest -q`.
102    pub fn pytest(workspace: impl Into<PathBuf>) -> Self {
103        Self::new("pytest", ["-q"], workspace)
104    }
105
106    /// `go test ./...`.
107    pub fn go(workspace: impl Into<PathBuf>) -> Self {
108        Self::new("go", ["test", "./..."], workspace)
109    }
110
111    /// Kill a single attempt after `timeout`.
112    #[must_use]
113    pub fn within(mut self, timeout: Duration) -> Self {
114        self.timeout = timeout;
115        self
116    }
117
118    /// Re-run a failing suite up to `retries` more times, to find out whether
119    /// it is broken or merely unreliable.
120    #[must_use]
121    pub fn retrying(mut self, retries: u8) -> Self {
122        self.retries = retries;
123        self
124    }
125
126    /// The grant this needs: read the workspace and execute the runner.
127    ///
128    /// No write, anywhere. See the note in US-0805 on what that means for a
129    /// runner that wants a build directory.
130    pub fn grant(&self) -> GrantSet {
131        workspace_grant(&self.workspace, &self.program)
132    }
133
134    /// How many attempts will be made at most.
135    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
144/// Keep the tail of `text`, which is where a test runner's summary is.
145///
146/// Counting characters rather than bytes, so a multi-byte character is never
147/// cut in half.
148pub 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
157/// Markers that introduce a failing test name, and how much of the line to keep
158/// after them.
159///
160/// Ordered longest-first where two could match one line, so the more specific
161/// reading wins.
162const MARKERS: &[&str] = &[
163    // `go test`: `--- FAIL: TestParseHeader (0.00s)`
164    "--- FAIL: ",
165    // `pytest -q`: `FAILED tests/test_parse.py::test_header - AssertionError`
166    "FAILED ",
167    // `jest`, `vitest`: `● parses a header` / `✕ parses a header (3 ms)`
168    "● ",
169    "✕ ",
170    "✗ ",
171];
172
173/// Extract the names of failing tests from a runner's output.
174///
175/// Recognises the shapes `cargo test`, `pytest`, `go test`, and the `jest`
176/// family emit. Deterministic, in the order encountered, without duplicates.
177///
178/// Best-effort by design: an unrecognised runner yields no names and the
179/// failure reason falls back to the tail of the output, which is still useful.
180/// Guessing wrongly would be worse — a repair loop sent after a test that did
181/// not fail wastes an attempt and an inference call.
182pub 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        // `cargo test`: `test parse::header ... FAILED`
195        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                // `pytest` appends ` - AssertionError`, `go test` appends a
205                // duration; neither is part of the name.
206                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
222/// Render a list of failing tests, counting the rest once it is long.
223fn 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/// Runs a project's tests and reports whether they passed.
247#[derive(Debug)]
248pub struct SandboxedTests<S: Sandbox> {
249    config: TestConfig,
250    sandbox: S,
251    id: CheckerId,
252}
253
254impl<S: Sandbox> SandboxedTests<S> {
255    /// A test-run checker over `sandbox`.
256    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    /// Name it something more specific, so a ledger says which suite this was.
265    #[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    /// The workspace whose tests are run.
272    pub fn workspace(&self) -> &Path {
273        &self.config.workspace
274    }
275}
276
277/// What one attempt did, in a form that is stable across runs.
278///
279/// No durations and no timestamps: the attempt log is digested into the
280/// evidence, so anything that varied between two identical runs would make a
281/// replay fail to compare for reasons that say nothing about the tests.
282#[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                // The tests did not run. That is not the code's fault.
326                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                // Not retried. A suite that outran its deadline will do it
334                // again, and spending the budget to confirm that helps nobody.
335                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            // The suite is unreliable, which is not the same as the change
362            // being correct, and only one of those permits a commit.
363            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
402/// Content-address the attempt log, so how many attempts were made and what
403/// each did is bound by the signature rather than merely mentioned.
404fn 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    /// A sandbox that returns a scripted outcome per attempt.
418    #[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                // The last entry repeats, so a test need not spell out every
448                // attempt of a suite that keeps failing.
449                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    // ── the verdict mapping ─────────────────────────────────────────────────
526
527    /// The criterion: exit status maps to a verdict.
528    #[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    /// The criterion: failing test names and output are condensed into the
540    /// failure reason, so the repair loop has something to act on.
541    #[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    /// The criterion: a timeout is `Inconclusive`.
552    #[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    /// A suite that outran its deadline will do it again.
561    #[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    // ── retries ─────────────────────────────────────────────────────────────
593
594    /// The criterion: the retry count is configurable, and the retries are
595    /// recorded.
596    #[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    /// The design decision worth its own test: a suite that failed then passed
629    /// has shown itself unreliable, not shown the change correct.
630    #[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    /// The retries are bound by the signature, not merely mentioned in text.
653    #[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    // ── extracting names ────────────────────────────────────────────────────
671
672    #[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    /// Guessing wrongly would send a repair loop after a test that did not
730    /// fail, so an unrecognised runner yields nothing rather than a guess.
731    #[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    // ── output handling ─────────────────────────────────────────────────────
753
754    /// The opposite of the compile checker, and deliberately so: a runner's
755    /// summary is at the end.
756    #[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    // ── authority ───────────────────────────────────────────────────────────
787
788    /// The criterion: the checker cannot modify the workspace.
789    #[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    // ── determinism ─────────────────────────────────────────────────────────
813
814    #[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}