1use std::collections::BTreeMap;
114use std::path::Path;
115
116use super::coverage::{CommitId, EvidenceCoverage, EvidenceCoverageFloors};
117use super::freshness::TreeState;
118use super::ledger::{LedgerTarget, LEDGER_PATH, LOCAL_LEDGER_PATH};
119use super::provenance::EvidenceProvenance;
120use super::run::{
121 run_id, EvidenceAbsent, EvidenceDropped, EvidenceProducer, EvidenceRunRecord,
122 EvidenceRunTotals, RUN_RECORD_SCHEMA,
123};
124use super::{digest_of, Execution, Ledger, Origin, Outcome, Runner, TestId};
125use crate::error::{Error, Result};
126
127const UNIT_TESTS: &str =
129 "the crate's own unit tests, which no claim can name until a runner records them one by one";
130const DOC_TESTS: &str = "doctests, which no claim can name until a runner records them one by one";
132const NOT_AN_INTEGRATION_BINARY: &str = "not an integration test binary under tests/";
134const UNATTRIBUTED: &str = "no Running line named the binary it belongs to";
136
137#[derive(Debug, Clone)]
157pub struct RecordRequest {
165 pub suite: Option<std::path::PathBuf>,
167 pub crate_output: Option<std::path::PathBuf>,
169 pub origin: Origin,
171 pub run: Option<super::RunRef>,
173 pub provenance: BTreeMap<EvidenceProducer, EvidenceProvenance>,
176 pub coverage: Option<std::path::PathBuf>,
178 pub ledger: LedgerTarget,
180 pub run_record: Option<std::path::PathBuf>,
182}
183
184impl Default for RecordRequest {
185 fn default() -> RecordRequest {
187 RecordRequest::new(Origin::Local)
188 }
189}
190
191impl RecordRequest {
192 pub fn new(origin: Origin) -> RecordRequest {
202 RecordRequest {
203 suite: None,
204 crate_output: None,
205 origin,
206 run: None,
207 provenance: BTreeMap::new(),
208 coverage: None,
209 ledger: LedgerTarget::Repo,
210 run_record: None,
211 }
212 }
213}
214
215#[derive(Debug, Clone, PartialEq)]
257pub struct RecordOutcome {
300 pub recorded: usize,
302 pub passed: usize,
304 pub commit: String,
306 pub working_tree: String,
309 pub unknown: Vec<String>,
312 pub dropped: Vec<EvidenceDropped>,
317 pub run_record: EvidenceRunRecord,
319}
320
321pub(crate) fn now_rfc3339() -> String {
326 let secs = std::time::SystemTime::now()
327 .duration_since(std::time::UNIX_EPOCH)
328 .map(|d| d.as_secs() as i64)
329 .unwrap_or(0);
330 let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
331 let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
332 let z = days + 719_468;
334 let era = z.div_euclid(146_097);
335 let doe = z.rem_euclid(146_097);
336 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
337 let y = yoe + era * 400;
338 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
339 let mp = (5 * doy + 2) / 153;
340 let d = doy - (153 * mp + 2) / 5 + 1;
341 let m = if mp < 10 { mp + 3 } else { mp - 9 };
342 let y = if m <= 2 { y + 1 } else { y };
343 format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
344}
345
346#[derive(Debug, Clone, PartialEq, Eq)]
368pub struct CrateBinary {
369 pub name: String,
371 pub outcome: Outcome,
373 pub seconds: u64,
375 pub passed: u64,
377 pub failed: u64,
379 pub ignored: u64,
381 pub filtered_out: u64,
383}
384
385#[derive(Debug, Clone, Default, PartialEq, Eq)]
400pub struct CrateRead {
401 pub binaries: Vec<CrateBinary>,
403 pub dropped: Vec<EvidenceDropped>,
405}
406
407enum Pending {
409 Binary(String),
411 Dropped(String, &'static str),
413}
414
415impl CrateRead {
416 fn close(&mut self, pending: Option<Pending>) {
421 match pending {
422 None => {}
423 Some(Pending::Binary(name)) => self.binaries.push(CrateBinary {
424 name,
425 outcome: Outcome::Error,
426 seconds: 0,
427 passed: 0,
428 failed: 0,
429 ignored: 0,
430 filtered_out: 0,
431 }),
432 Some(Pending::Dropped(what, reason)) => self.dropped.push(dropped(what, reason)),
433 }
434 }
435}
436
437fn dropped(what: impl Into<String>, reason: &str) -> EvidenceDropped {
439 EvidenceDropped {
440 producer: EvidenceProducer::Crate,
441 what: what.into(),
442 reason: reason.to_string(),
443 }
444}
445
446pub fn read_crate_output(text: &str) -> CrateRead {
493 let mut read = CrateRead::default();
494 let mut pending: Option<Pending> = None;
495 for line in text.lines() {
496 let plain = strip_ansi(line);
497 let t = plain.trim();
498 if let Some(rest) = t.strip_prefix("Running ") {
499 read.close(pending.take());
500 let mut words = rest.split_whitespace();
503 let first = words.next().unwrap_or("");
504 pending = Some(if first == "unittests" {
505 let path = words.next().unwrap_or("");
506 Pending::Dropped(format!("unittests {path}"), UNIT_TESTS)
507 } else if let Some(name) = first
508 .strip_prefix("tests/")
509 .and_then(|p| p.strip_suffix(".rs"))
510 {
511 Pending::Binary(name.to_string())
512 } else {
513 Pending::Dropped(first.to_string(), NOT_AN_INTEGRATION_BINARY)
514 });
515 continue;
516 }
517 if let Some(rest) = t.strip_prefix("Doc-tests ") {
518 read.close(pending.take());
519 let krate = rest.split_whitespace().next().unwrap_or("");
520 pending = Some(Pending::Dropped(format!("doc-tests {krate}"), DOC_TESTS));
521 continue;
522 }
523 if let Some(rest) = t.strip_prefix("test result: ") {
524 match pending.take() {
525 None => read.dropped.push(dropped("a result line", UNATTRIBUTED)),
526 Some(Pending::Dropped(what, reason)) => read.dropped.push(dropped(what, reason)),
527 Some(Pending::Binary(name)) => read.binaries.push(judge(name, rest)),
528 }
529 }
530 }
531 read.close(pending.take());
532 read
533}
534
535fn judge(name: String, rest: &str) -> CrateBinary {
538 let (word, counts) = rest.split_once('.').unwrap_or((rest, ""));
539 let word = word.trim();
540 let (mut passed, mut failed, mut ignored, mut filtered_out) = (None, 0, 0, 0);
541 let mut seconds = 0;
542 for part in counts.split(';').map(str::trim) {
543 if let Some(s) = part.strip_prefix("finished in ") {
544 seconds = s
545 .trim_end_matches('s')
546 .trim()
547 .parse::<f64>()
548 .map(|f| f.round() as u64)
549 .unwrap_or(0);
550 continue;
551 }
552 let Some((n, label)) = part.split_once(' ') else {
553 continue;
554 };
555 let Ok(n) = n.parse::<u64>() else { continue };
556 match label.trim() {
557 "passed" => passed = Some(n),
558 "failed" => failed = n,
559 "ignored" => ignored = n,
560 "filtered out" => filtered_out = n,
561 _ => {}
562 }
563 }
564 let outcome = if failed > 0 || word == "FAILED" {
565 Outcome::Fail
566 } else if word == "ok" {
567 match passed {
568 None => Outcome::Error,
570 Some(0) => Outcome::Skip,
572 Some(_) if filtered_out > 0 => Outcome::Skip,
573 Some(_) => Outcome::Pass,
574 }
575 } else {
576 Outcome::Error
577 };
578 CrateBinary {
579 name,
580 outcome,
581 seconds,
582 passed: passed.unwrap_or(0),
583 failed,
584 ignored,
585 filtered_out,
586 }
587}
588
589fn strip_ansi(line: &str) -> String {
598 const ESC: char = '\u{1b}';
599 let mut out = String::with_capacity(line.len());
600 let mut chars = line.chars().peekable();
601 while let Some(c) = chars.next() {
602 if c != ESC {
603 out.push(c);
604 continue;
605 }
606 match chars.next() {
607 Some('[') => {
608 for c in chars.by_ref() {
609 if ('@'..='~').contains(&c) {
610 break;
611 }
612 }
613 }
614 Some(']') => {
615 let mut after_esc = false;
616 for c in chars.by_ref() {
617 if c == '\u{7}' || (after_esc && c == '\\') {
618 break;
619 }
620 after_esc = c == ESC;
621 }
622 }
623 Some(' '..='/') => {
625 while chars.next_if(|c| (' '..='/').contains(c)).is_some() {}
626 chars.next();
627 }
628 Some(_) | None => {}
629 }
630 }
631 out
632}
633
634pub fn parse_crate_binaries(text: &str) -> Vec<(String, Outcome, u64)> {
666 read_crate_output(text)
667 .binaries
668 .into_iter()
669 .map(|b| (b.name, b.outcome, b.seconds))
670 .collect()
671}
672
673fn parse_suite(text: &str) -> Result<Vec<(String, Outcome, u64)>> {
680 let mut out = Vec::new();
681 for (n, line) in text.lines().enumerate() {
682 if line.trim().is_empty() {
683 continue;
684 }
685 let f: Vec<&str> = line.split('\t').collect();
686 if f.len() < 4 {
687 return Err(Error::InvalidSurface {
688 surface: "evidence".into(),
689 reason: format!(
690 "line {} of the run report has {} field(s); it is \
691 name<TAB>result<TAB>seconds<TAB>phase",
692 n + 1,
693 f.len()
694 ),
695 });
696 }
697 out.push((
698 f[0].to_string(),
699 Outcome::parse(f[1]),
700 f[2].parse().unwrap_or(0),
701 ));
702 }
703 Ok(out)
704}
705
706pub fn record(root: &Path, req: &RecordRequest) -> Result<RecordOutcome> {
765 use EvidenceProducer as P;
766 if req.suite.is_none() && req.crate_output.is_none() && req.coverage.is_none() {
767 return Err(refused(
768 "nothing to record: give --suite, --crate-output, --coverage or several",
769 ));
770 }
771
772 let commit_id = match crate::git::inspect(root) {
774 crate::git::GitState::Available(i) => i.head,
775 crate::git::GitState::Unavailable { .. } => None,
776 }
777 .and_then(|h| CommitId::parse(&h).ok())
778 .ok_or_else(|| {
779 refused(
780 "this is not a git work tree with a commit, so a run recorded here would carry no \
781 provenance and prove nothing",
782 )
783 })?;
784 let commit = commit_id.as_str().to_string();
785
786 let report_of = |p: EvidenceProducer| match p {
787 P::Suite => req.suite.as_deref(),
788 P::Crate => req.crate_output.as_deref(),
789 P::Coverage => req.coverage.as_deref(),
790 };
791 for (&p, prov) in &req.provenance {
792 let w = p.as_str();
793 if report_of(p).is_none() {
794 return Err(refused(format!(
795 "a measurement of the {w} report was given, and no {w} report"
796 )));
797 }
798 if let Some(x) = prov.producer.filter(|x| *x != p) {
799 return Err(refused(format!(
800 "the {w} measurement measures the {} report, not the {w} one",
801 x.as_str()
802 )));
803 }
804 if CommitId::parse(&prov.commit).is_err() || prov.commit != commit {
805 return Err(refused(format!(
806 "the {w} report was measured at {}, and this checkout is at {}: record it in a \
807 checkout of the commit it ran",
808 short12(&prov.commit),
809 short12(&commit)
810 )));
811 }
812 if req.origin != Origin::Ci && prov.report.is_none() {
813 return Err(refused(format!(
814 "the {w} measurement names no report, so it cannot say which run it measured: \
815 stamp with --report <file>"
816 )));
817 }
818 }
819 if req.origin == Origin::Ci {
820 for p in [P::Suite, P::Crate] {
821 if report_of(p).is_some() && !req.provenance.contains_key(&p) {
822 return Err(refused(format!(
823 "a CI report is recorded only with the measurement its own run made: give \
824 --provenance {}=<file> from `majordomus evidence stamp`",
825 p.as_str()
826 )));
827 }
828 }
829 }
830 let read_report = |p: EvidenceProducer, path: &Path| -> Result<Vec<u8>> {
832 let bytes = std::fs::read(path).map_err(Error::Transport)?;
833 if let Some(artifact) = req.provenance.get(&p).and_then(|m| m.report.as_ref()) {
834 let actual = digest_of(&bytes);
835 if artifact.digest != actual {
836 return Err(refused(format!(
837 "the measurement describes another report ({} ≠ {})",
838 short12(artifact.digest.trim_start_matches("sha256:")),
839 short12(actual.trim_start_matches("sha256:"))
840 )));
841 }
842 }
843 Ok(bytes)
844 };
845 let tree_of = |p: EvidenceProducer| {
846 req.provenance
847 .get(&p)
848 .map_or(TreeState::Unknown, |m| m.working_tree)
849 };
850
851 let mut results: Vec<(TestId, Outcome, u64)> = Vec::new();
852 let mut unknown = Vec::new();
853 let mut absent = Vec::new();
854
855 if let Some(p) = &req.suite {
856 let bytes = read_report(P::Suite, p)?;
857 let text = String::from_utf8_lossy(&bytes);
858 let before = results.len();
859 for (name, outcome, seconds) in parse_suite(&text)? {
860 let id = TestId {
862 runner: Runner::Suite,
863 name: name.clone(),
864 };
865 if root.join(id.source()).exists() {
866 results.push((id, outcome, seconds));
867 } else {
868 unknown.push(format!("suite:{name}"));
869 }
870 }
871 if results.len() == before {
872 absent.push(absence(
873 P::Suite,
874 "the report named no test this repository has",
875 ));
876 }
877 } else {
878 absent.push(absence(P::Suite, "no suite report was given"));
879 }
880
881 let mut dropped = Vec::new();
882 if let Some(p) = &req.crate_output {
883 let bytes = read_report(P::Crate, p)?;
884 let read = read_crate_output(&String::from_utf8_lossy(&bytes));
885 if read.binaries.is_empty() {
886 absent.push(absence(
887 P::Crate,
888 "the output named no integration test binary",
889 ));
890 }
891 for binary in read.binaries {
892 let id = TestId {
893 runner: Runner::Crate,
894 name: binary.name.clone(),
895 };
896 if root.join(id.source()).exists() {
897 results.push((id, binary.outcome, binary.seconds));
898 } else {
899 unknown.push(format!("crate:{}", binary.name));
900 }
901 }
902 dropped = read.dropped;
904 } else {
905 absent.push(absence(P::Crate, "no crate report was given"));
906 }
907
908 let coverage = match &req.coverage {
909 Some(p) => {
910 let bytes = read_report(P::Coverage, p)?;
911 let floors = EvidenceCoverageFloors::read(root);
912 let text = String::from_utf8_lossy(&bytes);
913 let cov =
914 EvidenceCoverage::from_summary(&text, commit_id, tree_of(P::Coverage), floors)
915 .map_err(|e| refused(format!("{}: {e}", p.display())))?;
916 Some(cov)
917 }
918 None => {
919 absent.push(absence(P::Coverage, "no coverage report was given"));
920 None
921 }
922 };
923
924 if let Some(out) = &req.run_record {
925 let is_ledger = [LEDGER_PATH, LOCAL_LEDGER_PATH].iter().any(|l| {
926 let ledger = root.join(l);
927 out == &ledger
928 || matches!(
929 (std::fs::canonicalize(out), std::fs::canonicalize(&ledger)),
930 (Ok(a), Ok(b)) if a == b
931 )
932 });
933 if out.as_os_str().is_empty() || out.is_dir() || is_ledger {
934 return Err(refused(format!(
935 "`{}` cannot hold a run record: it is empty, a directory or a ledger",
936 out.display()
937 )));
938 }
939 }
940
941 let at = now_rfc3339();
942 let mut executions = Vec::with_capacity(results.len());
943 let mut passed = 0;
944 for (id, outcome, seconds) in results {
945 if outcome.proves() {
946 passed += 1;
947 }
948 let producer = match id.runner {
949 Runner::Suite => P::Suite,
950 Runner::Crate => P::Crate,
951 };
952 let tree = tree_of(producer);
953 let source = id.source();
954 let digest = digest_ran(root, &commit, &source, tree);
955 executions.push(Execution {
956 test: id.as_string(),
957 runner: id.runner,
958 source,
959 outcome,
960 seconds,
961 commit: commit.clone(),
962 working_tree: tree.as_str().into(),
963 digest,
964 at: at.clone(),
965 origin: req.origin,
966 command: id.reproduce(),
967 run: req.run.clone(),
968 });
969 }
970
971 let mut ledger = Ledger::load_from(root, req.ledger)?;
972 let recorded = ledger.merge(executions.clone());
973 if recorded > 0 {
979 ledger.save_to(root, req.ledger)?;
980 }
981
982 crate::order::canonical_strings(&mut unknown);
983 unknown.dedup();
984 let given = [P::Suite, P::Crate, P::Coverage]
985 .into_iter()
986 .filter(|p| report_of(*p).is_some());
987 let working_tree = given.fold(TreeState::Clean, |t, p| t.weaker(tree_of(p)));
988 let run_record = EvidenceRunRecord {
989 schema: RUN_RECORD_SCHEMA,
990 id: run_id(req.origin, req.run.as_ref(), &commit, &at),
991 origin: req.origin,
992 commit: commit.clone(),
993 working_tree,
994 recorded_at: at,
995 ledger: req.ledger,
996 run: req.run.clone(),
997 provenance: req.provenance.values().cloned().collect(),
998 totals: EvidenceRunTotals::of(&executions),
999 absent,
1000 dropped,
1001 unknown,
1002 coverage,
1003 executions,
1004 };
1005
1006 if let Some(out) = &req.run_record {
1007 write_run_record(out, &run_record).map_err(|e| {
1008 refused(format!(
1009 "the executions were recorded into {}, and the run record could not be \
1010 written to {}: {e}",
1011 req.ledger.path(),
1012 out.display()
1013 ))
1014 })?;
1015 }
1016
1017 Ok(RecordOutcome {
1018 recorded,
1019 passed,
1020 commit,
1021 working_tree: run_record.working_tree.as_str().to_string(),
1022 unknown: run_record.unknown.clone(),
1023 dropped: run_record.dropped.clone(),
1024 run_record,
1025 })
1026}
1027
1028fn refused(reason: impl Into<String>) -> Error {
1029 Error::InvalidSurface {
1030 surface: "evidence".into(),
1031 reason: reason.into(),
1032 }
1033}
1034
1035fn absence(producer: EvidenceProducer, reason: &str) -> EvidenceAbsent {
1036 EvidenceAbsent {
1037 producer,
1038 reason: reason.into(),
1039 }
1040}
1041
1042fn short12(id: &str) -> &str {
1044 id.get(..12).unwrap_or(id)
1045}
1046
1047fn digest_ran(root: &Path, commit: &str, source: &str, tree: TreeState) -> String {
1050 if tree == TreeState::Clean {
1051 let blob = crate::git::read_only(root)
1052 .args(["cat-file", "blob", &format!("{commit}:{source}")])
1053 .output()
1054 .ok()
1055 .filter(|out| out.status.success());
1056 if let Some(out) = blob {
1057 return digest_of(&out.stdout);
1058 }
1059 }
1060 std::fs::read(root.join(source))
1061 .map(|b| digest_of(&b))
1062 .unwrap_or_else(|_| "sha256:unreadable".into())
1063}
1064
1065fn write_run_record(out: &Path, run_record: &EvidenceRunRecord) -> std::io::Result<()> {
1066 out.parent().map_or(Ok(()), std::fs::create_dir_all)?;
1068 let mut text = serde_json::to_string_pretty(run_record).unwrap_or_default();
1070 text.push('\n');
1071 std::fs::write(out, text)
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076 use super::*;
1077
1078 fn git(dir: &Path, args: &[&str]) {
1079 let out = std::process::Command::new("git")
1080 .arg("-C")
1081 .arg(dir)
1082 .args(args)
1083 .output()
1084 .unwrap();
1085 assert!(out.status.success(), "git {args:?}");
1086 }
1087
1088 fn stamp_of(root: &Path, producer: EvidenceProducer, report: &Path) -> EvidenceProvenance {
1090 let req = super::super::provenance::StampRequest {
1091 producer: Some(producer),
1092 report: Some(report.to_path_buf()),
1093 ..Default::default()
1094 };
1095 super::super::provenance::stamp(root, &req).unwrap()
1096 }
1097
1098 fn measured(root: &Path, origin: Origin, tsv: &Path) -> RecordRequest {
1100 let mut req = RecordRequest {
1101 suite: Some(tsv.to_path_buf()),
1102 ..RecordRequest::new(origin)
1103 };
1104 req.provenance.insert(
1105 EvidenceProducer::Suite,
1106 stamp_of(root, EvidenceProducer::Suite, tsv),
1107 );
1108 req
1109 }
1110
1111 fn repo() -> tempfile::TempDir {
1112 let d = tempfile::tempdir().unwrap();
1113 git(d.path(), &["init", "-q"]);
1114 git(d.path(), &["config", "user.email", "t@example.com"]);
1115 git(d.path(), &["config", "user.name", "t"]);
1116 std::fs::create_dir_all(d.path().join("test/cases")).unwrap();
1117 std::fs::write(d.path().join("test/cases/07_scope.sh"), "echo scope\n").unwrap();
1118 std::fs::write(d.path().join("test/cases/08_other.sh"), "echo other\n").unwrap();
1119 git(d.path(), &["add", "-A"]);
1120 git(d.path(), &["commit", "-qm", "init"]);
1121 d
1122 }
1123
1124 #[test]
1125 fn a_suite_report_becomes_executions_with_provenance() {
1126 let d = repo();
1127 let reports = tempfile::tempdir().unwrap();
1131 let tsv = reports.path().join("run.tsv");
1132 std::fs::write(
1133 &tsv,
1134 "07_scope\tok\t12\tparallel\n08_other\tFAIL\t3\texclusive\n",
1135 )
1136 .unwrap();
1137
1138 let got = record(d.path(), &measured(d.path(), Origin::Ci, &tsv)).unwrap();
1139 assert_eq!(got.recorded, 2);
1140 assert_eq!(got.passed, 1);
1141 assert_eq!(got.commit.len(), 40);
1142 assert_eq!(got.working_tree, "clean");
1143 assert!(got.unknown.is_empty());
1144
1145 let l = Ledger::load(d.path()).unwrap();
1146 let e = l.latest("suite:07_scope").unwrap();
1147 assert_eq!(e.outcome, Outcome::Pass);
1148 assert_eq!(e.seconds, 12);
1149 assert_eq!(e.origin, Origin::Ci);
1150 assert_eq!(e.command, "bash test/run.sh 07_scope");
1151 assert_eq!(e.source, "test/cases/07_scope.sh");
1152 assert_eq!(e.commit, got.commit);
1153 assert_eq!(e.digest, digest_of(b"echo scope\n"));
1155 assert_eq!(e.digest_matches(d.path()), Some(true));
1156
1157 std::fs::write(d.path().join("test/cases/07_scope.sh"), "echo changed\n").unwrap();
1158 assert_eq!(
1159 l.latest("suite:07_scope").unwrap().digest_matches(d.path()),
1160 Some(false),
1161 "an edited test must not still match the digest that was recorded"
1162 );
1163 }
1164
1165 #[test]
1169 fn the_ledgers_own_row_does_not_make_the_tree_dirty() {
1170 let d = repo();
1171 let reports = tempfile::tempdir().unwrap();
1172 let tsv = reports.path().join("run.tsv");
1173 std::fs::write(&tsv, "07_scope\tok\t1\tparallel\n").unwrap();
1174
1175 let first = record(d.path(), &measured(d.path(), Origin::Local, &tsv)).unwrap();
1177 assert_eq!(first.working_tree, "clean");
1178 let second = record(d.path(), &measured(d.path(), Origin::Local, &tsv)).unwrap();
1179 assert_eq!(
1180 second.working_tree, "clean",
1181 "the previous recording's own row made the next run read as measured on a dirty tree"
1182 );
1183 let row = |d: &Path| {
1184 Ledger::load(d)
1185 .unwrap()
1186 .latest("suite:07_scope")
1187 .unwrap()
1188 .clone()
1189 };
1190 assert_eq!(row(d.path()).working_tree, "clean");
1191
1192 std::fs::write(d.path().join("test/cases/08_other.sh"), "echo edited\n").unwrap();
1194 let third = record(d.path(), &measured(d.path(), Origin::Local, &tsv)).unwrap();
1195 assert_eq!(third.working_tree, "dirty");
1196 assert_eq!(row(d.path()).working_tree, "dirty");
1197 }
1198
1199 #[test]
1203 fn a_result_for_a_test_that_does_not_exist_is_reported_not_recorded() {
1204 let d = repo();
1205 let tsv = d.path().join("run.tsv");
1206 std::fs::write(
1207 &tsv,
1208 "07_scope\tok\t1\tparallel\n99_ghost\tok\t1\tparallel\n",
1209 )
1210 .unwrap();
1211 let got = record(
1212 d.path(),
1213 &RecordRequest {
1214 suite: Some(tsv),
1215 crate_output: None,
1216 ..RecordRequest::new(Origin::Local)
1217 },
1218 )
1219 .unwrap();
1220 assert_eq!(got.recorded, 1);
1221 assert_eq!(got.unknown, vec!["suite:99_ghost"]);
1222 assert!(Ledger::load(d.path())
1223 .unwrap()
1224 .latest("suite:99_ghost")
1225 .is_none());
1226 }
1227
1228 #[test]
1231 fn a_later_partial_run_updates_only_what_it_ran() {
1232 let d = repo();
1233 let all = d.path().join("all.tsv");
1234 std::fs::write(
1235 &all,
1236 "07_scope\tok\t1\tparallel\n08_other\tok\t2\tparallel\n",
1237 )
1238 .unwrap();
1239 record(
1240 d.path(),
1241 &RecordRequest {
1242 suite: Some(all),
1243 crate_output: None,
1244 ..RecordRequest::new(Origin::Local)
1245 },
1246 )
1247 .unwrap();
1248
1249 let one = d.path().join("one.tsv");
1250 std::fs::write(&one, "07_scope\tFAIL\t9\tserial\n").unwrap();
1251 record(
1252 d.path(),
1253 &RecordRequest {
1254 suite: Some(one),
1255 crate_output: None,
1256 ..RecordRequest::new(Origin::Local)
1257 },
1258 )
1259 .unwrap();
1260
1261 let l = Ledger::load(d.path()).unwrap();
1262 assert_eq!(l.executions.len(), 2);
1263 assert_eq!(l.latest("suite:07_scope").unwrap().outcome, Outcome::Fail);
1264 assert_eq!(l.latest("suite:07_scope").unwrap().seconds, 9);
1265 assert_eq!(
1266 l.latest("suite:08_other").unwrap().outcome,
1267 Outcome::Pass,
1268 "the second run erased evidence it never measured"
1269 );
1270 }
1271
1272 #[test]
1275 fn a_malformed_report_is_refused_rather_than_partly_read() {
1276 let d = repo();
1277 let tsv = d.path().join("run.tsv");
1278 std::fs::write(&tsv, "07_scope\tok\t12\n").unwrap();
1279 let err = record(
1280 d.path(),
1281 &RecordRequest {
1282 suite: Some(tsv),
1283 crate_output: None,
1284 ..RecordRequest::new(Origin::Local)
1285 },
1286 )
1287 .unwrap_err()
1288 .to_string();
1289 assert!(err.contains("field(s)"), "{err}");
1290 assert!(
1291 !Ledger::present(d.path()),
1292 "a refused recording wrote a ledger"
1293 );
1294 }
1295
1296 #[test]
1301 fn a_recording_that_recorded_nothing_writes_no_ledger() {
1302 let d = repo();
1303 let reports = tempfile::tempdir().unwrap();
1304 let tsv = reports.path().join("run.tsv");
1305 std::fs::write(
1306 &tsv,
1307 "99_ghost\tok\t1\tparallel\n98_gone\tok\t1\tparallel\n",
1308 )
1309 .unwrap();
1310 let got = record(
1311 d.path(),
1312 &RecordRequest {
1313 suite: Some(tsv),
1314 crate_output: None,
1315 ..RecordRequest::new(Origin::Local)
1316 },
1317 )
1318 .unwrap();
1319 assert_eq!(got.recorded, 0);
1320 assert_eq!(got.unknown, vec!["suite:98_gone", "suite:99_ghost"]);
1321 assert!(
1322 !Ledger::present(d.path()),
1323 "a recording that recorded nothing created a ledger"
1324 );
1325 }
1326
1327 #[test]
1328 fn recording_nothing_is_refused() {
1329 let d = repo();
1330 assert!(record(
1331 d.path(),
1332 &RecordRequest {
1333 suite: None,
1334 crate_output: None,
1335 ..RecordRequest::new(Origin::Local)
1336 }
1337 )
1338 .is_err());
1339 }
1340
1341 #[test]
1345 fn a_run_with_no_commit_to_name_is_refused() {
1346 let d = tempfile::tempdir().unwrap();
1347 std::fs::create_dir_all(d.path().join("test/cases")).unwrap();
1348 std::fs::write(d.path().join("test/cases/07_scope.sh"), "x").unwrap();
1349 let tsv = d.path().join("run.tsv");
1350 std::fs::write(&tsv, "07_scope\tok\t1\tparallel\n").unwrap();
1351 let err = record(
1352 d.path(),
1353 &RecordRequest {
1354 suite: Some(tsv),
1355 crate_output: None,
1356 ..RecordRequest::new(Origin::Local)
1357 },
1358 )
1359 .unwrap_err()
1360 .to_string();
1361 assert!(err.contains("provenance"), "{err}");
1362 }
1363
1364 #[test]
1365 fn the_timestamp_is_rfc3339_utc() {
1366 let t = now_rfc3339();
1367 assert_eq!(t.len(), 20, "{t}");
1368 assert!(t.ends_with('Z'), "{t}");
1369 assert_eq!(&t[4..5], "-");
1370 assert_eq!(&t[10..11], "T");
1371 let year: i32 = t[..4].parse().unwrap();
1373 assert!((2020..2100).contains(&year), "{t}");
1374 }
1375
1376 #[test]
1379 fn an_unattributed_crate_result_is_not_credited_to_the_last_binary() {
1380 let got = parse_crate_binaries(
1381 "test result: ok. 1 passed; 0 failed\n\
1382 Running tests/why.rs (target/debug/deps/why-1)\n\
1383 test result: ok. 2 passed; 0 failed; finished in 1.60s\n\
1384 test result: FAILED. 0 passed; 1 failed\n",
1385 );
1386 assert_eq!(got.len(), 1);
1387 assert_eq!(got[0].0, "why");
1388 assert_eq!(got[0].2, 2, "1.60s rounds to 2");
1389 }
1390
1391 #[test]
1394 fn the_crates_own_unit_test_binary_is_not_an_integration_test() {
1395 let got = parse_crate_binaries(
1396 " Running unittests src/lib.rs (target/debug/deps/majordomus_cli-9)\n\
1397 test result: ok. 400 passed; 0 failed\n",
1398 );
1399 assert!(got.is_empty(), "{got:?}");
1400 }
1401
1402 fn only(text: &str) -> CrateBinary {
1405 let read = read_crate_output(text);
1406 assert_eq!(read.binaries.len(), 1, "{read:?}");
1407 read.binaries.into_iter().next().unwrap()
1408 }
1409
1410 #[test]
1413 fn a_binary_that_ran_no_test_is_a_skip() {
1414 let all_ignored = only(
1415 " Running tests/beta.rs (target/debug/deps/beta-1)\n\
1416 test result: ok. 0 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; \
1417 finished in 0.00s\n",
1418 );
1419 assert_eq!(all_ignored.outcome, Outcome::Skip);
1420 assert_eq!((all_ignored.passed, all_ignored.ignored), (0, 2));
1421
1422 let empty = only(
1423 " Running tests/empty.rs (target/debug/deps/empty-1)\n\
1424 test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; \
1425 finished in 0.00s\n",
1426 );
1427 assert_eq!(empty.outcome, Outcome::Skip);
1428 }
1429
1430 #[test]
1433 fn a_filtered_subset_is_a_skip() {
1434 let subset = only(
1435 " Running tests/gamma.rs (target/debug/deps/gamma-1)\n\
1436 test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; \
1437 finished in 0.00s\n",
1438 );
1439 assert_eq!(subset.outcome, Outcome::Skip);
1440 assert_eq!((subset.passed, subset.filtered_out), (1, 4));
1441
1442 let whole = only(
1443 " Running tests/gamma.rs (target/debug/deps/gamma-1)\n\
1444 test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; \
1445 finished in 0.00s\n",
1446 );
1447 assert_eq!(whole.outcome, Outcome::Pass, "the same binary, run whole");
1448 }
1449
1450 #[test]
1453 fn a_failed_count_is_a_failure_whatever_the_word() {
1454 for line in [
1455 "test result: ok. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out",
1456 "test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out",
1457 "test result: FAILED. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out",
1458 "test result: interrupted. 0 passed; 1 failed",
1459 ] {
1460 let b = only(&format!(
1461 " Running tests/delta.rs (target/debug/deps/delta-1)\n{line}\n"
1462 ));
1463 assert_eq!(b.outcome, Outcome::Fail, "{line}");
1464 }
1465 }
1466
1467 #[test]
1472 fn the_lines_between_a_binary_and_its_result_are_read_past() {
1473 let b = only(
1474 " Running tests/alpha.rs (target/debug/deps/alpha-1)\n\
1475 \n\
1476 running 3 tests\n\
1477 test alpha_holds ... ok\n\
1478 test alpha_is_fast ... ok\n\
1479 test alpha_later ... ignored\n\
1480 \n\
1481 test result: ok. 2 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; \
1482 finished in 0.40s\n\
1483 \n",
1484 );
1485 assert_eq!((b.name.as_str(), b.outcome), ("alpha", Outcome::Pass));
1486 assert_eq!((b.passed, b.ignored), (2, 1));
1487
1488 let noted = only(
1489 " Running tests/alpha.rs (target/debug/deps/alpha-1)\n\
1490 test result: ok. a note; 3 passed; 0 failed; 0 ignored; 0 measured; \
1491 0 filtered out; finished in 1.40s\n",
1492 );
1493 assert_eq!(noted.outcome, Outcome::Pass, "the counts after the note");
1494 assert_eq!((noted.passed, noted.seconds), (3, 1));
1495 }
1496
1497 #[test]
1500 fn a_crate_binary_this_repository_does_not_have_is_named_not_recorded() {
1501 let d = repo();
1502 let reports = tempfile::tempdir().unwrap();
1503 let log = reports.path().join("crate.log");
1504 std::fs::write(
1505 &log,
1506 " Running tests/ghost.rs (target/debug/deps/ghost-1)\n\
1507 test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n",
1508 )
1509 .unwrap();
1510 let got = record(
1511 d.path(),
1512 &RecordRequest {
1513 suite: None,
1514 crate_output: Some(log),
1515 ..RecordRequest::new(Origin::Local)
1516 },
1517 )
1518 .unwrap();
1519 assert_eq!(got.recorded, 0);
1520 assert_eq!(got.unknown, ["crate:ghost"]);
1521 assert!(
1522 !d.path().join(crate::evidence::LEDGER_PATH).exists(),
1523 "a recording of nothing this repository has writes no ledger"
1524 );
1525 }
1526
1527 #[test]
1530 fn a_result_line_without_counts_is_an_error() {
1531 for line in [
1532 "test result: ok.",
1533 "test result: ok",
1534 "test result: ok. finished in 0.10s",
1535 "test result: okay. 3 passed; 0 failed",
1536 "test result: . 3 passed",
1537 ] {
1538 let b = only(&format!(
1539 " Running tests/alpha.rs (target/debug/deps/alpha-1)\n{line}\n"
1540 ));
1541 assert_eq!(b.outcome, Outcome::Error, "{line}");
1542 }
1543 }
1544
1545 #[test]
1548 fn a_binary_that_printed_no_result_is_an_error() {
1549 let read = read_crate_output(
1550 " Running tests/eps.rs (target/debug/deps/eps-1)\n\
1551 Running tests/zeta.rs (target/debug/deps/zeta-1)\n\
1552 test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; \
1553 finished in 0.00s\n\
1554 Running tests/eta.rs (target/debug/deps/eta-1)\n\
1555 Doc-tests majordomus_cli\n\
1556 Running tests/theta.rs (target/debug/deps/theta-1)\n",
1557 );
1558 let got: Vec<(&str, Outcome, u64)> = read
1559 .binaries
1560 .iter()
1561 .map(|b| (b.name.as_str(), b.outcome, b.seconds))
1562 .collect();
1563 assert_eq!(
1564 got,
1565 [
1566 ("eps", Outcome::Error, 0),
1567 ("zeta", Outcome::Pass, 0),
1568 ("eta", Outcome::Error, 0),
1569 ("theta", Outcome::Error, 0),
1570 ]
1571 );
1572 assert_eq!(read.dropped.len(), 1, "{:?}", read.dropped);
1574 assert_eq!(read.dropped[0].what, "doc-tests majordomus_cli");
1575 }
1576
1577 #[test]
1580 fn colour_codes_are_stripped_before_matching() {
1581 let e = '\u{1b}';
1582 let coloured = format!(
1583 "{e}[1m{e}[92m Running{e}[0m tests/alpha.rs (target/debug/deps/alpha-1)\n\
1584 test result: {e}[32mok{e}(B{e}[m. 3 passed; 0 failed; 0 ignored; 0 measured; \
1585 0 filtered out; finished in 1.40s\n\
1586 {e}[1m{e}[92m Running{e}[0m tests/delta.rs (target/debug/deps/delta-1)\n\
1587 test result: {e}[31mFAILED{e}[0m. 2 passed; 1 failed; 0 ignored; 0 measured; \
1588 0 filtered out; finished in 0.20s\n\
1589 {e}[1m{e}[92m Doc-tests{e}[0m majordomus_cli\n\
1590 test result: ok. 5 passed; 0 failed\n"
1591 );
1592 let plain = strip_ansi(&coloured);
1593 assert!(!plain.contains(e), "{plain:?}");
1594 assert_eq!(read_crate_output(&coloured), read_crate_output(&plain));
1595 let read = read_crate_output(&coloured);
1596 let got: Vec<(&str, Outcome, u64)> = read
1597 .binaries
1598 .iter()
1599 .map(|b| (b.name.as_str(), b.outcome, b.seconds))
1600 .collect();
1601 assert_eq!(
1602 got,
1603 [("alpha", Outcome::Pass, 1), ("delta", Outcome::Fail, 0)]
1604 );
1605 assert_eq!(read.dropped[0].what, "doc-tests majordomus_cli");
1606
1607 assert_eq!(strip_ansi(&format!("a{e}[1;32mb{e}[0mc")), "abc");
1610 assert_eq!(
1611 strip_ansi(&format!("ok{e}(B{e}[m.")),
1612 "ok.",
1613 "the harness's own reset on an xterm, whose `B` is part of the escape"
1614 );
1615 assert_eq!(strip_ansi(&format!("a{e}#8b{e})0c{e} Fd")), "abcd");
1616 assert_eq!(
1617 strip_ansi(&format!("a{e}$(Bb")),
1618 "ab",
1619 "every intermediate byte, then the final one"
1620 );
1621 assert_eq!(strip_ansi(&format!("a{e}[?25lb")), "ab");
1622 assert_eq!(strip_ansi(&format!("a{e}]0;title\u{7}b")), "ab");
1623 assert_eq!(strip_ansi(&format!("a{e}]8;;http://x{e}\\b")), "ab");
1624 assert_eq!(strip_ansi(&format!("a{e}7b{e}8c")), "abc");
1625 assert_eq!(
1626 strip_ansi(&format!("a{e}")),
1627 "a",
1628 "a trailing ESC is dropped"
1629 );
1630 assert_eq!(strip_ansi("plain [brackets] stay"), "plain [brackets] stay");
1631 }
1632
1633 #[test]
1637 fn unit_and_doc_tests_are_listed_as_dropped() {
1638 let d = repo();
1639 std::fs::create_dir_all(d.path().join("apps/majordomus-cli/tests")).unwrap();
1640 std::fs::write(
1641 d.path().join("apps/majordomus-cli/tests/why.rs"),
1642 "// why\n",
1643 )
1644 .unwrap();
1645 git(d.path(), &["add", "-A"]);
1646 git(d.path(), &["commit", "-qm", "why"]);
1647 let out = " Running unittests src/lib.rs (target/debug/deps/majordomus_cli-1)\n\
1648 test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n\
1649 Running tests/why.rs (target/debug/deps/why-1)\n\
1650 test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n\
1651 Running benches/speed.rs (target/debug/deps/speed-1)\n\
1652 test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n\
1653 Doc-tests majordomus_cli\n\
1654 test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n";
1655 let read = read_crate_output(out);
1656 assert_eq!(read.binaries.len(), 1);
1657 assert_eq!(read.binaries[0].name, "why");
1658 let listed: Vec<(EvidenceProducer, &str, &str)> = read
1659 .dropped
1660 .iter()
1661 .map(|d| (d.producer, d.what.as_str(), d.reason.as_str()))
1662 .collect();
1663 assert_eq!(
1664 listed,
1665 [
1666 (EvidenceProducer::Crate, "unittests src/lib.rs", UNIT_TESTS),
1667 (
1668 EvidenceProducer::Crate,
1669 "benches/speed.rs",
1670 NOT_AN_INTEGRATION_BINARY
1671 ),
1672 (
1673 EvidenceProducer::Crate,
1674 "doc-tests majordomus_cli",
1675 DOC_TESTS
1676 ),
1677 ]
1678 );
1679 for entry in &read.dropped {
1680 assert!(
1681 !entry.reason.chars().any(|c| c.is_ascii_digit()),
1682 "a reason carries no counts: {}",
1683 entry.reason
1684 );
1685 }
1686
1687 let reports = tempfile::tempdir().unwrap();
1688 let log = reports.path().join("crate.log");
1689 std::fs::write(&log, out).unwrap();
1690 let got = record(
1691 d.path(),
1692 &RecordRequest {
1693 suite: None,
1694 crate_output: Some(log),
1695 ..RecordRequest::new(Origin::Local)
1696 },
1697 )
1698 .unwrap();
1699 assert_eq!(got.recorded, 1);
1700 assert_eq!(
1701 got.dropped, read.dropped,
1702 "the recording carries the same list"
1703 );
1704 let ledger = Ledger::load(d.path()).unwrap();
1705 assert_eq!(
1706 ledger.executions.len(),
1707 1,
1708 "nothing dropped reached the ledger"
1709 );
1710 assert_eq!(ledger.executions[0].test, "crate:why");
1711 }
1712
1713 #[test]
1716 fn a_result_line_no_running_line_named_is_dropped() {
1717 let read = read_crate_output(
1718 "test result: ok. 1 passed; 0 failed\n\
1719 Running tests/why.rs (target/debug/deps/why-1)\n\
1720 test result: ok. 2 passed; 0 failed; finished in 1.60s\n\
1721 test result: FAILED. 0 passed; 1 failed\n",
1722 );
1723 assert_eq!(read.binaries.len(), 1);
1724 assert_eq!(
1725 read.binaries[0].outcome,
1726 Outcome::Pass,
1727 "not the stray FAILED"
1728 );
1729 assert_eq!(read.dropped.len(), 2);
1730 for entry in &read.dropped {
1731 assert_eq!(
1732 (entry.producer, entry.what.as_str(), entry.reason.as_str()),
1733 (EvidenceProducer::Crate, "a result line", UNATTRIBUTED)
1734 );
1735 }
1736 }
1737
1738 fn report(dir: &tempfile::TempDir, name: &str, rows: &str) -> std::path::PathBuf {
1739 let p = dir.path().join(name);
1740 std::fs::write(&p, rows).unwrap();
1741 p
1742 }
1743
1744 fn refusal(root: &Path, req: &RecordRequest) -> String {
1745 record(root, req).unwrap_err().to_string()
1746 }
1747
1748 fn ledger_bytes(root: &Path) -> Option<Vec<u8>> {
1749 std::fs::read(root.join(LEDGER_PATH)).ok()
1750 }
1751
1752 #[test]
1753 fn a_report_is_stamped_with_its_runs_measurement_not_the_recorders() {
1754 let d = repo();
1755 let reports = tempfile::tempdir().unwrap();
1756 let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1757 std::fs::write(d.path().join("pending.txt"), "x").unwrap();
1759 let dirty = measured(d.path(), Origin::Local, &tsv);
1760 std::fs::remove_file(d.path().join("pending.txt")).unwrap();
1761 assert_eq!(record(d.path(), &dirty).unwrap().working_tree, "dirty");
1762 let clean = measured(d.path(), Origin::Local, &tsv);
1764 std::fs::write(d.path().join("pending.txt"), "x").unwrap();
1765 assert_eq!(record(d.path(), &clean).unwrap().working_tree, "clean");
1766 }
1767
1768 #[test]
1769 fn a_report_without_a_measurement_records_an_unknown_tree() {
1770 let d = repo();
1771 let reports = tempfile::tempdir().unwrap();
1772 let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1773 for origin in [Origin::Local, Origin::Release] {
1774 let got = record(
1775 d.path(),
1776 &RecordRequest {
1777 suite: Some(tsv.clone()),
1778 ..RecordRequest::new(origin)
1779 },
1780 );
1781 let got = got.unwrap();
1782 assert_eq!(got.working_tree, "unknown");
1783 assert!(got.run_record.provenance.is_empty());
1784 let row = Ledger::load(d.path())
1785 .unwrap()
1786 .latest("suite:07_scope")
1787 .unwrap()
1788 .clone();
1789 assert_eq!(row.working_tree, "unknown");
1790 }
1791 }
1792
1793 #[test]
1794 fn a_local_measurement_that_names_no_report_is_refused() {
1795 let d = repo();
1796 let reports = tempfile::tempdir().unwrap();
1797 let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1798 for origin in [Origin::Local, Origin::Release] {
1799 let mut req = measured(d.path(), origin, &tsv);
1800 req.provenance
1801 .get_mut(&EvidenceProducer::Suite)
1802 .unwrap()
1803 .report = None;
1804 let err = refusal(d.path(), &req);
1805 assert!(err.contains("stamp with --report"), "{err}");
1806 }
1807 assert_eq!(ledger_bytes(d.path()), None);
1808 }
1809
1810 #[test]
1811 fn a_ci_measurement_without_a_report_is_accepted() {
1812 let d = repo();
1813 let reports = tempfile::tempdir().unwrap();
1814 let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1815 let mut req = measured(d.path(), Origin::Ci, &tsv);
1816 req.provenance
1817 .get_mut(&EvidenceProducer::Suite)
1818 .unwrap()
1819 .report = None;
1820 assert_eq!(record(d.path(), &req).unwrap().working_tree, "clean");
1821 }
1822
1823 #[test]
1824 fn a_ci_report_without_a_measurement_is_refused_before_anything_is_written() {
1825 let d = repo();
1826 let reports = tempfile::tempdir().unwrap();
1827 let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1828 let log = report(&reports, "crate.log", "");
1829 for req in [
1830 RecordRequest {
1831 suite: Some(tsv.clone()),
1832 ..RecordRequest::new(Origin::Ci)
1833 },
1834 RecordRequest {
1835 crate_output: Some(log),
1836 ..measured(d.path(), Origin::Ci, &tsv)
1837 },
1838 ] {
1839 let err = refusal(d.path(), &req);
1840 assert!(err.contains("give --provenance"), "{err}");
1841 }
1842 assert_eq!(ledger_bytes(d.path()), None);
1843 }
1844
1845 #[test]
1846 fn a_measurement_of_another_commit_is_refused() {
1847 let d = repo();
1848 let reports = tempfile::tempdir().unwrap();
1849 let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1850 let old = measured(d.path(), Origin::Local, &tsv);
1851 std::fs::write(d.path().join("next.txt"), "x").unwrap();
1852 git(d.path(), &["add", "-A"]);
1853 git(d.path(), &["commit", "-qm", "next"]);
1854 let err = refusal(d.path(), &old);
1855 let then = &old.provenance[&EvidenceProducer::Suite].commit[..12];
1856 assert!(
1857 err.contains(then) && err.contains("in a checkout of the commit it ran"),
1858 "{err}"
1859 );
1860 let mut short = measured(d.path(), Origin::Local, &tsv);
1861 let m = short.provenance.get_mut(&EvidenceProducer::Suite).unwrap();
1862 m.commit.truncate(12);
1863 assert!(refusal(d.path(), &short).contains("was measured at"));
1864 }
1865
1866 #[test]
1867 fn a_measurement_of_another_report_is_refused() {
1868 let d = repo();
1869 let reports = tempfile::tempdir().unwrap();
1870 let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1871 let other = report(&reports, "other.tsv", "07_scope\tok\t2\tparallel\n");
1872 let mut req = measured(d.path(), Origin::Local, &other);
1873 req.suite = Some(tsv);
1874 let err = refusal(d.path(), &req);
1875 assert!(err.contains("describes another report"), "{err}");
1876 assert_eq!(ledger_bytes(d.path()), None);
1877 }
1878
1879 #[test]
1880 fn a_measurement_for_a_report_not_given_is_refused() {
1881 let d = repo();
1882 let reports = tempfile::tempdir().unwrap();
1883 let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1884 let mut req = measured(d.path(), Origin::Local, &tsv);
1885 let m = req.provenance[&EvidenceProducer::Suite].clone();
1886 for p in [EvidenceProducer::Crate, EvidenceProducer::Coverage] {
1887 let mut m = m.clone();
1888 m.producer = Some(p);
1889 req.provenance.insert(p, m);
1890 let err = refusal(d.path(), &req);
1891 assert!(
1892 err.contains(&format!("and no {} report", p.as_str())),
1893 "{err}"
1894 );
1895 req.provenance.remove(&p);
1896 }
1897 }
1898
1899 #[test]
1900 fn a_measurement_whose_producer_disagrees_with_its_key_is_refused() {
1901 let d = repo();
1902 let reports = tempfile::tempdir().unwrap();
1903 let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1904 let mut req = measured(d.path(), Origin::Local, &tsv);
1905 req.provenance
1906 .get_mut(&EvidenceProducer::Suite)
1907 .unwrap()
1908 .producer = Some(EvidenceProducer::Crate);
1909 let err = refusal(d.path(), &req);
1910 assert!(
1911 err.contains("measures the crate report, not the suite one"),
1912 "{err}"
1913 );
1914 }
1915
1916 #[test]
1917 fn every_refusal_leaves_no_ledger_and_no_run_record() {
1918 let d = repo();
1919 let reports = tempfile::tempdir().unwrap();
1920 let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1921 let other = report(&reports, "other.tsv", "08_other\tok\t1\tparallel\n");
1922 let bad = report(&reports, "bad.tsv", "07_scope\tok\n");
1923 let cov = report(&reports, "cov.json", r#"{"schema": 2}"#);
1924 let out = reports.path().join("out/run.json");
1925 let with_out = |mut r: RecordRequest| {
1926 r.run_record = Some(out.clone());
1927 r
1928 };
1929 let unreported = {
1930 let mut r = measured(d.path(), Origin::Local, &tsv);
1931 r.provenance
1932 .get_mut(&EvidenceProducer::Suite)
1933 .unwrap()
1934 .report = None;
1935 r
1936 };
1937 let another = RecordRequest {
1938 suite: Some(tsv.clone()),
1939 ..measured(d.path(), Origin::Local, &other)
1940 };
1941 let foreign = {
1942 let mut r = measured(d.path(), Origin::Local, &tsv);
1943 r.provenance
1944 .get_mut(&EvidenceProducer::Suite)
1945 .unwrap()
1946 .commit = "a".repeat(40);
1947 r
1948 };
1949 let refusals = [
1950 RecordRequest::default(),
1951 RecordRequest {
1952 suite: Some(tsv.clone()),
1953 ..RecordRequest::new(Origin::Ci)
1954 },
1955 unreported,
1956 another,
1957 foreign,
1958 RecordRequest {
1959 suite: Some(bad),
1960 ..RecordRequest::default()
1961 },
1962 RecordRequest {
1963 coverage: Some(cov),
1964 ..RecordRequest::default()
1965 },
1966 RecordRequest {
1967 suite: Some(tsv.clone()),
1968 run_record: Some(reports.path().to_path_buf()),
1969 ..RecordRequest::default()
1970 },
1971 RecordRequest {
1972 suite: Some(tsv.clone()),
1973 run_record: Some(d.path().join(LEDGER_PATH)),
1974 ..RecordRequest::default()
1975 },
1976 RecordRequest {
1977 suite: Some(tsv.clone()),
1978 run_record: Some(d.path().join(LOCAL_LEDGER_PATH)),
1979 ..RecordRequest::default()
1980 },
1981 RecordRequest {
1982 suite: Some(tsv.clone()),
1983 run_record: Some("".into()),
1984 ..RecordRequest::default()
1985 },
1986 ];
1987 for (n, req) in refusals.into_iter().enumerate() {
1988 let keep = req.run_record.is_some();
1989 let req = if keep { req } else { with_out(req) };
1990 assert!(record(d.path(), &req).is_err(), "refusal {n} recorded");
1991 assert_eq!(ledger_bytes(d.path()), None, "refusal {n} wrote the ledger");
1992 assert!(!d.path().join(LOCAL_LEDGER_PATH).exists(), "refusal {n}");
1993 assert!(!out.exists(), "refusal {n} wrote a run record");
1994 }
1995 }
1996
1997 #[test]
1998 fn the_digest_is_taken_from_the_commit_when_the_run_was_clean() {
1999 let d = repo();
2000 let reports = tempfile::tempdir().unwrap();
2001 let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
2002 let req = measured(d.path(), Origin::Local, &tsv);
2003 std::fs::write(d.path().join("test/cases/07_scope.sh"), "echo edited\n").unwrap();
2004 let got = record(d.path(), &req).unwrap();
2005 assert_eq!(
2006 got.run_record.executions[0].digest,
2007 digest_of(b"echo scope\n")
2008 );
2009 }
2010
2011 #[test]
2012 fn the_digest_is_taken_from_the_checkout_when_the_tree_is_not_known_clean() {
2013 let d = repo();
2014 let reports = tempfile::tempdir().unwrap();
2015 let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
2016 std::fs::write(d.path().join("test/cases/07_scope.sh"), "echo edited\n").unwrap();
2017 let dirty = measured(d.path(), Origin::Local, &tsv);
2018 let got = record(d.path(), &dirty).unwrap();
2019 assert_eq!(
2020 got.run_record.executions[0].digest,
2021 digest_of(b"echo edited\n")
2022 );
2023 let unmeasured = RecordRequest {
2024 suite: Some(tsv.clone()),
2025 ..RecordRequest::default()
2026 };
2027 let got = record(d.path(), &unmeasured).unwrap();
2028 assert_eq!(
2029 got.run_record.executions[0].digest,
2030 digest_of(b"echo edited\n")
2031 );
2032 std::fs::write(d.path().join("test/cases/09_new.sh"), "echo new\n").unwrap();
2034 assert_eq!(
2035 digest_ran(
2036 d.path(),
2037 &got.commit,
2038 "test/cases/09_new.sh",
2039 TreeState::Clean
2040 ),
2041 digest_of(b"echo new\n")
2042 );
2043 assert_eq!(
2044 digest_ran(
2045 d.path(),
2046 &got.commit,
2047 "test/cases/none.sh",
2048 TreeState::Dirty
2049 ),
2050 "sha256:unreadable"
2051 );
2052 }
2053
2054 #[test]
2055 fn a_local_recording_changes_no_tracked_file() {
2056 let d = repo();
2057 let reports = tempfile::tempdir().unwrap();
2058 let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
2059 let req = RecordRequest {
2060 ledger: LedgerTarget::Local,
2061 ..measured(d.path(), Origin::Local, &tsv)
2062 };
2063 let got = record(d.path(), &req).unwrap();
2064 assert_eq!(got.run_record.ledger, LedgerTarget::Local);
2065 assert_eq!(
2066 ledger_bytes(d.path()),
2067 None,
2068 "the tracked ledger is not written"
2069 );
2070 let local = Ledger::load_from(d.path(), LedgerTarget::Local).unwrap();
2071 assert!(local.latest("suite:07_scope").is_some());
2072 }
2073
2074 #[test]
2075 fn the_run_record_written_is_the_run_record_returned() {
2076 let d = repo();
2077 let reports = tempfile::tempdir().unwrap();
2078 let tsv = report(
2079 &reports,
2080 "run.tsv",
2081 "07_scope\tok\t1\tparallel\n99_ghost\tok\t1\tparallel\n",
2082 );
2083 let out = reports.path().join("deep/run.json");
2084 let req = RecordRequest {
2085 run_record: Some(out.clone()),
2086 ..measured(d.path(), Origin::Local, &tsv)
2087 };
2088 let got = record(d.path(), &req).unwrap();
2089 let text = std::fs::read_to_string(&out).unwrap();
2090 assert!(text.ends_with("}\n"));
2091 let back: EvidenceRunRecord = serde_json::from_str(&text).unwrap();
2092 assert_eq!(back, got.run_record);
2093 assert_eq!(back.unknown, ["suite:99_ghost"]);
2094 assert_eq!(back.schema, RUN_RECORD_SCHEMA);
2095 let absent: Vec<_> = back.absent.iter().map(|a| a.producer).collect();
2096 assert_eq!(
2097 absent,
2098 [EvidenceProducer::Crate, EvidenceProducer::Coverage]
2099 );
2100 }
2101
2102 #[test]
2103 fn a_run_record_that_cannot_be_written_says_the_executions_were_recorded() {
2104 let d = repo();
2105 let reports = tempfile::tempdir().unwrap();
2106 let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
2107 let blocker = report(&reports, "file", "x");
2108 let req = RecordRequest {
2109 run_record: Some(blocker.join("run.json")),
2110 ..measured(d.path(), Origin::Local, &tsv)
2111 };
2112 let err = refusal(d.path(), &req);
2113 assert!(
2114 err.contains("the executions were recorded into")
2115 && err.contains("run record could not be written"),
2116 "{err}"
2117 );
2118 assert!(ledger_bytes(d.path()).is_some());
2119 }
2120
2121 #[test]
2122 fn the_outcomes_dropped_list_is_the_run_records() {
2123 let d = repo();
2124 let reports = tempfile::tempdir().unwrap();
2125 let log = report(&reports, "crate.log", " Running unittests src/lib.rs (x)\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.1s\n");
2126 let got = record(
2127 d.path(),
2128 &RecordRequest {
2129 crate_output: Some(log),
2130 ..RecordRequest::default()
2131 },
2132 )
2133 .unwrap();
2134 assert_eq!(got.dropped.len(), 1);
2135 assert_eq!(got.dropped, got.run_record.dropped);
2136 assert_eq!(
2137 got.run_record.absent[1].reason,
2138 "the output named no integration test binary"
2139 );
2140 }
2141
2142 const COVERAGE_SUMMARY: &str = r#"{"schema":1,"measurement":"scripts/rust-coverage",
2144 "test_code":"excluded",
2145 "crate":{"lines":{"covered":1,"total":2,"percent":50.0},
2146 "functions":{"covered":1,"total":2,"percent":50.0},
2147 "regions":{"covered":1,"total":2,"percent":50.0}},
2148 "domain":{"lines":{"covered":1,"total":2,"percent":50.0},
2149 "functions":{"covered":1,"total":2,"percent":50.0},
2150 "regions":{"covered":1,"total":2,"percent":50.0},
2151 "files":["src/a.rs"],"missing":[]},
2152 "files":{}}"#;
2153
2154 #[test]
2157 fn an_unreadable_crate_output_or_coverage_summary_is_refused() {
2158 let d = repo();
2159 let reports = tempfile::tempdir().unwrap();
2160 let missing = reports.path().join("absent.txt");
2161 for req in [
2162 RecordRequest {
2163 crate_output: Some(missing.clone()),
2164 ..RecordRequest::new(Origin::Local)
2165 },
2166 RecordRequest {
2167 coverage: Some(missing.clone()),
2168 ..RecordRequest::new(Origin::Local)
2169 },
2170 ] {
2171 assert!(record(d.path(), &req).is_err());
2172 assert!(ledger_bytes(d.path()).is_none());
2173 }
2174 }
2175
2176 #[test]
2179 fn a_coverage_summary_is_bound_to_the_commit_and_a_malformed_one_is_refused() {
2180 let d = repo();
2181 let reports = tempfile::tempdir().unwrap();
2182 let good = report(&reports, "coverage.json", COVERAGE_SUMMARY);
2183 let req = RecordRequest {
2184 coverage: Some(good),
2185 ..RecordRequest::new(Origin::Local)
2186 };
2187 let got = record(d.path(), &req).unwrap();
2188 let cov = got.run_record.coverage.expect("the coverage record");
2189 assert_eq!(cov.commit.as_str(), got.commit);
2190 assert_eq!(cov.working_tree, TreeState::Unknown);
2191
2192 let bad = report(&reports, "bad.json", r#"{"schema":2}"#);
2193 let req = RecordRequest {
2194 coverage: Some(bad),
2195 ..RecordRequest::new(Origin::Local)
2196 };
2197 assert!(refusal(d.path(), &req).contains("bad.json"));
2198 }
2199
2200 #[test]
2202 fn a_run_record_spelled_as_another_path_to_the_ledger_is_refused() {
2203 let d = repo();
2204 let reports = tempfile::tempdir().unwrap();
2205 let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
2206 record(d.path(), &measured(d.path(), Origin::Local, &tsv)).unwrap();
2207 let ledger = d.path().join(".ai/repo/evidence/../evidence/ledger.json");
2208 let req = RecordRequest {
2209 run_record: Some(ledger),
2210 ..measured(d.path(), Origin::Local, &tsv)
2211 };
2212 assert!(
2213 refusal(d.path(), &req).contains("cannot hold a run record"),
2214 "the ledger, reached through `..`, was accepted as a run record"
2215 );
2216 }
2217
2218 #[test]
2220 fn a_ledger_that_cannot_be_read_or_written_fails_the_recording() {
2221 let reports = tempfile::tempdir().unwrap();
2222 let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
2223
2224 let unreadable = repo();
2225 std::fs::create_dir_all(unreadable.path().join(LEDGER_PATH)).unwrap();
2226 let req = RecordRequest {
2227 suite: Some(tsv.clone()),
2228 ..RecordRequest::new(Origin::Local)
2229 };
2230 assert!(record(unreadable.path(), &req).is_err());
2231
2232 let unwritable = repo();
2233 std::fs::create_dir_all(unwritable.path().join(".ai/repo")).unwrap();
2234 std::fs::write(unwritable.path().join(".ai/repo/evidence"), "a file").unwrap();
2235 assert!(record(unwritable.path(), &req).is_err());
2236 }
2237}