1use std::collections::{BTreeMap, BTreeSet};
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
13#[serde(rename_all = "snake_case")]
14pub enum CheckStatus {
15 Passed,
17 Failed,
19 TimedOut,
21 Skipped,
23 Error,
25}
26
27impl CheckStatus {
28 #[must_use]
30 pub const fn is_failure(self) -> bool {
31 matches!(self, Self::Failed | Self::TimedOut | Self::Error)
32 }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
37#[serde(rename_all = "snake_case")]
38pub enum TestOutcome {
39 Passed,
41 Failed,
43 Skipped,
45 Error,
48}
49
50impl TestOutcome {
51 #[must_use]
53 pub const fn is_failing(self) -> bool {
54 matches!(self, Self::Failed | Self::Error)
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
60pub struct TestResult {
61 pub id: String,
63 pub outcome: TestOutcome,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
69pub struct CheckResult {
70 pub name: String,
72 pub argv: Vec<String>,
74 pub status: CheckStatus,
76 pub exit_code: Option<i32>,
78 pub duration_ms: u64,
80 pub junit: bool,
82 pub tests: Option<Vec<TestResult>>,
84 pub stdout_tail: String,
86 pub stderr_tail: String,
88 pub truncated: bool,
90 pub note: Option<String>,
92 #[serde(default)]
95 pub flaky: bool,
96}
97
98impl CheckResult {
99 #[must_use]
101 pub fn test_counts(&self) -> Option<(usize, usize, usize)> {
102 self.tests.as_ref().map(|tests| {
103 let n = |o| tests.iter().filter(|t| t.outcome == o).count();
104 (
105 n(TestOutcome::Passed),
106 n(TestOutcome::Failed) + n(TestOutcome::Error),
107 n(TestOutcome::Skipped),
108 )
109 })
110 }
111
112 #[must_use]
116 pub fn is_evidence(&self) -> bool {
117 !self.flaky
118 && self.status == CheckStatus::Passed
119 && (!self.junit
120 || self
121 .tests
122 .as_ref()
123 .is_some_and(|t| t.iter().any(|t| t.outcome == TestOutcome::Passed)))
124 }
125
126 fn failing_tests(&self) -> BTreeSet<&str> {
127 self.tests
128 .iter()
129 .flatten()
130 .filter(|t| t.outcome.is_failing())
131 .map(|t| t.id.as_str())
132 .collect()
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
138pub struct VerifyReport {
139 pub root: String,
141 pub tier: String,
143 pub checks: Vec<CheckResult>,
145 pub comparison: Option<Comparison>,
147 pub passed: bool,
149}
150
151#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
153pub struct Comparison {
154 pub regressions: Vec<Regression>,
156 pub pre_existing: Vec<String>,
158 pub new_tests: Vec<String>,
160 pub count_before: usize,
162 pub count_after: usize,
164 pub failing_as_before: Vec<String>,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
170pub struct Regression {
171 pub id: String,
173 pub now: String,
175}
176
177#[must_use]
179pub fn compare(baseline: &VerifyReport, current: &VerifyReport) -> Comparison {
180 let index = |r: &VerifyReport| -> BTreeMap<String, TestOutcome> {
181 r.checks
182 .iter()
183 .flat_map(|c| {
184 c.tests
185 .iter()
186 .flatten()
187 .map(move |t| (format!("{}/{}", c.name, t.id), t.outcome))
188 })
189 .collect()
190 };
191 let before = index(baseline);
192 let after = index(current);
193
194 let mut cmp = Comparison {
195 count_before: before.len(),
196 count_after: after.len(),
197 ..Comparison::default()
198 };
199 for (id, was) in &before {
200 match (was, after.get(id)) {
201 (TestOutcome::Passed, Some(TestOutcome::Passed)) => {}
202 (TestOutcome::Passed, Some(now)) => cmp.regressions.push(Regression {
203 id: id.clone(),
204 now: match now {
205 TestOutcome::Failed => "failed",
206 TestOutcome::Error => "error",
207 _ => "skipped",
208 }
209 .to_owned(),
210 }),
211 (TestOutcome::Passed, None) => cmp.regressions.push(Regression {
212 id: id.clone(),
213 now: "missing".to_owned(),
214 }),
215 (was, Some(now)) if was.is_failing() && now.is_failing() => {
216 cmp.pre_existing.push(id.clone());
217 }
218 _ => {}
219 }
220 }
221 cmp.new_tests = after
222 .keys()
223 .filter(|id| !before.contains_key(*id))
224 .cloned()
225 .collect();
226
227 for c in current
231 .checks
232 .iter()
233 .filter(|c| c.status == CheckStatus::Failed)
234 {
235 let Some(old) = baseline.checks.iter().find(|b| b.name == c.name) else {
236 continue;
237 };
238 let now = c.failing_tests();
239 if old.status == CheckStatus::Failed
240 && c.tests.is_some()
241 && !now.is_empty()
242 && now.is_subset(&old.failing_tests())
243 {
244 cmp.failing_as_before.push(c.name.clone());
245 }
246 }
247 cmp
248}
249
250fn failures<'r>(checks: &'r [CheckResult], cmp: Option<&Comparison>) -> Vec<&'r CheckResult> {
252 checks
253 .iter()
254 .filter(|c| c.status.is_failure())
255 .filter(|c| !cmp.is_some_and(|cmp| cmp.failing_as_before.contains(&c.name)))
256 .collect()
257}
258
259#[must_use]
261pub fn passes(checks: &[CheckResult], cmp: Option<&Comparison>) -> bool {
262 failures(checks, cmp).is_empty() && cmp.is_none_or(|c| c.regressions.is_empty())
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
272#[serde(rename_all = "snake_case")]
273pub enum RedClass {
274 Assertion,
276 Absent,
278 BuildError,
280 CollectionError,
282 Timeout,
284 NoTests,
286 Flaky,
288 Unclassified,
290 Passed,
292}
293
294impl RedClass {
295 #[must_use]
297 pub const fn is_red(self) -> bool {
298 matches!(self, Self::Assertion | Self::Absent)
299 }
300}
301
302#[derive(
305 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
306)]
307pub struct RunnerSummary {
308 pub passed: usize,
310 pub failed: usize,
312 pub errors: usize,
314 pub build_failed: bool,
316 pub unresolved_only: bool,
318}
319
320#[must_use]
323pub fn classify_red(result: &CheckResult, summary: Option<&RunnerSummary>) -> RedClass {
324 if result.status == CheckStatus::TimedOut {
325 return RedClass::Timeout;
326 }
327 if let Some(tests) = result.tests.as_ref() {
328 if tests.iter().any(|t| t.outcome == TestOutcome::Failed) {
329 return RedClass::Assertion;
330 }
331 if tests.iter().any(|t| t.outcome == TestOutcome::Error) {
332 return RedClass::CollectionError;
333 }
334 if tests.is_empty() {
335 return RedClass::NoTests;
336 }
337 if result.status == CheckStatus::Passed {
338 return RedClass::Passed;
339 }
340 }
341 if let Some(s) = summary {
342 if s.build_failed {
343 return if s.unresolved_only {
344 RedClass::Absent
345 } else {
346 RedClass::BuildError
347 };
348 }
349 if s.failed > 0 {
350 return RedClass::Assertion;
351 }
352 if s.errors > 0 {
353 return RedClass::CollectionError;
354 }
355 if s.passed == 0 {
356 return RedClass::NoTests;
357 }
358 }
359 match result.status {
360 CheckStatus::Passed => RedClass::Passed,
361 CheckStatus::Error => RedClass::BuildError,
362 _ => RedClass::Unclassified,
363 }
364}
365
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
368#[serde(rename_all = "snake_case", tag = "verdict")]
369pub enum Verdict {
370 Verified,
372 RegressionOnly,
374 Failed {
376 reasons: Vec<String>,
378 },
379}
380
381#[must_use]
383pub fn verdict(task_checks: &[&str], checks: &[CheckResult], cmp: Option<&Comparison>) -> Verdict {
384 let mut reasons: Vec<String> = failures(checks, cmp)
385 .iter()
386 .map(|c| format!("check `{}` {:?}", c.name, c.status).to_lowercase())
387 .collect();
388 if let Some(cmp) = cmp {
389 reasons.extend(
390 cmp.regressions
391 .iter()
392 .map(|r| format!("regression: {} is {}", r.id, r.now)),
393 );
394 }
395 if !reasons.is_empty() {
396 return Verdict::Failed { reasons };
397 }
398 let evidence = checks
399 .iter()
400 .any(|c| task_checks.contains(&c.name.as_str()) && c.is_evidence());
401 if evidence {
402 Verdict::Verified
403 } else {
404 Verdict::RegressionOnly
405 }
406}
407
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
411#[serde(rename_all = "snake_case", tag = "outcome")]
412pub enum HeldOut {
413 Off,
415 Absent {
417 reason: String,
419 },
420 Passed {
422 files: usize,
424 },
425 Failed {
427 scenarios: Vec<String>,
429 },
430}
431
432#[must_use]
435pub fn with_held_out(verdict: Verdict, held_out: &HeldOut) -> Verdict {
436 let HeldOut::Failed { scenarios } = held_out else {
437 return verdict;
438 };
439 let mut ids = scenarios.clone();
440 ids.sort();
441 ids.dedup();
442 let reason = format!("held-out acceptance failed: {}", ids.join(", "));
443 match verdict {
444 Verdict::Failed { mut reasons } => {
445 reasons.push(reason);
446 Verdict::Failed { reasons }
447 }
448 Verdict::Verified | Verdict::RegressionOnly => Verdict::Failed {
449 reasons: vec![reason],
450 },
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 fn check(
459 name: &str,
460 status: CheckStatus,
461 tests: Option<&[(&str, TestOutcome)]>,
462 ) -> CheckResult {
463 CheckResult {
464 name: name.to_owned(),
465 argv: vec!["x".to_owned()],
466 status,
467 exit_code: Some(i32::from(status != CheckStatus::Passed)),
468 duration_ms: 1,
469 junit: tests.is_some(),
470 tests: tests.map(|t| {
471 t.iter()
472 .map(|(id, o)| TestResult {
473 id: (*id).to_owned(),
474 outcome: *o,
475 })
476 .collect()
477 }),
478 stdout_tail: String::new(),
479 stderr_tail: String::new(),
480 truncated: false,
481 note: None,
482 flaky: false,
483 }
484 }
485
486 fn report(checks: Vec<CheckResult>) -> VerifyReport {
487 VerifyReport {
488 root: "/p".to_owned(),
489 tier: "fast".to_owned(),
490 checks,
491 comparison: None,
492 passed: true,
493 }
494 }
495
496 use CheckStatus::{Failed, Passed};
497 use TestOutcome as T;
498
499 #[test]
500 fn comparison_finds_regressions_pre_existing_and_new() {
501 let base = report(vec![check(
502 "test",
503 Failed,
504 Some(&[("a", T::Passed), ("b", T::Passed), ("c", T::Failed)]),
505 )]);
506 let now = report(vec![check(
507 "test",
508 Failed,
509 Some(&[
510 ("a", T::Passed),
511 ("b", T::Failed),
512 ("c", T::Failed),
513 ("d", T::Passed),
514 ]),
515 )]);
516 let cmp = compare(&base, &now);
517 assert_eq!(
518 cmp.regressions,
519 [Regression {
520 id: "test/b".to_owned(),
521 now: "failed".to_owned()
522 }]
523 );
524 assert_eq!(cmp.pre_existing, ["test/c"]);
525 assert_eq!(cmp.new_tests, ["test/d"]);
526 assert_eq!((cmp.count_before, cmp.count_after), (3, 4));
527 assert!(cmp.failing_as_before.is_empty(), "b is a new failure");
528 }
529
530 #[test]
531 fn missing_test_is_a_regression() {
532 let base = report(vec![check(
533 "test",
534 Passed,
535 Some(&[("a", T::Passed), ("b", T::Passed)]),
536 )]);
537 let now = report(vec![check("test", Passed, Some(&[("a", T::Passed)]))]);
538 let cmp = compare(&base, &now);
539 assert_eq!(cmp.regressions[0].now, "missing");
540 assert!(!passes(&now.checks, Some(&cmp)));
541 }
542
543 #[test]
544 fn pre_existing_failures_alone_do_not_fail_against_a_baseline() {
545 let base = report(vec![check(
546 "test",
547 Failed,
548 Some(&[("a", T::Passed), ("c", T::Failed)]),
549 )]);
550 let now = report(vec![check(
551 "test",
552 Failed,
553 Some(&[("a", T::Passed), ("c", T::Failed)]),
554 )]);
555 let cmp = compare(&base, &now);
556 assert_eq!(cmp.failing_as_before, ["test"]);
557 assert!(passes(&now.checks, Some(&cmp)));
558 assert!(
559 !passes(&now.checks, None),
560 "without a baseline a failing check fails"
561 );
562 }
563
564 #[test]
565 fn verdict_levels() {
566 let passing = check("unit", Passed, Some(&[("a", T::Passed)]));
567 let empty = check("unit", Passed, Some(&[]));
568 let lint = check("lint", Passed, None);
569 assert_eq!(
570 verdict(&["unit"], &[passing.clone(), lint.clone()], None),
571 Verdict::Verified
572 );
573 assert_eq!(
574 verdict(&[], &[passing.clone(), lint.clone()], None),
575 Verdict::RegressionOnly
576 );
577 assert_eq!(
578 verdict(&["unit"], &[empty], None),
579 Verdict::RegressionOnly,
580 "zero tests is no evidence"
581 );
582 assert_eq!(
583 verdict(&["lint"], &[lint], None),
584 Verdict::Verified,
585 "a passing command check is evidence"
586 );
587 let failing = check("lint", Failed, None);
588 assert!(matches!(
589 verdict(&["unit"], &[passing, failing], None),
590 Verdict::Failed { .. }
591 ));
592 }
593
594 fn summary(failed: usize, build_failed: bool, unresolved_only: bool) -> RunnerSummary {
595 RunnerSummary {
596 passed: 0,
597 failed,
598 errors: 0,
599 build_failed,
600 unresolved_only,
601 }
602 }
603
604 #[test]
605 fn red_is_an_assertion_or_an_absent_symbol() {
606 use TestOutcome as T;
607 let failed = check("a", CheckStatus::Failed, Some(&[("t", T::Failed)]));
608 assert_eq!(classify_red(&failed, None), RedClass::Assertion);
609 let errored = check("a", CheckStatus::Failed, Some(&[("t", T::Error)]));
610 assert_eq!(classify_red(&errored, None), RedClass::CollectionError);
611 let empty = check("a", CheckStatus::Failed, Some(&[]));
612 assert_eq!(classify_red(&empty, None), RedClass::NoTests);
613 let plain = check("a", CheckStatus::Failed, None);
614 assert_eq!(classify_red(&plain, None), RedClass::Unclassified);
615 assert_eq!(
616 classify_red(&plain, Some(&summary(2, false, false))),
617 RedClass::Assertion
618 );
619 assert_eq!(
620 classify_red(&plain, Some(&summary(0, true, false))),
621 RedClass::BuildError
622 );
623 assert_eq!(
624 classify_red(&plain, Some(&summary(0, true, true))),
625 RedClass::Absent
626 );
627 assert_eq!(
628 classify_red(&plain, Some(&summary(0, false, false))),
629 RedClass::NoTests
630 );
631 let timed_out = check("a", CheckStatus::TimedOut, None);
632 assert_eq!(
633 classify_red(&timed_out, Some(&summary(1, false, false))),
634 RedClass::Timeout
635 );
636 let passed = check("a", CheckStatus::Passed, Some(&[("t", T::Passed)]));
637 assert_eq!(classify_red(&passed, None), RedClass::Passed);
638 assert!(RedClass::Assertion.is_red() && RedClass::Absent.is_red());
639 assert!(!RedClass::BuildError.is_red() && !RedClass::Flaky.is_red());
640 }
641
642 #[test]
643 fn a_flaky_pass_is_not_evidence_and_errors_regress() {
644 use TestOutcome as T;
645 let mut c = check("acceptance", CheckStatus::Passed, None);
646 assert!(c.is_evidence());
647 c.flaky = true;
648 assert!(!c.is_evidence());
649 assert_eq!(
650 verdict(&["acceptance"], &[c], None),
651 Verdict::RegressionOnly
652 );
653
654 let before = report(vec![check(
655 "t",
656 CheckStatus::Passed,
657 Some(&[("x", T::Passed)]),
658 )]);
659 let after = report(vec![check(
660 "t",
661 CheckStatus::Failed,
662 Some(&[("x", T::Error)]),
663 )]);
664 let cmp = compare(&before, &after);
665 assert_eq!(cmp.regressions[0].now, "error");
666 let again = compare(&after, &after);
667 assert_eq!(again.pre_existing, ["t/x"]);
668 }
669
670 mod properties {
671 use super::*;
672 use proptest::prelude::*;
673
674 fn statuses() -> impl Strategy<Value = CheckStatus> {
675 prop_oneof![
676 Just(CheckStatus::Passed),
677 Just(CheckStatus::Failed),
678 Just(CheckStatus::TimedOut),
679 Just(CheckStatus::Skipped),
680 Just(CheckStatus::Error),
681 ]
682 }
683
684 fn outcomes() -> impl Strategy<Value = TestOutcome> {
685 prop_oneof![
686 Just(T::Passed),
687 Just(T::Failed),
688 Just(T::Skipped),
689 Just(T::Error)
690 ]
691 }
692
693 fn checks() -> impl Strategy<Value = Vec<CheckResult>> {
694 prop::collection::vec(
695 (
696 statuses(),
697 prop::option::of(prop::collection::vec(outcomes(), 0..5)),
698 any::<bool>(),
699 ),
700 1..5,
701 )
702 .prop_map(|v| {
703 v.into_iter()
704 .enumerate()
705 .map(|(i, (status, tests, flaky))| CheckResult {
706 name: format!("c{i}"),
707 argv: vec!["x".to_owned()],
708 status,
709 exit_code: None,
710 duration_ms: 0,
711 junit: tests.is_some(),
712 tests: tests.map(|t| {
713 t.into_iter()
714 .enumerate()
715 .map(|(j, outcome)| TestResult {
716 id: format!("t{j}"),
717 outcome,
718 })
719 .collect()
720 }),
721 stdout_tail: String::new(),
722 stderr_tail: String::new(),
723 truncated: false,
724 note: None,
725 flaky,
726 })
727 .collect()
728 })
729 }
730
731 fn held_outs() -> impl Strategy<Value = HeldOut> {
732 prop_oneof![
733 Just(HeldOut::Off),
734 Just(HeldOut::Absent {
735 reason: "not_red".to_owned()
736 }),
737 (0..4usize).prop_map(|files| HeldOut::Passed { files }),
738 proptest::collection::vec("US[1-3]-[1-4]", 1..4)
739 .prop_map(|scenarios| HeldOut::Failed { scenarios }),
740 ]
741 }
742
743 proptest! {
744 #[test]
745 fn a_held_out_failure_is_never_a_pass(cs in checks(), pick in 0..5usize, h in held_outs()) {
746 let name = format!("c{pick}");
747 let before = verdict(&[name.as_str()], &cs, None);
748 let after = with_held_out(before.clone(), &h);
749 match h {
750 HeldOut::Failed { .. } => {
751 let failed = matches!(after, Verdict::Failed { .. });
752 prop_assert!(failed);
753 }
754 _ => prop_assert_eq!(after, before),
755 }
756 }
757
758 #[test]
759 fn any_failing_check_means_failed(cs in checks(), pick in 0..5usize) {
760 let task = [format!("c{pick}")];
761 let task: Vec<&str> = task.iter().map(String::as_str).collect();
762 let v = verdict(&task, &cs, None);
763 let any_failure = cs.iter().any(|c| c.status.is_failure());
764 prop_assert_eq!(matches!(v, Verdict::Failed { .. }), any_failure);
765 }
766
767 #[test]
768 fn verified_requires_task_evidence(cs in checks(), pick in 0..5usize) {
769 let name = format!("c{pick}");
770 let v = verdict(&[name.as_str()], &cs, None);
771 if v == Verdict::Verified {
772 let c = cs.iter().find(|c| c.name == name);
773 prop_assert!(c.is_some_and(CheckResult::is_evidence));
774 }
775 }
776
777 #[test]
778 fn a_flaky_task_check_is_never_verified(cs in checks(), pick in 0..5usize) {
779 let name = format!("c{pick}");
780 let v = verdict(&[name.as_str()], &cs, None);
781 if cs.iter().find(|c| c.name == name).is_some_and(|c| c.flaky) {
782 prop_assert_ne!(v, Verdict::Verified);
783 }
784 }
785
786 #[test]
787 fn red_always_has_a_cause(
788 cs in checks(),
789 passed in 0..3usize,
790 failed in 0..3usize,
791 errors in 0..3usize,
792 build_failed in any::<bool>(),
793 unresolved_only in any::<bool>(),
794 with_summary in any::<bool>(),
795 ) {
796 let summary = RunnerSummary { passed, failed, errors, build_failed, unresolved_only };
797 let summary = with_summary.then_some(summary);
798 for c in &cs {
799 let class = classify_red(c, summary.as_ref());
800 if class.is_red() {
801 let assertion_in_report = c
802 .tests
803 .iter()
804 .flatten()
805 .any(|t| t.outcome == T::Failed);
806 let by_summary = summary.is_some_and(|s| s.failed > 0 || (s.build_failed && s.unresolved_only));
807 prop_assert!(assertion_in_report || by_summary);
808 prop_assert_ne!(c.status, CheckStatus::TimedOut);
809 }
810 }
811 }
812
813 #[test]
814 fn a_run_compared_with_itself_has_no_regressions(cs in checks()) {
815 let r = report(cs);
816 let cmp = compare(&r, &r);
817 prop_assert!(cmp.regressions.is_empty());
818 prop_assert!(cmp.new_tests.is_empty());
819 prop_assert_eq!(cmp.count_before, cmp.count_after);
820 }
821
822 #[test]
823 fn regressions_always_fail_the_verdict(cs in checks()) {
824 let before = report(cs.clone());
825 let mut after = report(cs);
826 for c in &mut after.checks {
827 if let Some(tests) = &mut c.tests {
828 tests.clear();
829 }
830 }
831 let cmp = compare(&before, &after);
832 let had_passing = cmp.count_before > 0
833 && before.checks.iter().flat_map(|c| c.tests.iter().flatten()).any(|t| t.outcome == T::Passed);
834 if had_passing {
835 let failed = matches!(verdict(&[], &after.checks, Some(&cmp)), Verdict::Failed { .. });
836 prop_assert!(failed);
837 }
838 }
839 }
840 }
841}