1use crate::{
7 coverage_report::{
8 ArchiveReportRequest, CoverageReport, ExitCodeInput, analyze_coverage_archive,
9 },
10 evidence_archive::read_archive_selected,
11 lifecycle::atomic_write,
12 run_store::{RunMetadata, StoredRun},
13 source_units::{Code, Diff, named},
14};
15use serde::{Deserialize, Serialize};
16use serde_json::{Value, json};
17use sha2::{Digest, Sha256};
18use std::{
19 collections::{BTreeMap, BTreeSet},
20 fs,
21 path::Path,
22};
23pub type Files = BTreeMap<String, String>;
24pub fn digest(value: &impl Serialize) -> String {
25 format!(
26 "{:x}",
27 Sha256::digest(serde_json::to_vec(value).expect("serializable map"))
28 )
29}
30
31pub fn local_path(file: &str) -> bool {
33 !file.is_empty()
34 && !file.contains(['\\', ':'])
35 && file.split('/').all(|p| !matches!(p, "" | "." | ".."))
36}
37pub fn line_starts(source: &str) -> Vec<usize> {
38 std::iter::once(0)
39 .chain(source.match_indices('\n').map(|(at, _)| at + 1))
40 .collect()
41}
42
43pub fn line_text<'s>(source: &'s str, starts: &[usize], line: usize) -> Option<&'s str> {
47 let start = *starts.get(line.checked_sub(1)?)?;
48 if start >= source.len() {
49 return None;
50 }
51 match starts.get(line) {
52 Some(next) => {
53 let text = &source[start..next - 1];
54 Some(text.strip_suffix('\r').unwrap_or(text))
55 }
56 None => Some(&source[start..]),
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase", deny_unknown_fields)]
63pub struct Inputs {
64 pub language: String,
65 pub files: Files,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70pub struct FileFingerprint {
71 pub sha256: String,
72 pub bytes: usize,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub code: Option<Code>,
79}
80impl FileFingerprint {
81 pub fn of(source: &str) -> Self {
82 Self {
83 sha256: format!("{:x}", Sha256::digest(source.as_bytes())),
84 bytes: source.len(),
85 code: None,
86 }
87 }
88 pub fn read(path: &str, source: &str) -> Self {
90 let mut fingerprint = Self::of(source);
91 fingerprint.code = crate::source_units::code(path, source);
92 fingerprint
93 }
94 pub fn same_bytes(&self, other: &Self) -> bool {
95 self.sha256 == other.sha256
96 }
97}
98pub type FileManifest = BTreeMap<String, FileFingerprint>;
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase", deny_unknown_fields)]
103pub struct InputManifest {
104 pub schema_version: u32,
105 pub language: String,
106 pub files: FileManifest,
107}
108pub const MANIFEST_SCHEMA: u32 = 3;
110impl Inputs {
111 pub fn manifest(&self) -> InputManifest {
112 InputManifest {
113 schema_version: MANIFEST_SCHEMA,
114 language: self.language.clone(),
115 files: self
116 .files
117 .iter()
118 .map(|(p, s)| (p.clone(), FileFingerprint::read(p, s)))
119 .collect(),
120 }
121 }
122}
123impl InputManifest {
124 pub fn with_sources(&self, files: Files) -> Inputs {
125 Inputs {
126 language: self.language.clone(),
127 files,
128 }
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase", deny_unknown_fields)]
134pub struct TestSelector {
135 pub file: String,
136 pub name: String,
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase", deny_unknown_fields)]
142pub struct Executions {
143 pub tests: Vec<Execution>,
144 pub probed: BTreeMap<String, Vec<usize>>,
147 #[serde(default)]
156 pub covered: BTreeMap<String, Vec<usize>>,
157}
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase", deny_unknown_fields)]
160pub struct Execution {
161 pub test: TestSelector,
162 pub passed: bool,
163 pub files: BTreeMap<String, Vec<usize>>,
165 #[serde(default = "exact_by_default")]
174 pub attribution: String,
175}
176
177fn exact_by_default() -> String {
180 crate::coverage_report::ATTRIBUTION_EXACT.to_owned()
181}
182pub enum FileChange<'a> {
185 Same,
186 CommentsOnly,
188 Added,
190 Removed,
191 Bytes,
193 Code {
194 before: &'a Code,
195 after: &'a Code,
196 diff: Diff,
197 narrow: bool,
200 },
201}
202pub fn file_change<'a>(
203 before: Option<&'a FileFingerprint>,
204 after: Option<&'a FileFingerprint>,
205 probed: Option<&[usize]>,
206) -> Option<FileChange<'a>> {
207 let Some(before) = before else {
208 return after.map(|_| FileChange::Added);
209 };
210 let Some(after) = after else {
211 return Some(FileChange::Removed);
212 };
213 if before.same_bytes(after) {
214 return Some(FileChange::Same);
215 }
216 let (Some(old), Some(new)) = (&before.code, &after.code) else {
217 return Some(FileChange::Bytes);
218 };
219 if old.semantic == new.semantic {
220 return Some(FileChange::CommentsOnly);
221 }
222 let diff = old.diff(new);
223 let narrow = probed.is_some_and(|probed| diff.narrow(old, probed));
224 Some(FileChange::Code {
225 before: old,
226 after: new,
227 diff,
228 narrow,
229 })
230}
231pub fn describe(before: &Code, after: &Code, diff: &Diff) -> String {
233 let mut parts = Vec::new();
234 if !diff.changed.is_empty() {
235 parts.push(named(diff.changed.iter().map(|i| &before.units[*i])));
236 }
237 if !diff.added.is_empty() {
238 parts.push(format!(
239 "added {}",
240 named(diff.added.iter().map(|i| &after.units[*i]))
241 ));
242 }
243 if !diff.removed.is_empty() {
244 parts.push(format!(
245 "removed {}",
246 named(diff.removed.iter().map(|i| &before.units[*i]))
247 ));
248 }
249 if parts.is_empty() {
250 "declarations".to_owned()
251 } else {
252 parts.join("; ")
253 }
254}
255
256const MANIFEST_CACHE_FILE: &str = "source-manifest.cache.json";
260pub const EXECUTIONS_FILE: &str = "test-executions.json";
262pub use crate::source_capture::ARCHIVE_PATH;
264
265#[derive(serde::Serialize, serde::Deserialize)]
266#[serde(rename_all = "camelCase", deny_unknown_fields)]
267struct ManifestCache {
268 evidence_digest: String,
269 manifest: InputManifest,
270 #[serde(default)]
271 statement_exclusions: Vec<Value>,
272}
273pub struct RunManifest {
274 pub manifest: InputManifest,
275 pub evidence_digest: String,
276 pub statement_exclusions: Vec<Value>,
277}
278pub struct RunInputs {
279 pub inputs: Inputs,
280 pub stored: RunManifest,
281}
282impl std::ops::Deref for RunInputs {
283 type Target = RunManifest;
284 fn deref(&self) -> &RunManifest {
285 &self.stored
286 }
287}
288pub fn load_inputs(root: &Path, run: &StoredRun) -> Result<RunInputs, String> {
291 let stored = load_manifest(run)?;
292 let inputs = crate::source_capture::current_sources(root, &stored.manifest)?;
293 Ok(RunInputs { inputs, stored })
294}
295pub fn load_manifest(run: &StoredRun) -> Result<RunManifest, String> {
296 load_optional_manifest(run)?.ok_or_else(|| {
297 "This run has no source manifest. Run tests once with this version of Supercov".into()
298 })
299}
300fn unsharded(path: &str) -> &str {
304 let mut rest = path;
305 while let Some((shard, inner)) = rest
306 .strip_prefix("shards/")
307 .and_then(|after| after.split_once('/'))
308 {
309 if shard.is_empty() || !shard.bytes().all(|byte| byte.is_ascii_digit()) {
310 break;
311 }
312 rest = inner;
313 }
314 rest
315}
316fn the_one(
321 entries: &[crate::evidence_archive::EvidenceArchiveEntry],
322 named: impl Fn(&str) -> bool,
323) -> Result<Option<Value>, String> {
324 let mut found: Option<(&[u8], Value)> = None;
325 for entry in entries.iter().filter(|entry| named(&entry.path)) {
326 match &found {
327 Some((bytes, _)) if *bytes == entry.contents.as_slice() => {}
328 Some((_, value)) => {
329 let other: Value =
330 serde_json::from_slice(&entry.contents).map_err(|e| e.to_string())?;
331 if other != *value {
332 return Err("The runs merged into this one recorded different source manifests; use one of them instead".into());
333 }
334 }
335 None => {
336 found = Some((
337 &entry.contents,
338 serde_json::from_slice(&entry.contents).map_err(|e| e.to_string())?,
339 ));
340 }
341 }
342 }
343 Ok(found.map(|(_, value)| value))
344}
345fn load_optional_manifest(run: &StoredRun) -> Result<Option<RunManifest>, String> {
346 let merged = run.metadata.merged == Some(true);
347 let bytes = fs::read(&run.evidence_path).map_err(|e| e.to_string())?;
348 let evidence_digest = format!("{:x}", Sha256::digest(&bytes));
349 if let Some(cached) = fs::read(run.directory.join(MANIFEST_CACHE_FILE))
350 .ok()
351 .and_then(|bytes| serde_json::from_slice::<ManifestCache>(&bytes).ok())
352 .filter(|cached| cached.evidence_digest == evidence_digest)
353 .filter(|cached| validate_manifest(&cached.manifest).is_ok())
354 {
355 return Ok(Some(RunManifest {
356 manifest: cached.manifest,
357 evidence_digest,
358 statement_exclusions: cached.statement_exclusions,
359 }));
360 }
361 let named = |path: &str, name: &str| {
362 if merged {
363 unsharded(path) == name
364 } else {
365 path == name
366 }
367 };
368 let entries = read_archive_selected(&run.evidence_path, |path| {
369 named(path, ARCHIVE_PATH) || named(path, "statement-exclusions.json")
370 })
371 .map_err(|e| e.to_string())?;
372 let Some(value) = the_one(&entries, |path| named(path, ARCHIVE_PATH))? else {
373 return Ok(None);
374 };
375 if value["schemaVersion"].as_u64() != Some(u64::from(MANIFEST_SCHEMA)) {
376 return Err(
377 "Unsupported source manifest schema; rerun tests with this version of Supercov".into(),
378 );
379 }
380 let manifest = serde_json::from_value::<InputManifest>(value).map_err(|e| e.to_string())?;
381 validate_manifest(&manifest)?;
382 if fs::read(&run.evidence_path).map_err(|e| e.to_string())? != bytes {
383 return Err("Run archive changed during read".into());
384 }
385 Ok(Some(RunManifest {
386 manifest,
387 evidence_digest,
388 statement_exclusions: the_one(&entries, |path| named(path, "statement-exclusions.json"))?
389 .map(|value| serde_json::from_value(value).map_err(|e| e.to_string()))
390 .transpose()?
391 .unwrap_or_default(),
392 }))
393}
394fn validate_manifest(manifest: &InputManifest) -> Result<(), String> {
395 if manifest.files.iter().any(|(p, f)| {
396 !local_path(p) || f.sha256.len() != 64 || !f.sha256.bytes().all(|b| b.is_ascii_hexdigit())
397 }) {
398 return Err("Invalid source manifest".into());
399 }
400 Ok(())
401}
402
403pub(crate) fn archived_manifest(
407 manifest: InputManifest,
408 evidence_digest: String,
409) -> Result<RunManifest, String> {
410 validate_manifest(&manifest)?;
411 Ok(RunManifest {
412 manifest,
413 evidence_digest,
414 statement_exclusions: Vec::new(),
415 })
416}
417fn write_json(
418 root: &Path,
419 run: &StoredRun,
420 name: &str,
421 value: &impl serde::Serialize,
422) -> Result<(), String> {
423 let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
424 bytes.push(b'\n');
425 atomic_write(root, &run.directory.join(name), &bytes).map_err(|e| e.to_string())
426}
427pub(crate) fn prepare_publication_with(
434 root: &Path,
435 directory: &Path,
436 metadata: &RunMetadata,
437 analysed: Option<&CoverageReport>,
438 archived: Option<RunManifest>,
439) -> Result<(), String> {
440 if metadata.merged == Some(true) {
441 return Ok(());
442 }
443 let run = StoredRun {
444 id: metadata.id.clone(),
445 directory: directory.into(),
446 evidence_path: directory.join("evidence.raw.gz"),
447 metadata_path: directory.join("run.json"),
448 query_index_path: directory.join(crate::run_store::RUST_QUERY_INDEX_FILE),
449 metadata: metadata.clone(),
450 };
451 let input = match archived {
452 Some(input) => input,
453 None => match load_optional_manifest(&run)? {
454 Some(input) => input,
455 None => return Ok(()),
456 },
457 };
458 let current = crate::source_capture::current_sources(root, &input.manifest);
459 let owned;
460 let analysed = match analysed {
461 Some(report) => Some(report),
462 None => {
463 owned = coverage(&run).ok();
464 owned.as_ref()
465 }
466 };
467 if let Some(executions) = analysed.and_then(|report| {
470 executions(
471 report,
472 &input.manifest,
473 current.as_ref().ok().map(|inputs| &inputs.files),
474 )
475 }) {
476 write_json(root, &run, EXECUTIONS_FILE, &executions)?;
477 }
478 let _ = write_json(
480 root,
481 &run,
482 MANIFEST_CACHE_FILE,
483 &ManifestCache {
484 evidence_digest: input.evidence_digest.clone(),
485 manifest: input.manifest.clone(),
486 statement_exclusions: input.statement_exclusions.clone(),
487 },
488 );
489 Ok(())
490}
491pub fn executions(
497 coverage: &CoverageReport,
498 manifest: &InputManifest,
499 sources: Option<&Files>,
500) -> Option<Executions> {
501 let mut located: crate::interned::FastMap<&str, (usize, usize)> = Default::default();
505 let mut files: Vec<&str> = Vec::new();
506 let mut file_indexes: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
507 let mut probed: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
508 for point in &coverage.view.points {
509 let meta = &point.meta;
510 let Some(code) = manifest
511 .files
512 .get(&meta.file)
513 .and_then(|fingerprint| fingerprint.code.as_ref())
514 else {
515 continue;
516 };
517 let column = if manifest.language == "javascript" {
518 let source = sources?.get(&meta.file)?;
519 let Some(column) = byte_column(source, meta.line, meta.column, &manifest.language)
520 else {
521 continue;
522 };
523 column
524 } else {
525 meta.column + 1
526 };
527 let unit = code.unit_at(meta.line, column);
528 let file = *file_indexes.entry(meta.file.as_str()).or_insert_with(|| {
529 files.push(meta.file.as_str());
530 files.len() - 1
531 });
532 located.insert(meta.id.as_str(), (file, unit));
533 if code.units[unit].is_code() {
534 probed.entry(meta.file.clone()).or_default().insert(unit);
535 }
536 }
537 let mut covered_by_file = vec![Vec::new(); files.len()];
542 for test in &coverage.view.tests {
543 for hit in &test.hits {
544 if let Some((file, unit)) = located.get(hit.as_str()) {
545 covered_by_file[*file].push(*unit);
546 }
547 }
548 }
549 let covered = files
550 .iter()
551 .zip(covered_by_file)
552 .filter(|(_, units)| !units.is_empty())
553 .map(|(file, units)| {
554 (
555 (*file).to_owned(),
556 units.into_iter().collect::<BTreeSet<_>>(),
557 )
558 })
559 .collect::<BTreeMap<_, _>>();
560
561 let mut tests: BTreeMap<TestSelector, Execution> = BTreeMap::new();
562 for test in &coverage.view.tests {
563 let Some(file) = &test.file else {
564 continue;
565 };
566 let selector = TestSelector {
567 file: file.clone(),
568 name: test.name.clone(),
569 };
570 let record = tests.entry(selector.clone()).or_insert_with(|| Execution {
571 test: selector,
572 passed: false,
573 files: BTreeMap::new(),
574 attribution: crate::coverage_report::ATTRIBUTION_EXACT.to_owned(),
575 });
576 record.passed |= test.outcome == "passed";
577 if !crate::coverage_report::coverage_is_complete(&record.attribution) {
581 } else if !crate::coverage_report::coverage_is_complete(&test.attribution) {
583 record.attribution = test.attribution.clone();
584 }
585 let mut reached = test
586 .hits
587 .iter()
588 .filter_map(|hit| located.get(hit.as_str()).copied())
589 .collect::<Vec<_>>();
590 reached.sort_unstable();
591 for group in reached.chunk_by(|left, right| left.0 == right.0) {
592 record
593 .files
594 .entry(files[group[0].0].to_owned())
595 .or_default()
596 .extend(group.iter().map(|(_, unit)| *unit));
597 }
598 }
599 let mut tests = tests.into_values().collect::<Vec<_>>();
600 for record in &mut tests {
601 for units in record.files.values_mut() {
602 units.sort_unstable();
603 units.dedup();
604 }
605 }
606 Some(Executions {
607 tests,
608 probed: probed
609 .into_iter()
610 .map(|(file, units)| (file, units.into_iter().collect()))
611 .collect(),
612 covered: covered
613 .into_iter()
614 .map(|(file, units)| (file, units.into_iter().collect()))
615 .collect(),
616 })
617}
618pub fn test_files(run: &StoredRun) -> Option<BTreeSet<String>> {
630 let executions: Executions =
631 serde_json::from_slice(&fs::read(run.directory.join(EXECUTIONS_FILE)).ok()?).ok()?;
632 Some(
633 executions
634 .tests
635 .into_iter()
636 .map(|record| record.test.file)
637 .collect(),
638 )
639}
640
641pub fn changed_files(recorded: &FileManifest, now: impl Fn(&str) -> Option<String>) -> Vec<String> {
643 recorded
644 .iter()
645 .filter(|(file, was)| now(file).as_deref() != Some(was.sha256.as_str()))
646 .map(|(file, _)| file.clone())
647 .collect()
648}
649
650fn affected_tests_of_merge(root: &Path, run: &StoredRun) -> Result<Value, String> {
655 let parents = run
656 .metadata
657 .parents
658 .as_deref()
659 .filter(|parents| !parents.is_empty())
660 .ok_or("This merged run does not say which runs it was merged from")?;
661 let inventory = crate::run_store::discover_runs(root).map_err(|error| error.to_string())?;
662 const BUCKETS: [&str; 3] = ["affected", "undetermined", "unaffected"];
665 let mut tests = BTreeMap::<(String, String), (usize, Value)>::new();
666 let mut changed = BTreeMap::<String, Value>::new();
667 let mut meaning = Value::Null;
668 for parent in parents {
669 let Some(stored) = inventory.runs.iter().find(|stored| stored.id == *parent) else {
670 return Err(format!(
671 "Run {} was merged from {parent}, which is no longer stored, and a merge is answered from the runs it was merged from. Ask the others, or measure the suite again",
672 run.id
673 ));
674 };
675 let report = affected_tests(root, stored)?;
676 for (rank, bucket) in BUCKETS.iter().enumerate() {
677 for test in report[*bucket].as_array().into_iter().flatten() {
678 let key = (
679 test["file"].as_str().unwrap_or_default().to_owned(),
680 test["name"].as_str().unwrap_or_default().to_owned(),
681 );
682 match tests.get_mut(&key) {
683 Some((held, _)) if *held < rank => {}
684 Some((held, entry)) if *held == rank => {
685 let mut reasons = entry["reasons"].as_array().cloned().unwrap_or_default();
686 for reason in test["reasons"].as_array().into_iter().flatten() {
687 if !reasons.contains(reason) {
688 reasons.push(reason.clone());
689 }
690 }
691 entry["reasons"] = Value::Array(reasons);
692 }
693 _ => {
694 tests.insert(key, (rank, test.clone()));
695 }
696 }
697 }
698 }
699 for file in report["changedFiles"].as_array().into_iter().flatten() {
700 if let Some(name) = file["file"].as_str() {
701 changed
702 .entry(name.to_owned())
703 .or_insert_with(|| file.clone());
704 }
705 }
706 if meaning.is_null() {
707 meaning = report["meaning"].clone();
708 }
709 }
710 let mut buckets = BUCKETS.map(|_| Vec::new());
711 for (rank, test) in tests.into_values() {
712 buckets[rank].push(test);
713 }
714 for bucket in &mut buckets[..2] {
716 bucket.sort_by_key(|entry| std::cmp::Reverse(entry["nameMatch"].as_u64().unwrap_or(0)));
717 }
718 let [affected, undetermined, unaffected] = buckets;
719 let total = affected.len() + undetermined.len() + unaffected.len();
720 Ok(json!({
721 "run": run.id,
722 "mergedFrom": parents,
723 "summary": {
724 "tests": total,
725 "affected": affected.len(),
726 "undetermined": undetermined.len(),
727 "unaffected": unaffected.len(),
728 "changedFiles": changed.len(),
729 },
730 "affected": affected,
731 "undetermined": undetermined,
732 "unaffected": unaffected,
733 "changedFiles": changed.into_values().collect::<Vec<_>>(),
734 "meaning": meaning,
735 }))
736}
737
738pub fn affected_tests(root: &Path, run: &StoredRun) -> Result<Value, String> {
739 if run.metadata.merged == Some(true) {
743 return affected_tests_of_merge(root, run);
744 }
745 let stored = load_manifest(run)?;
746 let executions: Executions = fs::read(run.directory.join(EXECUTIONS_FILE))
747 .ok()
748 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
749 .ok_or(
750 "This run has no per-test execution record; rerun tests with this version of Supercov",
751 )?;
752 let executions = &executions;
753 let root = crate::workspace::canonicalize_simplified(root).map_err(|e| e.to_string())?;
754 let manifest = &stored.manifest;
755 let now = manifest
756 .files
757 .keys()
758 .filter_map(|file| {
759 let path = root.join(file);
760 let canonical = crate::workspace::canonicalize_simplified(&path).ok()?;
761 if !canonical.starts_with(&root) || !canonical.is_file() {
762 return None;
763 }
764 let text = fs::read_to_string(canonical).ok()?;
765 Some((file.clone(), FileFingerprint::read(file, &text)))
766 })
767 .collect::<FileManifest>();
768 let changes = manifest
769 .files
770 .keys()
771 .filter_map(|file| {
772 file_change(
773 manifest.files.get(file),
774 now.get(file),
775 executions.probed.get(file).map(Vec::as_slice),
776 )
777 .map(|change| (file.as_str(), change))
778 })
779 .collect::<BTreeMap<_, _>>();
780 let files = changes
781 .iter()
782 .filter_map(|(file, change)| {
783 let (kind, detail) = match change {
784 FileChange::Same | FileChange::Added => return None,
785 FileChange::CommentsOnly => ("formatting", None),
786 FileChange::Removed => ("removed", None),
787 FileChange::Bytes => ("changed", None),
788 FileChange::Code {
789 before,
790 after,
791 diff,
792 narrow,
793 } => (
794 if *narrow { "bodies" } else { "declarations" },
795 Some(describe(before, after, diff)),
796 ),
797 };
798 Some(json!({"file":file,"change":kind,"detail":detail}))
799 })
800 .collect::<Vec<_>>();
801 let beyond_the_run = changes
815 .iter()
816 .filter_map(|(file, change)| match change {
817 FileChange::Same | FileChange::CommentsOnly | FileChange::Added => None,
818 FileChange::Removed => Some(format!("{file} removed (the run covered code in it)")),
819 FileChange::Bytes => Some(format!("{file} changed (the run covered code in it)")),
820 FileChange::Code { before, diff, .. } => {
821 let code = manifest.files.get(*file).and_then(|f| f.code.as_ref());
826 let reached = executions
827 .covered
828 .get(*file)
829 .map(Vec::as_slice)
830 .unwrap_or_default()
831 .iter()
832 .flat_map(|unit| match code {
833 Some(code) if *unit < code.units.len() => {
834 code.ancestors(*unit).collect::<Vec<_>>()
835 }
836 _ => vec![*unit],
837 })
838 .collect::<BTreeSet<_>>();
839 let hit = diff
840 .changed
841 .iter()
842 .filter(|unit| reached.contains(unit))
843 .filter_map(|unit| before.units.get(*unit))
844 .collect::<Vec<_>>();
845 (!hit.is_empty())
846 .then(|| format!("{file}: {} changed (the run covered it)", named(hit)))
847 }
848 })
849 .collect::<Vec<_>>();
850
851 let own_tests = executions
865 .tests
866 .iter()
867 .map(|record| record.test.file.clone())
868 .collect::<BTreeSet<_>>();
869 let known_tests = crate::run_store::discover_runs(&root)
870 .map(|inventory| {
871 inventory
872 .runs
873 .iter()
874 .filter_map(test_files)
875 .flatten()
876 .collect::<BTreeSet<_>>()
877 })
878 .unwrap_or_default()
879 .union(&own_tests)
880 .cloned()
881 .collect::<BTreeSet<_>>();
882 let measured = read_archive_selected(&run.evidence_path, |path| path == "manifest.json")
887 .ok()
888 .and_then(|entries| entries.into_iter().next())
889 .and_then(|entry| serde_json::from_slice::<Value>(&entry.contents).ok())
890 .and_then(|manifest| {
891 Some(
892 manifest["scope"]["entries"]
893 .as_array()?
894 .iter()
895 .filter(|entry| entry["status"] == "included")
896 .filter_map(|entry| entry["file"].as_str().map(str::to_owned))
897 .collect::<BTreeSet<_>>(),
898 )
899 });
900 let helper = |file: &str| match &measured {
901 Some(measured) => !measured.contains(file),
902 None => crate::run_currency::beside_the_tests(file),
903 };
904 let helpers = changes
905 .iter()
906 .filter(|(file, change)| {
907 !matches!(
908 change,
909 FileChange::Same | FileChange::CommentsOnly | FileChange::Added
910 ) && !known_tests.contains(**file)
911 && helper(file)
912 && !crate::run_currency::configuration_file(file)
913 && !crate::run_currency::dependency_file(file)
914 })
915 .filter(|(file, change)| match change {
917 FileChange::Code { before, .. } => fs::read_to_string(root.join(file))
918 .ok()
919 .and_then(|text| crate::source_units::only_added(before, file, &text))
920 .is_none(),
921 _ => true,
922 })
923 .map(|(file, change)| {
924 let what = match change {
925 FileChange::Code {
926 before,
927 after,
928 diff,
929 ..
930 } => {
931 format!("{file} changed: {}", describe(before, after, diff))
932 }
933 FileChange::Removed => format!("{file} removed"),
934 _ => format!("{file} changed"),
935 };
936 (*file, what)
937 })
938 .collect::<Vec<_>>();
939 let checkout =
940 (!helpers.is_empty()).then(|| crate::run_currency::Checkout::new(&root, &known_tests));
941 let ran = (!helpers.is_empty()).then(|| crate::run_currency::Ran::of(run));
942 let configurations = manifest
943 .files
944 .keys()
945 .filter(|file| crate::run_currency::configuration_file(file))
946 .cloned()
947 .collect::<Vec<_>>();
948 let mut loads_of = BTreeMap::<String, Option<crate::test_loads::Loads>>::new();
949 let named_by_tests = named_by_tests(&changes);
950 let mut test_sources = BTreeMap::<&str, Option<String>>::new();
951
952 let mut affected = Vec::new();
953 let mut undetermined = Vec::new();
954 let mut unaffected = Vec::new();
955 for record in &executions.tests {
956 let mut reasons = Vec::new();
957 let mut changed_code = Vec::new();
962 if !record.passed {
963 reasons.push("did not pass in the run".to_owned());
964 }
965 match changes.get(record.test.file.as_str()) {
966 None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
967 Some(FileChange::Removed) => reasons.push("test file removed".to_owned()),
968 Some(FileChange::Bytes) => reasons.push("test file changed".to_owned()),
969 Some(FileChange::Code {
970 before,
971 after,
972 diff,
973 ..
974 }) => reasons.push(format!(
975 "test file changed: {}",
976 describe(before, after, diff)
977 )),
978 }
979 for (file, units) in &record.files {
980 let code = manifest.files.get(file).and_then(|f| f.code.as_ref());
981 let ran = units
982 .iter()
983 .flat_map(|unit| match code {
984 Some(code) if *unit < code.units.len() => {
985 code.ancestors(*unit).collect::<Vec<_>>()
986 }
987 _ => vec![*unit],
988 })
989 .collect::<BTreeSet<_>>();
990 match changes.get(file.as_str()) {
991 None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
992 Some(FileChange::Removed) => {
993 reasons.push(format!("{file} removed (this test ran code in it)"));
994 }
995 Some(FileChange::Bytes) => {
996 reasons.push(format!("{file} changed (this test ran code in it)"));
997 }
998 Some(FileChange::Code {
999 before,
1000 after,
1001 diff,
1002 narrow,
1003 }) => {
1004 if *narrow {
1005 let hit = diff
1006 .changed
1007 .iter()
1008 .filter(|i| ran.contains(i))
1009 .map(|i| &before.units[*i])
1010 .collect::<Vec<_>>();
1011 let now = after.by_path();
1012 for index in diff.changed.iter().filter(|i| ran.contains(i)) {
1013 let unit = &before.units[*index];
1014 changed_code.push(json!({
1015 "file": file,
1016 "declaration": unit.path,
1017 "own": own_lines(before, *index),
1018 "now": now.get(unit.path.as_str()).map(|u| [u.line, u.end_line]),
1019 }));
1020 }
1021 if !hit.is_empty() {
1022 reasons
1023 .push(format!("{file}: {} changed (this test ran it)", named(hit)));
1024 }
1025 } else {
1026 reasons.push(format!(
1027 "{file}: {} changed (this test ran code in this file)",
1028 describe(before, after, diff)
1029 ));
1030 }
1031 }
1032 }
1033 }
1034 if let (Some(checkout), Some(ran)) = (&checkout, &ran) {
1035 let loads = loads_of.entry(record.test.file.clone()).or_insert_with(|| {
1036 checkout.loads(
1037 &BTreeSet::from([record.test.file.clone()]),
1038 ran.runners.as_ref(),
1039 &configurations,
1040 )
1041 });
1042 for (file, what) in &helpers {
1043 let served_by = ran.support.as_ref().and_then(|support| {
1046 let tests = support.files.get(*file)?;
1047 Some(tests.iter().any(|test| {
1048 support
1049 .names
1050 .get(test)
1051 .is_some_and(|name| *name == record.test.name)
1052 }))
1053 });
1054 let imported = matches!(loads, Some(loads) if loads.open.is_none() && loads.files.contains(*file));
1055 if let Some(served) = served_by
1056 && !imported
1057 {
1058 if served {
1059 reasons.push(format!("{what} (a server that served this test loaded it)"));
1060 }
1061 continue;
1062 }
1063 let loaded = match loads {
1064 Some(loads) if loads.open.is_none() => {
1065 loads.files.contains(*file) || !checkout.places(file)
1066 }
1067 _ => true,
1068 };
1069 if loaded {
1070 reasons.push(match loads {
1071 Some(loads) if loads.open.is_none() && loads.files.contains(*file) => {
1072 format!("{what} (this test's file loads it)")
1073 }
1074 _ => format!("{what} (a file tests may load)"),
1075 });
1076 }
1077 }
1078 }
1079 for (file, names) in &named_by_tests {
1080 if record.files.contains_key(*file) || *file == record.test.file.as_str() {
1081 continue; }
1083 let source = test_sources
1084 .entry(record.test.file.as_str())
1085 .or_insert_with(|| fs::read_to_string(root.join(&record.test.file)).ok());
1086 if let Some(name) = source
1087 .as_deref()
1088 .and_then(|text| names.iter().find(|name| names_word(text, name)))
1089 {
1090 reasons.push(format!(
1091 "{file}: declarations changed, and the test file names {name} (code that runs once per process is credited to the first test to reach it)"
1092 ));
1093 }
1094 }
1095 if !crate::coverage_report::coverage_is_complete(&record.attribution) {
1096 let proven = !reasons.is_empty();
1100 if !proven {
1104 reasons.extend(beyond_the_run.iter().cloned());
1105 reasons.sort();
1106 reasons.dedup();
1107 }
1108 let entry = json!({
1109 "file": record.test.file,
1110 "name": record.test.name,
1111 "reasons": reasons,
1112 "attribution": record.attribution,
1113 });
1114 if proven {
1115 affected.push(entry);
1116 } else if reasons.is_empty() {
1117 unaffected.push(entry);
1118 } else {
1119 undetermined.push(entry);
1120 }
1121 continue;
1122 }
1123 let entry = json!({"file":record.test.file,"name":record.test.name,"reasons":reasons,"changedCode":changed_code});
1124 if reasons.is_empty() {
1125 unaffected.push(entry);
1126 } else {
1127 affected.push(entry);
1128 }
1129 }
1130 let changed_words = changes
1136 .iter()
1137 .filter(|(_, change)| !matches!(change, FileChange::Same | FileChange::CommentsOnly))
1138 .flat_map(|(file, _)| words_of(file))
1139 .collect::<BTreeSet<_>>();
1140 for bucket in [&mut affected, &mut undetermined] {
1141 for entry in bucket.iter_mut() {
1142 entry["nameMatch"] = json!(name_match(entry, &changed_words));
1143 }
1144 bucket.sort_by_key(|entry| std::cmp::Reverse(entry["nameMatch"].as_u64().unwrap_or(0)));
1145 }
1146 Ok(json!({
1147 "run": run.id,
1148 "affected": affected,
1149 "undetermined": undetermined,
1156 "unaffected": unaffected,
1157 "changedFiles": files,
1158 "summary": {
1159 "tests": executions.tests.len(),
1160 "affected": affected.len(),
1161 "undetermined": undetermined.len(),
1162 "unaffected": unaffected.len(),
1163 "changedFiles": files.len(),
1164 },
1165 "meaning": "Tests whose recorded execution a change since the run could have reached, and those whose execution nothing recorded: a test that ran alongside others has no coverage of its own, so it is undetermined whenever a change reaches anything the run covered. Run both. A file the run never captured, a dependency or a configuration change is not seen here; see workingTree."
1166 }))
1167}
1168fn named_by_tests<'a>(
1171 changes: &BTreeMap<&'a str, FileChange<'_>>,
1172) -> BTreeMap<&'a str, BTreeSet<String>> {
1173 changes
1174 .iter()
1175 .filter(|(file, _)| {
1176 [".java", ".kt", ".kts", ".groovy", ".scala"]
1177 .iter()
1178 .any(|extension| file.ends_with(extension))
1179 })
1180 .filter_map(|(file, change)| match change {
1181 FileChange::Code {
1182 before,
1183 narrow: false,
1184 ..
1185 } => {
1186 let stem = file.rsplit('/').next()?.split('.').next()?.to_owned();
1187 let mut names = before
1188 .units
1189 .iter()
1190 .filter(|unit| unit.parent.is_none_or(|p| before.units[p].path.is_empty()))
1191 .filter_map(|unit| unit.path.rsplit('.').next().map(str::to_owned))
1192 .filter(|name| name.len() > 2)
1193 .collect::<BTreeSet<_>>();
1194 if stem.len() > 2
1195 && !matches!(stem.as_str(), "mod" | "lib" | "main" | "index" | "__init__")
1196 {
1197 names.insert(stem);
1198 }
1199 Some((*file, names))
1200 }
1201 _ => None,
1202 })
1203 .collect::<BTreeMap<_, _>>()
1204}
1205
1206fn words_of(path: &str) -> BTreeSet<String> {
1210 let stem = path.rsplit(['/', '\\']).next().unwrap_or(path);
1211 let stem = stem.split('.').next().unwrap_or(stem);
1212 split_words(stem)
1213 .into_iter()
1214 .filter(|w| !matches!(w.as_str(), "test" | "tests" | "spec" | "specs"))
1215 .collect()
1216}
1217
1218fn split_words(text: &str) -> Vec<String> {
1221 let mut words = Vec::new();
1222 let mut current = String::new();
1223 let mut previous_lower = false;
1224 for c in text.chars() {
1225 if !c.is_alphanumeric() {
1226 if !current.is_empty() {
1227 words.push(std::mem::take(&mut current));
1228 }
1229 previous_lower = false;
1230 continue;
1231 }
1232 if c.is_uppercase() && previous_lower && !current.is_empty() {
1233 words.push(std::mem::take(&mut current));
1234 }
1235 previous_lower = c.is_lowercase() || c.is_ascii_digit();
1236 current.extend(c.to_lowercase());
1237 }
1238 if !current.is_empty() {
1239 words.push(current);
1240 }
1241 words.into_iter().filter(|w| w.len() > 1).collect()
1242}
1243
1244fn name_match(entry: &Value, changed: &BTreeSet<String>) -> usize {
1246 let file = entry["file"].as_str().unwrap_or("");
1247 let name = entry["name"].as_str().unwrap_or("");
1248 let own = name
1251 .rsplit(['#', ':', '>', '.', '/'])
1252 .next()
1253 .unwrap_or(name)
1254 .split(['[', '('])
1255 .next()
1256 .unwrap_or("");
1257 let mut words = words_of(file);
1258 words.extend(split_words(own));
1259 words.intersection(changed).count()
1260}
1261
1262fn names_word(text: &str, word: &str) -> bool {
1264 text.match_indices(word).any(|(at, _)| {
1265 let before = text[..at].chars().last();
1266 let after = text[at + word.len()..].chars().next();
1267 let part = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
1268 !part(before) && !part(after)
1269 })
1270}
1271
1272fn own_lines(code: &crate::source_units::Code, index: usize) -> Vec<[usize; 2]> {
1275 let unit = &code.units[index];
1276 let mut children = code
1277 .units
1278 .iter()
1279 .filter(|u| u.parent == Some(index))
1280 .map(|u| (u.line, u.end_line))
1281 .collect::<Vec<_>>();
1282 children.sort_unstable();
1283 let mut out = Vec::new();
1284 let mut next = unit.line;
1285 for (start, end) in children {
1286 if start > next {
1287 out.push([next, start - 1]);
1288 }
1289 next = next.max(end + 1);
1290 }
1291 if next <= unit.end_line {
1292 out.push([next, unit.end_line]);
1293 }
1294 out
1295}
1296
1297pub fn coverage(run: &StoredRun) -> Result<CoverageReport, String> {
1298 analyze_coverage_archive(&ArchiveReportRequest {
1299 archive_path: run.evidence_path.clone(),
1300 run_id: run.id.clone(),
1301 generated_at: run.metadata.started_at.clone(),
1302 integrity: None,
1303 test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
1304 })
1305 .map_err(|e| format!("{e:?}"))
1306}
1307fn byte_column(source: &str, line: usize, column: usize, language: &str) -> Option<usize> {
1308 byte_column_in(source, &line_starts(source), line, column, language)
1309}
1310
1311fn byte_column_in(
1314 source: &str,
1315 starts: &[usize],
1316 line: usize,
1317 column: usize,
1318 language: &str,
1319) -> Option<usize> {
1320 if line == 0 {
1321 return None;
1322 }
1323 let line = line_text(source, starts, line)?;
1324 if language != "javascript" {
1325 return column.checked_add(1);
1327 }
1328 if column == 0 {
1329 return None;
1330 }
1331 let mut units = 0;
1332 for (byte, ch) in line.char_indices() {
1333 if units == column - 1 {
1334 return Some(byte + 1);
1335 }
1336 units += ch.len_utf16();
1337 }
1338 (units == column - 1).then_some(line.len() + 1)
1339}
1340
1341#[cfg(test)]
1342mod tests {
1343 use super::*;
1344 use crate::evidence_archive::{read_archive, write_archive};
1345 use crate::run_store::discover_runs;
1346
1347 fn prepare_publication(
1348 root: &Path,
1349 directory: &Path,
1350 metadata: &RunMetadata,
1351 analysed: Option<&CoverageReport>,
1352 ) -> Result<(), String> {
1353 prepare_publication_with(root, directory, metadata, analysed, None)
1354 }
1355
1356 fn recorded(run: &StoredRun) -> Executions {
1357 serde_json::from_slice(&fs::read(run.directory.join(EXECUTIONS_FILE)).expect("a record"))
1358 .unwrap()
1359 }
1360
1361 #[test]
1362 fn a_merged_run_has_the_manifest_its_parents_share() {
1363 assert_eq!(unsharded("source-inputs.json"), "source-inputs.json");
1366 assert_eq!(
1367 unsharded("shards/0/source-inputs.json"),
1368 "source-inputs.json"
1369 );
1370 assert_eq!(
1371 unsharded("shards/12/shards/1/source-inputs.json"),
1372 "source-inputs.json"
1373 );
1374 assert_eq!(
1375 unsharded("shards/x/source-inputs.json"),
1376 "shards/x/source-inputs.json"
1377 );
1378 assert_eq!(
1379 unsharded("shards/source-inputs.json"),
1380 "shards/source-inputs.json"
1381 );
1382
1383 let entry = |path: &str, contents: &str| crate::evidence_archive::EvidenceArchiveEntry {
1384 path: path.into(),
1385 contents: contents.as_bytes().to_vec(),
1386 };
1387 let named = |path: &str| unsharded(path) == ARCHIVE_PATH;
1388 let same = [
1390 entry("shards/0/source-inputs.json", r#"{"files":{"a.js":1}}"#),
1391 entry("shards/1/source-inputs.json", r#"{"files":{"a.js":1}}"#),
1392 entry("shards/2/source-inputs.json", r#"{ "files": {"a.js": 1} }"#),
1393 entry("shards/0/tests.jsonl", "not read"),
1394 ];
1395 assert_eq!(
1396 the_one(&same, named).unwrap(),
1397 Some(json!({"files": {"a.js": 1}}))
1398 );
1399 let different = [
1401 entry("shards/0/source-inputs.json", r#"{"files":{"a.js":1}}"#),
1402 entry("shards/1/source-inputs.json", r#"{"files":{"a.js":2}}"#),
1403 ];
1404 assert!(
1405 the_one(&different, named)
1406 .unwrap_err()
1407 .contains("different source manifests")
1408 );
1409 assert_eq!(the_one(&[], named).unwrap(), None);
1410 }
1411
1412 fn reference_byte_column(
1414 source: &str,
1415 line: usize,
1416 column: usize,
1417 language: &str,
1418 ) -> Option<usize> {
1419 if line == 0 {
1420 return None;
1421 }
1422 let line = source.lines().nth(line - 1)?;
1423 if language != "javascript" {
1424 return column.checked_add(1);
1425 }
1426 if column == 0 {
1427 return None;
1428 }
1429 let mut units = 0;
1430 for (byte, ch) in line.char_indices() {
1431 if units == column - 1 {
1432 return Some(byte + 1);
1433 }
1434 units += ch.len_utf16();
1435 }
1436 (units == column - 1).then_some(line.len() + 1)
1437 }
1438
1439 #[test]
1440 fn a_column_from_line_starts_agrees_with_walking_the_file() {
1441 for source in [
1444 "",
1445 "a",
1446 "a\n",
1447 "a\r\nb\r\n",
1448 "x\r",
1449 "héllo\nwörld\n",
1450 "😀x\ny😀z\n",
1451 "const a = 1;\n b();\n",
1452 ] {
1453 let starts = line_starts(source);
1454 for language in ["javascript", "python"] {
1455 for line in 0..source.len() + 3 {
1456 for column in 0..source.len() + 4 {
1457 assert_eq!(
1458 byte_column_in(source, &starts, line, column, language),
1459 reference_byte_column(source, line, column, language),
1460 "{language} {line}:{column} in {source:?}"
1461 );
1462 }
1463 }
1464 }
1465 }
1466 }
1467
1468 #[test]
1469 fn publication_records_what_each_test_ran_and_affected_tests_reads_it() {
1470 let root = std::env::temp_dir().join(format!("supercov-affected-{}", std::process::id()));
1471 let _ = fs::remove_dir_all(&root);
1472 fs::create_dir_all(root.join("src")).unwrap();
1473 fs::create_dir_all(root.join("tests")).unwrap();
1474 let app = "function work() {\n return 1;\n}\nfunction idle() {\n return 2;\n}\n";
1478 let test = "import assert from 'node:assert/strict';\nimport { LIMIT } from '../src/config.js';\nassert.equal(work(), 1);\n";
1479 let config = "export const LIMIT = 5;\nfunction check(n) {\n return n < LIMIT;\n}\n";
1480 fs::write(root.join("src/app.js"), app).unwrap();
1481 fs::write(root.join("src/config.js"), config).unwrap();
1482 fs::write(root.join("tests/app.test.js"), test).unwrap();
1483 let inputs = crate::source_capture::capture(
1487 &root,
1488 "python",
1489 [
1490 "src/app.js".into(),
1491 "src/config.js".into(),
1492 "tests/app.test.js".into(),
1493 ],
1494 )
1495 .unwrap();
1496 let directory = crate::run_store::create_analyzable_test_run(&root, "first");
1497 let path = directory.join("evidence.raw.gz");
1498 let entries = crate::source_capture::append(read_archive(&path).unwrap(), &inputs).unwrap();
1499 let archive = write_archive(entries, &path).unwrap();
1500 let metadata_path = directory.join("run.json");
1501 let mut metadata: RunMetadata =
1502 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1503 metadata.raw_evidence.files = archive.files;
1504 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1505 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1506 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1507 prepare_publication(&root, &directory, &metadata, None).unwrap();
1508 let run = discover_runs(&root).unwrap().runs.remove(0);
1509 let stored = load_manifest(&run).unwrap();
1510 let executions = &recorded(&run);
1511 let code = stored.manifest.files["src/app.js"].code.as_ref().unwrap();
1512 let work = code.units.iter().position(|u| u.path == "work").unwrap();
1513 let idle = code.units.iter().position(|u| u.path == "idle").unwrap();
1514 assert_eq!(executions.tests.len(), 1);
1515 let record = &executions.tests[0];
1516 assert_eq!(record.test.file, "tests/app.test.js");
1517 assert_eq!(record.test.name, "test");
1518 assert!(record.passed);
1519 assert_eq!(record.files["src/app.js"], vec![work]);
1520 assert_eq!(
1521 executions.probed["src/app.js"],
1522 vec![work],
1523 "idle holds no probe in this run"
1524 );
1525 let _ = idle;
1526
1527 let names = |value: &Value, key: &str| {
1528 value[key]
1529 .as_array()
1530 .unwrap()
1531 .iter()
1532 .map(|t| t["name"].as_str().unwrap().to_owned())
1533 .collect::<Vec<_>>()
1534 };
1535 let report = affected_tests(&root, &run).unwrap();
1537 assert!(names(&report, "affected").is_empty(), "{report}");
1538 assert_eq!(names(&report, "unaffected"), ["test"]);
1539 assert_eq!(report["summary"]["changedFiles"], 0);
1540 fs::write(root.join("src/app.js"), format!("// about\n{app}")).unwrap();
1542 let report = affected_tests(&root, &run).unwrap();
1543 assert!(names(&report, "affected").is_empty(), "{report}");
1544 assert_eq!(report["changedFiles"][0]["change"], "formatting");
1545 fs::write(
1547 root.join("src/app.js"),
1548 app.replace("return 1;", "return 1 + 0;"),
1549 )
1550 .unwrap();
1551 let report = affected_tests(&root, &run).unwrap();
1552 assert_eq!(names(&report, "affected"), ["test"]);
1553 assert_eq!(
1554 report["affected"][0]["reasons"][0],
1555 "src/app.js: work (line 1) changed (this test ran it)"
1556 );
1557 fs::write(
1561 root.join("src/app.js"),
1562 app.replace("return 2;", "return 2 + 0;"),
1563 )
1564 .unwrap();
1565 let report = affected_tests(&root, &run).unwrap();
1566 assert_eq!(names(&report, "affected"), ["test"], "{report}");
1567 assert!(
1568 report["affected"][0]["reasons"][0]
1569 .as_str()
1570 .unwrap()
1571 .contains("this test ran code in this file"),
1572 "{report}"
1573 );
1574 fs::write(
1576 root.join("src/app.js"),
1577 format!("{app}function more() {{}}\n"),
1578 )
1579 .unwrap();
1580 let report = affected_tests(&root, &run).unwrap();
1581 assert_eq!(names(&report, "affected"), ["test"]);
1582 assert_eq!(report["changedFiles"][0]["change"], "declarations");
1583 fs::write(root.join("src/app.js"), app).unwrap();
1585 fs::write(root.join("tests/app.test.js"), test.replace("1)", "2)")).unwrap();
1586 let report = affected_tests(&root, &run).unwrap();
1587 assert_eq!(names(&report, "affected"), ["test"]);
1588 assert!(
1589 report["affected"][0]["reasons"][0]
1590 .as_str()
1591 .unwrap()
1592 .starts_with("test file changed"),
1593 "{report}"
1594 );
1595 fs::write(root.join("tests/app.test.js"), test).unwrap();
1599 fs::write(root.join("src/config.js"), config.replace("= 5", "= 6")).unwrap();
1600 let report = affected_tests(&root, &run).unwrap();
1601 assert!(names(&report, "affected").is_empty(), "{report}");
1602 fs::write(root.join("src/config.js"), config).unwrap();
1603 fs::remove_file(root.join("src/app.js")).unwrap();
1605 let report = affected_tests(&root, &run).unwrap();
1606 assert_eq!(
1607 report["affected"][0]["reasons"][0],
1608 "src/app.js removed (this test ran code in it)"
1609 );
1610 fs::remove_dir_all(root).unwrap();
1611 }
1612
1613 #[test]
1616 fn a_test_named_like_the_changed_file_comes_first() {
1617 assert_eq!(
1618 words_of("h11/tests/test_receive_buffer.py"),
1619 BTreeSet::from(["receive".to_owned(), "buffer".to_owned()])
1620 );
1621 assert_eq!(
1622 words_of("src/test/java/org/apache/commons/cli/ConverterTests.java"),
1623 BTreeSet::from(["converter".to_owned()])
1624 );
1625 assert_eq!(
1626 split_words("parseHTTPVersion_v2"),
1627 ["parse", "httpversion", "v2"]
1628 );
1629 let changed = words_of("src/main/java/org/apache/commons/cli/Converter.java");
1630 let entry = |file: &str, name: &str| json!({"file": file, "name": name});
1631 assert_eq!(
1632 name_match(
1633 &entry(
1634 "src/test/java/x/ConverterTests.java",
1635 "x.ConverterTests#testClass()"
1636 ),
1637 &changed
1638 ),
1639 1
1640 );
1641 assert_eq!(
1642 name_match(
1643 &entry(
1644 "src/test/java/x/OptionTests.java",
1645 "x.OptionTests#testConverter()"
1646 ),
1647 &changed
1648 ),
1649 1,
1650 "the test's own name counts too"
1651 );
1652 assert_eq!(
1653 name_match(
1654 &entry(
1655 "src/test/java/x/OptionTests.java",
1656 "x.OptionTests#testBuilder()"
1657 ),
1658 &changed
1659 ),
1660 0
1661 );
1662 }
1663
1664 #[test]
1669 fn a_jvm_class_changed_in_its_declarations_is_reached_by_its_name() {
1670 let java = "package app;\nclass Converter {\n static final Object CLASS = null;\n static int size(int n) {\n return n;\n }\n}\n";
1671 let before = FileFingerprint::read("src/app/Converter.java", java);
1672 let after = FileFingerprint::read(
1673 "src/app/Converter.java",
1674 &java.replace("CLASS = null", "CLASS = new Object()"),
1675 );
1676 let js = "export const LIMIT = 5;\n";
1677 let js_before = FileFingerprint::read("src/config.js", js);
1678 let js_after = FileFingerprint::read("src/config.js", &js.replace('5', "6"));
1679 let changes = BTreeMap::from([
1680 (
1681 "src/app/Converter.java",
1682 file_change(Some(&before), Some(&after), Some(&[])).unwrap(),
1683 ),
1684 (
1685 "src/config.js",
1686 file_change(Some(&js_before), Some(&js_after), Some(&[])).unwrap(),
1687 ),
1688 ]);
1689 let named = named_by_tests(&changes);
1690 assert_eq!(
1691 named.get("src/app/Converter.java"),
1692 Some(&BTreeSet::from(["Converter".to_owned()]))
1693 );
1694 assert!(!named.contains_key("src/config.js"));
1695 assert!(names_word("assertEquals(Converter.CLASS, x)", "Converter"));
1696 assert!(!names_word("ConverterTests", "Converter"));
1697 }
1698
1699 #[test]
1700 fn a_test_that_ran_beside_others_is_undetermined_rather_than_unaffected() {
1701 let root = std::env::temp_dir().join(format!(
1708 "supercov-undetermined-{}-{}",
1709 std::process::id(),
1710 std::time::SystemTime::now()
1711 .duration_since(std::time::UNIX_EPOCH)
1712 .unwrap()
1713 .as_nanos()
1714 ));
1715 let _ = fs::remove_dir_all(&root);
1716 fs::create_dir_all(root.join("src")).unwrap();
1717 fs::create_dir_all(root.join("tests")).unwrap();
1718 let app = "function work() {\n return 1;\n}\nfunction idle() {\n return 2;\n}\n";
1719 let test = "import assert from 'node:assert/strict';\nimport { LIMIT } from '../src/config.js';\nassert.equal(work(), 1);\n";
1720 let config = "export const LIMIT = 5;\nfunction check(n) {\n return n < LIMIT;\n}\n";
1721 fs::write(root.join("src/app.js"), app).unwrap();
1722 fs::write(root.join("src/config.js"), config).unwrap();
1723 fs::write(root.join("tests/app.test.js"), test).unwrap();
1724 let inputs = crate::source_capture::capture(
1725 &root,
1726 "python",
1727 ["src/app.js".into(), "tests/app.test.js".into()],
1728 )
1729 .unwrap();
1730 let directory = crate::run_store::create_run_wide_test_run(&root, "parallel");
1731 let path = directory.join("evidence.raw.gz");
1732 let entries = crate::source_capture::append(read_archive(&path).unwrap(), &inputs).unwrap();
1733 let archive = write_archive(entries, &path).unwrap();
1734 let metadata_path = directory.join("run.json");
1735 let mut metadata: RunMetadata =
1736 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1737 metadata.raw_evidence.files = archive.files;
1738 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1739 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1740 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1741 prepare_publication(&root, &directory, &metadata, None).unwrap();
1742 let run = discover_runs(&root).unwrap().runs.remove(0);
1743
1744 let executions = &recorded(&run);
1745 assert_eq!(executions.tests.len(), 1, "the named test, not the record");
1746 let record = &executions.tests[0];
1747 assert_eq!(
1748 record.attribution,
1749 crate::coverage_report::ATTRIBUTION_RUN_WIDE
1750 );
1751 assert!(
1752 record.files.is_empty(),
1753 "it is credited with nothing: {:?}",
1754 record.files
1755 );
1756 assert!(
1757 !executions.covered["src/app.js"].is_empty(),
1758 "and the run is credited with what it reached"
1759 );
1760
1761 let bucket = |value: &Value, key: &str| {
1762 value[key]
1763 .as_array()
1764 .unwrap()
1765 .iter()
1766 .map(|t| t["name"].as_str().unwrap().to_owned())
1767 .collect::<Vec<_>>()
1768 };
1769
1770 let report = affected_tests(&root, &run).unwrap();
1773 assert_eq!(bucket(&report, "unaffected"), ["test"], "{report}");
1774 assert!(bucket(&report, "undetermined").is_empty(), "{report}");
1775
1776 fs::write(
1779 root.join("src/app.js"),
1780 app.replace("return 1;", "return 3;"),
1781 )
1782 .unwrap();
1783 let report = affected_tests(&root, &run).unwrap();
1784 assert!(
1785 bucket(&report, "affected").is_empty(),
1786 "nothing proves it ran the change: {report}"
1787 );
1788 assert_eq!(
1789 bucket(&report, "undetermined"),
1790 ["test"],
1791 "the run reached it, so this test might have: {report}"
1792 );
1793 assert_eq!(
1794 report["undetermined"][0]["attribution"],
1795 crate::coverage_report::ATTRIBUTION_RUN_WIDE,
1796 "{report}"
1797 );
1798 fs::remove_dir_all(root).unwrap();
1799 }
1800
1801 #[test]
1802 fn a_lower_bound_still_proves_what_it_recorded() {
1803 let root = std::env::temp_dir().join(format!(
1810 "supercov-partial-{}-{}",
1811 std::process::id(),
1812 std::time::SystemTime::now()
1813 .duration_since(std::time::UNIX_EPOCH)
1814 .unwrap()
1815 .as_nanos()
1816 ));
1817 let _ = fs::remove_dir_all(&root);
1818 fs::create_dir_all(root.join("src")).unwrap();
1819 fs::create_dir_all(root.join("tests")).unwrap();
1820 let app = "function work() {\n return 1;\n}\nfunction idle() {\n return 2;\n}\n";
1821 let test = "import assert from 'node:assert/strict';\nimport { LIMIT } from '../src/config.js';\nassert.equal(work(), 1);\n";
1822 let config = "export const LIMIT = 5;\nfunction check(n) {\n return n < LIMIT;\n}\n";
1823 fs::write(root.join("src/app.js"), app).unwrap();
1824 fs::write(root.join("src/config.js"), config).unwrap();
1825 fs::write(root.join("tests/app.test.js"), test).unwrap();
1826 let inputs = crate::source_capture::capture(
1827 &root,
1828 "python",
1829 ["src/app.js".into(), "tests/app.test.js".into()],
1830 )
1831 .unwrap();
1832 let directory = crate::run_store::create_partial_test_run(&root, "partial");
1833 let path = directory.join("evidence.raw.gz");
1834 let entries = crate::source_capture::append(read_archive(&path).unwrap(), &inputs).unwrap();
1835 let archive = write_archive(entries, &path).unwrap();
1836 let metadata_path = directory.join("run.json");
1837 let mut metadata: RunMetadata =
1838 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1839 metadata.raw_evidence.files = archive.files;
1840 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1841 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1842 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1843 prepare_publication(&root, &directory, &metadata, None).unwrap();
1844 let run = discover_runs(&root).unwrap().runs.remove(0);
1845
1846 let executions = recorded(&run);
1847 let record = &executions.tests[0];
1848 assert_eq!(
1849 record.attribution,
1850 crate::coverage_report::ATTRIBUTION_PARTIAL
1851 );
1852 assert!(
1853 !record.files.is_empty(),
1854 "a lower bound is coverage it was credited with"
1855 );
1856
1857 let names = |value: &Value, key: &str| {
1858 value[key]
1859 .as_array()
1860 .unwrap()
1861 .iter()
1862 .map(|t| t["name"].as_str().unwrap().to_owned())
1863 .collect::<Vec<_>>()
1864 };
1865 fs::write(
1866 root.join("src/app.js"),
1867 app.replace("return 1;", "return 3;"),
1868 )
1869 .unwrap();
1870 let report = affected_tests(&root, &run).unwrap();
1871 assert_eq!(
1872 names(&report, "affected"),
1873 ["test"],
1874 "what its own record proves, it proves: {report}"
1875 );
1876 assert!(names(&report, "undetermined").is_empty(), "{report}");
1877 let reasons = report["affected"][0]["reasons"].as_array().unwrap();
1879 assert_eq!(reasons.len(), 1, "{report}");
1880 let reason = reasons[0].as_str().unwrap();
1881 assert!(
1882 reason.contains("this test ran it"),
1883 "the reason is its own: {report}"
1884 );
1885 assert!(
1886 !reason.contains("the run covered"),
1887 "and not the run's stand-in: {report}"
1888 );
1889 fs::remove_dir_all(root).unwrap();
1890 }
1891}