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 load_inputs_for(root, run, |_| true)
292}
293pub fn load_inputs_for(
295 root: &Path,
296 run: &StoredRun,
297 needed: impl Fn(&str) -> bool,
298) -> Result<RunInputs, String> {
299 let stored = load_manifest(run)?;
300 let inputs = crate::source_capture::current_sources_for(root, &stored.manifest, needed)?;
301 Ok(RunInputs { inputs, stored })
302}
303pub fn load_manifest(run: &StoredRun) -> Result<RunManifest, String> {
304 load_optional_manifest(run)?.ok_or_else(|| {
305 "This run has no source manifest. Run tests once with this version of Supercov".into()
306 })
307}
308fn unsharded(path: &str) -> &str {
312 let mut rest = path;
313 while let Some((shard, inner)) = rest
314 .strip_prefix("shards/")
315 .and_then(|after| after.split_once('/'))
316 {
317 if shard.is_empty() || !shard.bytes().all(|byte| byte.is_ascii_digit()) {
318 break;
319 }
320 rest = inner;
321 }
322 rest
323}
324fn the_one(
329 entries: &[crate::evidence_archive::EvidenceArchiveEntry],
330 named: impl Fn(&str) -> bool,
331) -> Result<Option<Value>, String> {
332 let mut found: Option<(&[u8], Value)> = None;
333 for entry in entries.iter().filter(|entry| named(&entry.path)) {
334 match &found {
335 Some((bytes, _)) if *bytes == entry.contents.as_slice() => {}
336 Some((_, value)) => {
337 let other: Value =
338 serde_json::from_slice(&entry.contents).map_err(|e| e.to_string())?;
339 if other != *value {
340 return Err("The runs merged into this one recorded different source manifests; use one of them instead".into());
341 }
342 }
343 None => {
344 found = Some((
345 &entry.contents,
346 serde_json::from_slice(&entry.contents).map_err(|e| e.to_string())?,
347 ));
348 }
349 }
350 }
351 Ok(found.map(|(_, value)| value))
352}
353fn load_optional_manifest(run: &StoredRun) -> Result<Option<RunManifest>, String> {
354 let merged = run.metadata.merged == Some(true);
355 let bytes = fs::read(&run.evidence_path).map_err(|e| e.to_string())?;
356 let evidence_digest = format!("{:x}", Sha256::digest(&bytes));
357 if let Some(cached) = fs::read(run.directory.join(MANIFEST_CACHE_FILE))
358 .ok()
359 .and_then(|bytes| serde_json::from_slice::<ManifestCache>(&bytes).ok())
360 .filter(|cached| cached.evidence_digest == evidence_digest)
361 .filter(|cached| validate_manifest(&cached.manifest).is_ok())
362 {
363 return Ok(Some(RunManifest {
364 manifest: cached.manifest,
365 evidence_digest,
366 statement_exclusions: cached.statement_exclusions,
367 }));
368 }
369 let named = |path: &str, name: &str| {
370 if merged {
371 unsharded(path) == name
372 } else {
373 path == name
374 }
375 };
376 let entries = read_archive_selected(&run.evidence_path, |path| {
377 named(path, ARCHIVE_PATH) || named(path, "statement-exclusions.json")
378 })
379 .map_err(|e| e.to_string())?;
380 let Some(value) = the_one(&entries, |path| named(path, ARCHIVE_PATH))? else {
381 return Ok(None);
382 };
383 if value["schemaVersion"].as_u64() != Some(u64::from(MANIFEST_SCHEMA)) {
384 return Err(
385 "Unsupported source manifest schema; rerun tests with this version of Supercov".into(),
386 );
387 }
388 let manifest = serde_json::from_value::<InputManifest>(value).map_err(|e| e.to_string())?;
389 validate_manifest(&manifest)?;
390 if fs::read(&run.evidence_path).map_err(|e| e.to_string())? != bytes {
391 return Err("Run archive changed during read".into());
392 }
393 Ok(Some(RunManifest {
394 manifest,
395 evidence_digest,
396 statement_exclusions: the_one(&entries, |path| named(path, "statement-exclusions.json"))?
397 .map(|value| serde_json::from_value(value).map_err(|e| e.to_string()))
398 .transpose()?
399 .unwrap_or_default(),
400 }))
401}
402fn validate_manifest(manifest: &InputManifest) -> Result<(), String> {
403 if manifest.files.iter().any(|(p, f)| {
404 !local_path(p) || f.sha256.len() != 64 || !f.sha256.bytes().all(|b| b.is_ascii_hexdigit())
405 }) {
406 return Err("Invalid source manifest".into());
407 }
408 Ok(())
409}
410
411pub(crate) fn archived_manifest(
415 manifest: InputManifest,
416 evidence_digest: String,
417) -> Result<RunManifest, String> {
418 validate_manifest(&manifest)?;
419 Ok(RunManifest {
420 manifest,
421 evidence_digest,
422 statement_exclusions: Vec::new(),
423 })
424}
425fn write_json(
426 root: &Path,
427 run: &StoredRun,
428 name: &str,
429 value: &impl serde::Serialize,
430) -> Result<(), String> {
431 let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
432 bytes.push(b'\n');
433 atomic_write(root, &run.directory.join(name), &bytes).map_err(|e| e.to_string())
434}
435pub(crate) fn prepare_publication_with(
442 root: &Path,
443 directory: &Path,
444 metadata: &RunMetadata,
445 analysed: Option<&CoverageReport>,
446 archived: Option<RunManifest>,
447) -> Result<(), String> {
448 if metadata.merged == Some(true) {
449 return Ok(());
450 }
451 let run = StoredRun {
452 id: metadata.id.clone(),
453 directory: directory.into(),
454 evidence_path: directory.join("evidence.raw.gz"),
455 metadata_path: directory.join("run.json"),
456 query_index_path: directory.join(crate::run_store::RUST_QUERY_INDEX_FILE),
457 metadata: metadata.clone(),
458 };
459 let input = match archived {
460 Some(input) => input,
461 None => match load_optional_manifest(&run)? {
462 Some(input) => input,
463 None => return Ok(()),
464 },
465 };
466 let current = crate::source_capture::current_sources(root, &input.manifest);
467 let owned;
468 let analysed = match analysed {
469 Some(report) => Some(report),
470 None => {
471 owned = coverage(&run).ok();
472 owned.as_ref()
473 }
474 };
475 if let Some(executions) = analysed.and_then(|report| {
478 executions(
479 report,
480 &input.manifest,
481 current.as_ref().ok().map(|inputs| &inputs.files),
482 )
483 }) {
484 write_json(root, &run, EXECUTIONS_FILE, &executions)?;
485 }
486 let _ = write_json(
488 root,
489 &run,
490 MANIFEST_CACHE_FILE,
491 &ManifestCache {
492 evidence_digest: input.evidence_digest.clone(),
493 manifest: input.manifest.clone(),
494 statement_exclusions: input.statement_exclusions.clone(),
495 },
496 );
497 Ok(())
498}
499pub fn executions(
505 coverage: &CoverageReport,
506 manifest: &InputManifest,
507 sources: Option<&Files>,
508) -> Option<Executions> {
509 let mut located: crate::interned::FastMap<&str, (usize, usize)> = Default::default();
513 let mut files: Vec<&str> = Vec::new();
514 let mut file_indexes: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
515 let mut probed: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
516 for point in &coverage.view.points {
517 let meta = &point.meta;
518 let Some(code) = manifest
519 .files
520 .get(&meta.file)
521 .and_then(|fingerprint| fingerprint.code.as_ref())
522 else {
523 continue;
524 };
525 let column = if manifest.language == "javascript" {
526 let source = sources?.get(&meta.file)?;
527 let Some(column) = byte_column(source, meta.line, meta.column, &manifest.language)
528 else {
529 continue;
530 };
531 column
532 } else {
533 meta.column + 1
534 };
535 let unit = code.unit_at(meta.line, column);
536 let file = *file_indexes.entry(meta.file.as_str()).or_insert_with(|| {
537 files.push(meta.file.as_str());
538 files.len() - 1
539 });
540 located.insert(meta.id.as_str(), (file, unit));
541 if code.units[unit].is_code() {
542 probed.entry(meta.file.clone()).or_default().insert(unit);
543 }
544 }
545 let mut covered_by_file = vec![Vec::new(); files.len()];
550 for test in &coverage.view.tests {
551 for hit in &test.hits {
552 if let Some((file, unit)) = located.get(hit.as_str()) {
553 covered_by_file[*file].push(*unit);
554 }
555 }
556 }
557 let covered = files
558 .iter()
559 .zip(covered_by_file)
560 .filter(|(_, units)| !units.is_empty())
561 .map(|(file, units)| {
562 (
563 (*file).to_owned(),
564 units.into_iter().collect::<BTreeSet<_>>(),
565 )
566 })
567 .collect::<BTreeMap<_, _>>();
568
569 let mut tests: BTreeMap<TestSelector, Execution> = BTreeMap::new();
570 for test in &coverage.view.tests {
571 let Some(file) = &test.file else {
572 continue;
573 };
574 let selector = TestSelector {
575 file: file.clone(),
576 name: test.name.clone(),
577 };
578 let record = tests.entry(selector.clone()).or_insert_with(|| Execution {
579 test: selector,
580 passed: false,
581 files: BTreeMap::new(),
582 attribution: crate::coverage_report::ATTRIBUTION_EXACT.to_owned(),
583 });
584 record.passed |= test.outcome == "passed";
585 if !crate::coverage_report::coverage_is_complete(&record.attribution) {
589 } else if !crate::coverage_report::coverage_is_complete(&test.attribution) {
591 record.attribution = test.attribution.clone();
592 }
593 let mut reached = test
594 .hits
595 .iter()
596 .filter_map(|hit| located.get(hit.as_str()).copied())
597 .collect::<Vec<_>>();
598 reached.sort_unstable();
599 for group in reached.chunk_by(|left, right| left.0 == right.0) {
600 record
601 .files
602 .entry(files[group[0].0].to_owned())
603 .or_default()
604 .extend(group.iter().map(|(_, unit)| *unit));
605 }
606 }
607 let mut tests = tests.into_values().collect::<Vec<_>>();
608 for record in &mut tests {
609 for units in record.files.values_mut() {
610 units.sort_unstable();
611 units.dedup();
612 }
613 }
614 Some(Executions {
615 tests,
616 probed: probed
617 .into_iter()
618 .map(|(file, units)| (file, units.into_iter().collect()))
619 .collect(),
620 covered: covered
621 .into_iter()
622 .map(|(file, units)| (file, units.into_iter().collect()))
623 .collect(),
624 })
625}
626pub fn test_files(run: &StoredRun) -> Option<BTreeSet<String>> {
638 let executions: Executions =
639 serde_json::from_slice(&fs::read(run.directory.join(EXECUTIONS_FILE)).ok()?).ok()?;
640 Some(
641 executions
642 .tests
643 .into_iter()
644 .map(|record| record.test.file)
645 .collect(),
646 )
647}
648
649pub fn changed_files(recorded: &FileManifest, now: impl Fn(&str) -> Option<String>) -> Vec<String> {
651 recorded
652 .iter()
653 .filter(|(file, was)| now(file).as_deref() != Some(was.sha256.as_str()))
654 .map(|(file, _)| file.clone())
655 .collect()
656}
657
658fn affected_tests_of_merge(root: &Path, run: &StoredRun) -> Result<Value, String> {
663 let parents = run
664 .metadata
665 .parents
666 .as_deref()
667 .filter(|parents| !parents.is_empty())
668 .ok_or("This merged run does not say which runs it was merged from")?;
669 let inventory = crate::run_store::discover_runs(root).map_err(|error| error.to_string())?;
670 const BUCKETS: [&str; 3] = ["affected", "undetermined", "unaffected"];
673 let mut tests = BTreeMap::<(String, String), (usize, Value)>::new();
674 let mut changed = BTreeMap::<String, Value>::new();
675 let mut meaning = Value::Null;
676 for parent in parents {
677 let Some(stored) = inventory.runs.iter().find(|stored| stored.id == *parent) else {
678 return Err(format!(
679 "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",
680 run.id
681 ));
682 };
683 let report = affected_tests(root, stored)?;
684 for (rank, bucket) in BUCKETS.iter().enumerate() {
685 for test in report[*bucket].as_array().into_iter().flatten() {
686 let key = (
687 test["file"].as_str().unwrap_or_default().to_owned(),
688 test["name"].as_str().unwrap_or_default().to_owned(),
689 );
690 match tests.get_mut(&key) {
691 Some((held, _)) if *held < rank => {}
692 Some((held, entry)) if *held == rank => {
693 let mut reasons = entry["reasons"].as_array().cloned().unwrap_or_default();
694 for reason in test["reasons"].as_array().into_iter().flatten() {
695 if !reasons.contains(reason) {
696 reasons.push(reason.clone());
697 }
698 }
699 entry["reasons"] = Value::Array(reasons);
700 }
701 _ => {
702 tests.insert(key, (rank, test.clone()));
703 }
704 }
705 }
706 }
707 for file in report["changedFiles"].as_array().into_iter().flatten() {
708 if let Some(name) = file["file"].as_str() {
709 changed
710 .entry(name.to_owned())
711 .or_insert_with(|| file.clone());
712 }
713 }
714 if meaning.is_null() {
715 meaning = report["meaning"].clone();
716 }
717 }
718 let mut buckets = BUCKETS.map(|_| Vec::new());
719 for (rank, test) in tests.into_values() {
720 buckets[rank].push(test);
721 }
722 for bucket in &mut buckets[..2] {
724 bucket.sort_by_key(|entry| std::cmp::Reverse(entry["nameMatch"].as_u64().unwrap_or(0)));
725 }
726 let [affected, undetermined, unaffected] = buckets;
727 let total = affected.len() + undetermined.len() + unaffected.len();
728 Ok(json!({
729 "run": run.id,
730 "mergedFrom": parents,
731 "summary": {
732 "tests": total,
733 "affected": affected.len(),
734 "undetermined": undetermined.len(),
735 "unaffected": unaffected.len(),
736 "changedFiles": changed.len(),
737 },
738 "affected": affected,
739 "undetermined": undetermined,
740 "unaffected": unaffected,
741 "changedFiles": changed.into_values().collect::<Vec<_>>(),
742 "meaning": meaning,
743 }))
744}
745
746pub fn affected_tests(root: &Path, run: &StoredRun) -> Result<Value, String> {
747 if run.metadata.merged == Some(true) {
751 return affected_tests_of_merge(root, run);
752 }
753 let stored = load_manifest(run)?;
754 let executions: Executions = fs::read(run.directory.join(EXECUTIONS_FILE))
755 .ok()
756 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
757 .ok_or(
758 "This run has no per-test execution record; rerun tests with this version of Supercov",
759 )?;
760 let executions = &executions;
761 let root = crate::workspace::canonicalize_simplified(root).map_err(|e| e.to_string())?;
762 let manifest = &stored.manifest;
763 let now = manifest
764 .files
765 .keys()
766 .filter_map(|file| {
767 let path = root.join(file);
768 let canonical = crate::workspace::canonicalize_simplified(&path).ok()?;
769 if !canonical.starts_with(&root) || !canonical.is_file() {
770 return None;
771 }
772 let text = fs::read_to_string(canonical).ok()?;
773 Some((file.clone(), FileFingerprint::read(file, &text)))
774 })
775 .collect::<FileManifest>();
776 let changes = manifest
777 .files
778 .keys()
779 .filter_map(|file| {
780 file_change(
781 manifest.files.get(file),
782 now.get(file),
783 executions.probed.get(file).map(Vec::as_slice),
784 )
785 .map(|change| (file.as_str(), change))
786 })
787 .collect::<BTreeMap<_, _>>();
788 let files = changes
789 .iter()
790 .filter_map(|(file, change)| {
791 let (kind, detail) = match change {
792 FileChange::Same | FileChange::Added => return None,
793 FileChange::CommentsOnly => ("formatting", None),
794 FileChange::Removed => ("removed", None),
795 FileChange::Bytes => ("changed", None),
796 FileChange::Code {
797 before,
798 after,
799 diff,
800 narrow,
801 } => (
802 if *narrow { "bodies" } else { "declarations" },
803 Some(describe(before, after, diff)),
804 ),
805 };
806 Some(json!({"file":file,"change":kind,"detail":detail}))
807 })
808 .collect::<Vec<_>>();
809 let beyond_the_run = changes
823 .iter()
824 .filter_map(|(file, change)| match change {
825 FileChange::Same | FileChange::CommentsOnly | FileChange::Added => None,
826 FileChange::Removed => Some(format!("{file} removed (the run covered code in it)")),
827 FileChange::Bytes => Some(format!("{file} changed (the run covered code in it)")),
828 FileChange::Code { before, diff, .. } => {
829 let code = manifest.files.get(*file).and_then(|f| f.code.as_ref());
834 let reached = executions
835 .covered
836 .get(*file)
837 .map(Vec::as_slice)
838 .unwrap_or_default()
839 .iter()
840 .flat_map(|unit| match code {
841 Some(code) if *unit < code.units.len() => {
842 code.ancestors(*unit).collect::<Vec<_>>()
843 }
844 _ => vec![*unit],
845 })
846 .collect::<BTreeSet<_>>();
847 let hit = diff
848 .changed
849 .iter()
850 .filter(|unit| reached.contains(unit))
851 .filter_map(|unit| before.units.get(*unit))
852 .collect::<Vec<_>>();
853 (!hit.is_empty())
854 .then(|| format!("{file}: {} changed (the run covered it)", named(hit)))
855 }
856 })
857 .collect::<Vec<_>>();
858
859 let own_tests = executions
873 .tests
874 .iter()
875 .map(|record| record.test.file.clone())
876 .collect::<BTreeSet<_>>();
877 let known_tests = crate::run_store::discover_runs(&root)
878 .map(|inventory| {
879 inventory
880 .runs
881 .iter()
882 .filter_map(test_files)
883 .flatten()
884 .collect::<BTreeSet<_>>()
885 })
886 .unwrap_or_default()
887 .union(&own_tests)
888 .cloned()
889 .collect::<BTreeSet<_>>();
890 let measured = read_archive_selected(&run.evidence_path, |path| path == "manifest.json")
895 .ok()
896 .and_then(|entries| entries.into_iter().next())
897 .and_then(|entry| serde_json::from_slice::<Value>(&entry.contents).ok())
898 .and_then(|manifest| {
899 Some(
900 manifest["scope"]["entries"]
901 .as_array()?
902 .iter()
903 .filter(|entry| entry["status"] == "included")
904 .filter_map(|entry| entry["file"].as_str().map(str::to_owned))
905 .collect::<BTreeSet<_>>(),
906 )
907 });
908 let helper = |file: &str| match &measured {
909 Some(measured) => !measured.contains(file),
910 None => crate::run_currency::beside_the_tests(file),
911 };
912 let helpers = changes
913 .iter()
914 .filter(|(file, change)| {
915 !matches!(
916 change,
917 FileChange::Same | FileChange::CommentsOnly | FileChange::Added
918 ) && !known_tests.contains(**file)
919 && helper(file)
920 && !crate::run_currency::configuration_file(file)
921 && !crate::run_currency::dependency_file(file)
922 })
923 .filter(|(file, change)| match change {
925 FileChange::Code { before, .. } => fs::read_to_string(root.join(file))
926 .ok()
927 .and_then(|text| crate::source_units::only_added(before, file, &text))
928 .is_none(),
929 _ => true,
930 })
931 .map(|(file, change)| {
932 let what = match change {
933 FileChange::Code {
934 before,
935 after,
936 diff,
937 ..
938 } => {
939 format!("{file} changed: {}", describe(before, after, diff))
940 }
941 FileChange::Removed => format!("{file} removed"),
942 _ => format!("{file} changed"),
943 };
944 (*file, what)
945 })
946 .collect::<Vec<_>>();
947 let checkout =
948 (!helpers.is_empty()).then(|| crate::run_currency::Checkout::new(&root, &known_tests));
949 let ran = (!helpers.is_empty()).then(|| crate::run_currency::Ran::of(run));
950 let configurations = manifest
951 .files
952 .keys()
953 .filter(|file| crate::run_currency::configuration_file(file))
954 .cloned()
955 .collect::<Vec<_>>();
956 let mut loads_of = BTreeMap::<String, Option<crate::test_loads::Loads>>::new();
957 let named_by_tests = named_by_tests(&changes);
958 let mut test_sources = BTreeMap::<&str, Option<String>>::new();
959
960 let mut affected = Vec::new();
961 let mut undetermined = Vec::new();
962 let mut unaffected = Vec::new();
963 for record in &executions.tests {
964 let mut reasons = Vec::new();
965 let mut changed_code = Vec::new();
970 if !record.passed {
971 reasons.push("did not pass in the run".to_owned());
972 }
973 match changes.get(record.test.file.as_str()) {
974 None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
975 Some(FileChange::Removed) => reasons.push("test file removed".to_owned()),
976 Some(FileChange::Bytes) => reasons.push("test file changed".to_owned()),
977 Some(FileChange::Code {
978 before,
979 after,
980 diff,
981 ..
982 }) => reasons.push(format!(
983 "test file changed: {}",
984 describe(before, after, diff)
985 )),
986 }
987 for (file, units) in &record.files {
988 let code = manifest.files.get(file).and_then(|f| f.code.as_ref());
989 let ran = units
990 .iter()
991 .flat_map(|unit| match code {
992 Some(code) if *unit < code.units.len() => {
993 code.ancestors(*unit).collect::<Vec<_>>()
994 }
995 _ => vec![*unit],
996 })
997 .collect::<BTreeSet<_>>();
998 match changes.get(file.as_str()) {
999 None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
1000 Some(FileChange::Removed) => {
1001 reasons.push(format!("{file} removed (this test ran code in it)"));
1002 }
1003 Some(FileChange::Bytes) => {
1004 reasons.push(format!("{file} changed (this test ran code in it)"));
1005 }
1006 Some(FileChange::Code {
1007 before,
1008 after,
1009 diff,
1010 narrow,
1011 }) => {
1012 if *narrow {
1013 let hit = diff
1014 .changed
1015 .iter()
1016 .filter(|i| ran.contains(i))
1017 .map(|i| &before.units[*i])
1018 .collect::<Vec<_>>();
1019 let now = after.by_path();
1020 for index in diff.changed.iter().filter(|i| ran.contains(i)) {
1021 let unit = &before.units[*index];
1022 changed_code.push(json!({
1023 "file": file,
1024 "declaration": unit.path,
1025 "own": own_lines(before, *index),
1026 "now": now.get(unit.path.as_str()).map(|u| [u.line, u.end_line]),
1027 }));
1028 }
1029 if !hit.is_empty() {
1030 reasons
1031 .push(format!("{file}: {} changed (this test ran it)", named(hit)));
1032 }
1033 } else {
1034 reasons.push(format!(
1035 "{file}: {} changed (this test ran code in this file)",
1036 describe(before, after, diff)
1037 ));
1038 }
1039 }
1040 }
1041 }
1042 if let (Some(checkout), Some(ran)) = (&checkout, &ran) {
1043 let loads = loads_of.entry(record.test.file.clone()).or_insert_with(|| {
1044 checkout.loads(
1045 &BTreeSet::from([record.test.file.clone()]),
1046 ran.runners.as_ref(),
1047 &configurations,
1048 )
1049 });
1050 for (file, what) in &helpers {
1051 let served_by = ran.support.as_ref().and_then(|support| {
1054 let tests = support.files.get(*file)?;
1055 Some(tests.iter().any(|test| {
1056 support
1057 .names
1058 .get(test)
1059 .is_some_and(|name| *name == record.test.name)
1060 }))
1061 });
1062 let imported = matches!(loads, Some(loads) if loads.open.is_none() && loads.files.contains(*file));
1063 if let Some(served) = served_by
1064 && !imported
1065 {
1066 if served {
1067 reasons.push(format!("{what} (a server that served this test loaded it)"));
1068 }
1069 continue;
1070 }
1071 let loaded = match loads {
1072 Some(loads) if loads.open.is_none() => {
1073 loads.files.contains(*file) || !checkout.places(file)
1074 }
1075 _ => true,
1076 };
1077 if loaded {
1078 reasons.push(match loads {
1079 Some(loads) if loads.open.is_none() && loads.files.contains(*file) => {
1080 format!("{what} (this test's file loads it)")
1081 }
1082 _ => format!("{what} (a file tests may load)"),
1083 });
1084 }
1085 }
1086 }
1087 for (file, names) in &named_by_tests {
1088 if record.files.contains_key(*file) || *file == record.test.file.as_str() {
1089 continue; }
1091 let source = test_sources
1092 .entry(record.test.file.as_str())
1093 .or_insert_with(|| fs::read_to_string(root.join(&record.test.file)).ok());
1094 if let Some(name) = source
1095 .as_deref()
1096 .and_then(|text| names.iter().find(|name| names_word(text, name)))
1097 {
1098 reasons.push(format!(
1099 "{file}: declarations changed, and the test file names {name} (code that runs once per process is credited to the first test to reach it)"
1100 ));
1101 }
1102 }
1103 if !crate::coverage_report::coverage_is_complete(&record.attribution) {
1104 let proven = !reasons.is_empty();
1108 if !proven {
1112 reasons.extend(beyond_the_run.iter().cloned());
1113 reasons.sort();
1114 reasons.dedup();
1115 }
1116 let entry = json!({
1117 "file": record.test.file,
1118 "name": record.test.name,
1119 "reasons": reasons,
1120 "attribution": record.attribution,
1121 });
1122 if proven {
1123 affected.push(entry);
1124 } else if reasons.is_empty() {
1125 unaffected.push(entry);
1126 } else {
1127 undetermined.push(entry);
1128 }
1129 continue;
1130 }
1131 let entry = json!({"file":record.test.file,"name":record.test.name,"reasons":reasons,"changedCode":changed_code});
1132 if reasons.is_empty() {
1133 unaffected.push(entry);
1134 } else {
1135 affected.push(entry);
1136 }
1137 }
1138 let changed_words = changes
1144 .iter()
1145 .filter(|(_, change)| !matches!(change, FileChange::Same | FileChange::CommentsOnly))
1146 .flat_map(|(file, _)| words_of(file))
1147 .collect::<BTreeSet<_>>();
1148 for bucket in [&mut affected, &mut undetermined] {
1149 for entry in bucket.iter_mut() {
1150 entry["nameMatch"] = json!(name_match(entry, &changed_words));
1151 }
1152 bucket.sort_by_key(|entry| std::cmp::Reverse(entry["nameMatch"].as_u64().unwrap_or(0)));
1153 }
1154 Ok(json!({
1155 "run": run.id,
1156 "affected": affected,
1157 "undetermined": undetermined,
1164 "unaffected": unaffected,
1165 "changedFiles": files,
1166 "summary": {
1167 "tests": executions.tests.len(),
1168 "affected": affected.len(),
1169 "undetermined": undetermined.len(),
1170 "unaffected": unaffected.len(),
1171 "changedFiles": files.len(),
1172 },
1173 "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."
1174 }))
1175}
1176fn named_by_tests<'a>(
1179 changes: &BTreeMap<&'a str, FileChange<'_>>,
1180) -> BTreeMap<&'a str, BTreeSet<String>> {
1181 changes
1182 .iter()
1183 .filter(|(file, _)| {
1184 [".java", ".kt", ".kts", ".groovy", ".scala"]
1185 .iter()
1186 .any(|extension| file.ends_with(extension))
1187 })
1188 .filter_map(|(file, change)| match change {
1189 FileChange::Code {
1190 before,
1191 narrow: false,
1192 ..
1193 } => {
1194 let stem = file.rsplit('/').next()?.split('.').next()?.to_owned();
1195 let mut names = before
1196 .units
1197 .iter()
1198 .filter(|unit| unit.parent.is_none_or(|p| before.units[p].path.is_empty()))
1199 .filter_map(|unit| unit.path.rsplit('.').next().map(str::to_owned))
1200 .filter(|name| name.len() > 2)
1201 .collect::<BTreeSet<_>>();
1202 if stem.len() > 2
1203 && !matches!(stem.as_str(), "mod" | "lib" | "main" | "index" | "__init__")
1204 {
1205 names.insert(stem);
1206 }
1207 Some((*file, names))
1208 }
1209 _ => None,
1210 })
1211 .collect::<BTreeMap<_, _>>()
1212}
1213
1214fn words_of(path: &str) -> BTreeSet<String> {
1218 let stem = path.rsplit(['/', '\\']).next().unwrap_or(path);
1219 let stem = stem.split('.').next().unwrap_or(stem);
1220 split_words(stem)
1221 .into_iter()
1222 .filter(|w| !matches!(w.as_str(), "test" | "tests" | "spec" | "specs"))
1223 .collect()
1224}
1225
1226fn split_words(text: &str) -> Vec<String> {
1229 let mut words = Vec::new();
1230 let mut current = String::new();
1231 let mut previous_lower = false;
1232 for c in text.chars() {
1233 if !c.is_alphanumeric() {
1234 if !current.is_empty() {
1235 words.push(std::mem::take(&mut current));
1236 }
1237 previous_lower = false;
1238 continue;
1239 }
1240 if c.is_uppercase() && previous_lower && !current.is_empty() {
1241 words.push(std::mem::take(&mut current));
1242 }
1243 previous_lower = c.is_lowercase() || c.is_ascii_digit();
1244 current.extend(c.to_lowercase());
1245 }
1246 if !current.is_empty() {
1247 words.push(current);
1248 }
1249 words.into_iter().filter(|w| w.len() > 1).collect()
1250}
1251
1252fn name_match(entry: &Value, changed: &BTreeSet<String>) -> usize {
1254 let file = entry["file"].as_str().unwrap_or("");
1255 let name = entry["name"].as_str().unwrap_or("");
1256 let own = name
1259 .rsplit(['#', ':', '>', '.', '/'])
1260 .next()
1261 .unwrap_or(name)
1262 .split(['[', '('])
1263 .next()
1264 .unwrap_or("");
1265 let mut words = words_of(file);
1266 words.extend(split_words(own));
1267 words.intersection(changed).count()
1268}
1269
1270fn names_word(text: &str, word: &str) -> bool {
1272 text.match_indices(word).any(|(at, _)| {
1273 let before = text[..at].chars().last();
1274 let after = text[at + word.len()..].chars().next();
1275 let part = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
1276 !part(before) && !part(after)
1277 })
1278}
1279
1280fn own_lines(code: &crate::source_units::Code, index: usize) -> Vec<[usize; 2]> {
1283 let unit = &code.units[index];
1284 let mut children = code
1285 .units
1286 .iter()
1287 .filter(|u| u.parent == Some(index))
1288 .map(|u| (u.line, u.end_line))
1289 .collect::<Vec<_>>();
1290 children.sort_unstable();
1291 let mut out = Vec::new();
1292 let mut next = unit.line;
1293 for (start, end) in children {
1294 if start > next {
1295 out.push([next, start - 1]);
1296 }
1297 next = next.max(end + 1);
1298 }
1299 if next <= unit.end_line {
1300 out.push([next, unit.end_line]);
1301 }
1302 out
1303}
1304
1305pub fn coverage(run: &StoredRun) -> Result<CoverageReport, String> {
1306 analyze_coverage_archive(&ArchiveReportRequest {
1307 archive_path: run.evidence_path.clone(),
1308 run_id: run.id.clone(),
1309 generated_at: run.metadata.started_at.clone(),
1310 integrity: None,
1311 test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
1312 test_filter: None,
1313 })
1314 .map_err(|e| format!("{e:?}"))
1315}
1316fn byte_column(source: &str, line: usize, column: usize, language: &str) -> Option<usize> {
1317 byte_column_in(source, &line_starts(source), line, column, language)
1318}
1319
1320fn byte_column_in(
1323 source: &str,
1324 starts: &[usize],
1325 line: usize,
1326 column: usize,
1327 language: &str,
1328) -> Option<usize> {
1329 if line == 0 {
1330 return None;
1331 }
1332 let line = line_text(source, starts, line)?;
1333 if language != "javascript" {
1334 return column.checked_add(1);
1336 }
1337 if column == 0 {
1338 return None;
1339 }
1340 let mut units = 0;
1341 for (byte, ch) in line.char_indices() {
1342 if units == column - 1 {
1343 return Some(byte + 1);
1344 }
1345 units += ch.len_utf16();
1346 }
1347 (units == column - 1).then_some(line.len() + 1)
1348}
1349
1350#[cfg(test)]
1351mod tests {
1352 use super::*;
1353 use crate::evidence_archive::{read_archive, write_archive};
1354 use crate::run_store::discover_runs;
1355
1356 fn prepare_publication(
1357 root: &Path,
1358 directory: &Path,
1359 metadata: &RunMetadata,
1360 analysed: Option<&CoverageReport>,
1361 ) -> Result<(), String> {
1362 prepare_publication_with(root, directory, metadata, analysed, None)
1363 }
1364
1365 fn recorded(run: &StoredRun) -> Executions {
1366 serde_json::from_slice(&fs::read(run.directory.join(EXECUTIONS_FILE)).expect("a record"))
1367 .unwrap()
1368 }
1369
1370 #[test]
1371 fn a_merged_run_has_the_manifest_its_parents_share() {
1372 assert_eq!(unsharded("source-inputs.json"), "source-inputs.json");
1375 assert_eq!(
1376 unsharded("shards/0/source-inputs.json"),
1377 "source-inputs.json"
1378 );
1379 assert_eq!(
1380 unsharded("shards/12/shards/1/source-inputs.json"),
1381 "source-inputs.json"
1382 );
1383 assert_eq!(
1384 unsharded("shards/x/source-inputs.json"),
1385 "shards/x/source-inputs.json"
1386 );
1387 assert_eq!(
1388 unsharded("shards/source-inputs.json"),
1389 "shards/source-inputs.json"
1390 );
1391
1392 let entry = |path: &str, contents: &str| crate::evidence_archive::EvidenceArchiveEntry {
1393 path: path.into(),
1394 contents: contents.as_bytes().to_vec(),
1395 };
1396 let named = |path: &str| unsharded(path) == ARCHIVE_PATH;
1397 let same = [
1399 entry("shards/0/source-inputs.json", r#"{"files":{"a.js":1}}"#),
1400 entry("shards/1/source-inputs.json", r#"{"files":{"a.js":1}}"#),
1401 entry("shards/2/source-inputs.json", r#"{ "files": {"a.js": 1} }"#),
1402 entry("shards/0/tests.jsonl", "not read"),
1403 ];
1404 assert_eq!(
1405 the_one(&same, named).unwrap(),
1406 Some(json!({"files": {"a.js": 1}}))
1407 );
1408 let different = [
1410 entry("shards/0/source-inputs.json", r#"{"files":{"a.js":1}}"#),
1411 entry("shards/1/source-inputs.json", r#"{"files":{"a.js":2}}"#),
1412 ];
1413 assert!(
1414 the_one(&different, named)
1415 .unwrap_err()
1416 .contains("different source manifests")
1417 );
1418 assert_eq!(the_one(&[], named).unwrap(), None);
1419 }
1420
1421 fn reference_byte_column(
1423 source: &str,
1424 line: usize,
1425 column: usize,
1426 language: &str,
1427 ) -> Option<usize> {
1428 if line == 0 {
1429 return None;
1430 }
1431 let line = source.lines().nth(line - 1)?;
1432 if language != "javascript" {
1433 return column.checked_add(1);
1434 }
1435 if column == 0 {
1436 return None;
1437 }
1438 let mut units = 0;
1439 for (byte, ch) in line.char_indices() {
1440 if units == column - 1 {
1441 return Some(byte + 1);
1442 }
1443 units += ch.len_utf16();
1444 }
1445 (units == column - 1).then_some(line.len() + 1)
1446 }
1447
1448 #[test]
1449 fn a_column_from_line_starts_agrees_with_walking_the_file() {
1450 for source in [
1453 "",
1454 "a",
1455 "a\n",
1456 "a\r\nb\r\n",
1457 "x\r",
1458 "héllo\nwörld\n",
1459 "😀x\ny😀z\n",
1460 "const a = 1;\n b();\n",
1461 ] {
1462 let starts = line_starts(source);
1463 for language in ["javascript", "python"] {
1464 for line in 0..source.len() + 3 {
1465 for column in 0..source.len() + 4 {
1466 assert_eq!(
1467 byte_column_in(source, &starts, line, column, language),
1468 reference_byte_column(source, line, column, language),
1469 "{language} {line}:{column} in {source:?}"
1470 );
1471 }
1472 }
1473 }
1474 }
1475 }
1476
1477 #[test]
1478 fn publication_records_what_each_test_ran_and_affected_tests_reads_it() {
1479 let root = std::env::temp_dir().join(format!("supercov-affected-{}", std::process::id()));
1480 let _ = fs::remove_dir_all(&root);
1481 fs::create_dir_all(root.join("src")).unwrap();
1482 fs::create_dir_all(root.join("tests")).unwrap();
1483 let app = "function work() {\n return 1;\n}\nfunction idle() {\n return 2;\n}\n";
1487 let test = "import assert from 'node:assert/strict';\nimport { LIMIT } from '../src/config.js';\nassert.equal(work(), 1);\n";
1488 let config = "export const LIMIT = 5;\nfunction check(n) {\n return n < LIMIT;\n}\n";
1489 fs::write(root.join("src/app.js"), app).unwrap();
1490 fs::write(root.join("src/config.js"), config).unwrap();
1491 fs::write(root.join("tests/app.test.js"), test).unwrap();
1492 let inputs = crate::source_capture::capture(
1496 &root,
1497 "python",
1498 [
1499 "src/app.js".into(),
1500 "src/config.js".into(),
1501 "tests/app.test.js".into(),
1502 ],
1503 )
1504 .unwrap();
1505 let directory = crate::run_store::create_analyzable_test_run(&root, "first");
1506 let path = directory.join("evidence.raw.gz");
1507 let entries = crate::source_capture::append(read_archive(&path).unwrap(), &inputs).unwrap();
1508 let archive = write_archive(entries, &path).unwrap();
1509 let metadata_path = directory.join("run.json");
1510 let mut metadata: RunMetadata =
1511 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1512 metadata.raw_evidence.files = archive.files;
1513 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1514 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1515 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1516 prepare_publication(&root, &directory, &metadata, None).unwrap();
1517 let run = discover_runs(&root).unwrap().runs.remove(0);
1518 let stored = load_manifest(&run).unwrap();
1519 let executions = &recorded(&run);
1520 let code = stored.manifest.files["src/app.js"].code.as_ref().unwrap();
1521 let work = code.units.iter().position(|u| u.path == "work").unwrap();
1522 let idle = code.units.iter().position(|u| u.path == "idle").unwrap();
1523 assert_eq!(executions.tests.len(), 1);
1524 let record = &executions.tests[0];
1525 assert_eq!(record.test.file, "tests/app.test.js");
1526 assert_eq!(record.test.name, "test");
1527 assert!(record.passed);
1528 assert_eq!(record.files["src/app.js"], vec![work]);
1529 assert_eq!(
1530 executions.probed["src/app.js"],
1531 vec![work],
1532 "idle holds no probe in this run"
1533 );
1534 let _ = idle;
1535
1536 let names = |value: &Value, key: &str| {
1537 value[key]
1538 .as_array()
1539 .unwrap()
1540 .iter()
1541 .map(|t| t["name"].as_str().unwrap().to_owned())
1542 .collect::<Vec<_>>()
1543 };
1544 let report = affected_tests(&root, &run).unwrap();
1546 assert!(names(&report, "affected").is_empty(), "{report}");
1547 assert_eq!(names(&report, "unaffected"), ["test"]);
1548 assert_eq!(report["summary"]["changedFiles"], 0);
1549 fs::write(root.join("src/app.js"), format!("// about\n{app}")).unwrap();
1551 let report = affected_tests(&root, &run).unwrap();
1552 assert!(names(&report, "affected").is_empty(), "{report}");
1553 assert_eq!(report["changedFiles"][0]["change"], "formatting");
1554 fs::write(
1556 root.join("src/app.js"),
1557 app.replace("return 1;", "return 1 + 0;"),
1558 )
1559 .unwrap();
1560 let report = affected_tests(&root, &run).unwrap();
1561 assert_eq!(names(&report, "affected"), ["test"]);
1562 assert_eq!(
1563 report["affected"][0]["reasons"][0],
1564 "src/app.js: work (line 1) changed (this test ran it)"
1565 );
1566 fs::write(
1570 root.join("src/app.js"),
1571 app.replace("return 2;", "return 2 + 0;"),
1572 )
1573 .unwrap();
1574 let report = affected_tests(&root, &run).unwrap();
1575 assert_eq!(names(&report, "affected"), ["test"], "{report}");
1576 assert!(
1577 report["affected"][0]["reasons"][0]
1578 .as_str()
1579 .unwrap()
1580 .contains("this test ran code in this file"),
1581 "{report}"
1582 );
1583 fs::write(
1585 root.join("src/app.js"),
1586 format!("{app}function more() {{}}\n"),
1587 )
1588 .unwrap();
1589 let report = affected_tests(&root, &run).unwrap();
1590 assert_eq!(names(&report, "affected"), ["test"]);
1591 assert_eq!(report["changedFiles"][0]["change"], "declarations");
1592 fs::write(root.join("src/app.js"), app).unwrap();
1594 fs::write(root.join("tests/app.test.js"), test.replace("1)", "2)")).unwrap();
1595 let report = affected_tests(&root, &run).unwrap();
1596 assert_eq!(names(&report, "affected"), ["test"]);
1597 assert!(
1598 report["affected"][0]["reasons"][0]
1599 .as_str()
1600 .unwrap()
1601 .starts_with("test file changed"),
1602 "{report}"
1603 );
1604 fs::write(root.join("tests/app.test.js"), test).unwrap();
1608 fs::write(root.join("src/config.js"), config.replace("= 5", "= 6")).unwrap();
1609 let report = affected_tests(&root, &run).unwrap();
1610 assert!(names(&report, "affected").is_empty(), "{report}");
1611 fs::write(root.join("src/config.js"), config).unwrap();
1612 fs::remove_file(root.join("src/app.js")).unwrap();
1614 let report = affected_tests(&root, &run).unwrap();
1615 assert_eq!(
1616 report["affected"][0]["reasons"][0],
1617 "src/app.js removed (this test ran code in it)"
1618 );
1619 fs::remove_dir_all(root).unwrap();
1620 }
1621
1622 #[test]
1625 fn a_test_named_like_the_changed_file_comes_first() {
1626 assert_eq!(
1627 words_of("h11/tests/test_receive_buffer.py"),
1628 BTreeSet::from(["receive".to_owned(), "buffer".to_owned()])
1629 );
1630 assert_eq!(
1631 words_of("src/test/java/org/apache/commons/cli/ConverterTests.java"),
1632 BTreeSet::from(["converter".to_owned()])
1633 );
1634 assert_eq!(
1635 split_words("parseHTTPVersion_v2"),
1636 ["parse", "httpversion", "v2"]
1637 );
1638 let changed = words_of("src/main/java/org/apache/commons/cli/Converter.java");
1639 let entry = |file: &str, name: &str| json!({"file": file, "name": name});
1640 assert_eq!(
1641 name_match(
1642 &entry(
1643 "src/test/java/x/ConverterTests.java",
1644 "x.ConverterTests#testClass()"
1645 ),
1646 &changed
1647 ),
1648 1
1649 );
1650 assert_eq!(
1651 name_match(
1652 &entry(
1653 "src/test/java/x/OptionTests.java",
1654 "x.OptionTests#testConverter()"
1655 ),
1656 &changed
1657 ),
1658 1,
1659 "the test's own name counts too"
1660 );
1661 assert_eq!(
1662 name_match(
1663 &entry(
1664 "src/test/java/x/OptionTests.java",
1665 "x.OptionTests#testBuilder()"
1666 ),
1667 &changed
1668 ),
1669 0
1670 );
1671 }
1672
1673 #[test]
1678 fn a_jvm_class_changed_in_its_declarations_is_reached_by_its_name() {
1679 let java = "package app;\nclass Converter {\n static final Object CLASS = null;\n static int size(int n) {\n return n;\n }\n}\n";
1680 let before = FileFingerprint::read("src/app/Converter.java", java);
1681 let after = FileFingerprint::read(
1682 "src/app/Converter.java",
1683 &java.replace("CLASS = null", "CLASS = new Object()"),
1684 );
1685 let js = "export const LIMIT = 5;\n";
1686 let js_before = FileFingerprint::read("src/config.js", js);
1687 let js_after = FileFingerprint::read("src/config.js", &js.replace('5', "6"));
1688 let changes = BTreeMap::from([
1689 (
1690 "src/app/Converter.java",
1691 file_change(Some(&before), Some(&after), Some(&[])).unwrap(),
1692 ),
1693 (
1694 "src/config.js",
1695 file_change(Some(&js_before), Some(&js_after), Some(&[])).unwrap(),
1696 ),
1697 ]);
1698 let named = named_by_tests(&changes);
1699 assert_eq!(
1700 named.get("src/app/Converter.java"),
1701 Some(&BTreeSet::from(["Converter".to_owned()]))
1702 );
1703 assert!(!named.contains_key("src/config.js"));
1704 assert!(names_word("assertEquals(Converter.CLASS, x)", "Converter"));
1705 assert!(!names_word("ConverterTests", "Converter"));
1706 }
1707
1708 #[test]
1709 fn a_test_that_ran_beside_others_is_undetermined_rather_than_unaffected() {
1710 let root = std::env::temp_dir().join(format!(
1717 "supercov-undetermined-{}-{}",
1718 std::process::id(),
1719 std::time::SystemTime::now()
1720 .duration_since(std::time::UNIX_EPOCH)
1721 .unwrap()
1722 .as_nanos()
1723 ));
1724 let _ = fs::remove_dir_all(&root);
1725 fs::create_dir_all(root.join("src")).unwrap();
1726 fs::create_dir_all(root.join("tests")).unwrap();
1727 let app = "function work() {\n return 1;\n}\nfunction idle() {\n return 2;\n}\n";
1728 let test = "import assert from 'node:assert/strict';\nimport { LIMIT } from '../src/config.js';\nassert.equal(work(), 1);\n";
1729 let config = "export const LIMIT = 5;\nfunction check(n) {\n return n < LIMIT;\n}\n";
1730 fs::write(root.join("src/app.js"), app).unwrap();
1731 fs::write(root.join("src/config.js"), config).unwrap();
1732 fs::write(root.join("tests/app.test.js"), test).unwrap();
1733 let inputs = crate::source_capture::capture(
1734 &root,
1735 "python",
1736 ["src/app.js".into(), "tests/app.test.js".into()],
1737 )
1738 .unwrap();
1739 let directory = crate::run_store::create_run_wide_test_run(&root, "parallel");
1740 let path = directory.join("evidence.raw.gz");
1741 let entries = crate::source_capture::append(read_archive(&path).unwrap(), &inputs).unwrap();
1742 let archive = write_archive(entries, &path).unwrap();
1743 let metadata_path = directory.join("run.json");
1744 let mut metadata: RunMetadata =
1745 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1746 metadata.raw_evidence.files = archive.files;
1747 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1748 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1749 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1750 prepare_publication(&root, &directory, &metadata, None).unwrap();
1751 let run = discover_runs(&root).unwrap().runs.remove(0);
1752
1753 let executions = &recorded(&run);
1754 assert_eq!(executions.tests.len(), 1, "the named test, not the record");
1755 let record = &executions.tests[0];
1756 assert_eq!(
1757 record.attribution,
1758 crate::coverage_report::ATTRIBUTION_RUN_WIDE
1759 );
1760 assert!(
1761 record.files.is_empty(),
1762 "it is credited with nothing: {:?}",
1763 record.files
1764 );
1765 assert!(
1766 !executions.covered["src/app.js"].is_empty(),
1767 "and the run is credited with what it reached"
1768 );
1769
1770 let bucket = |value: &Value, key: &str| {
1771 value[key]
1772 .as_array()
1773 .unwrap()
1774 .iter()
1775 .map(|t| t["name"].as_str().unwrap().to_owned())
1776 .collect::<Vec<_>>()
1777 };
1778
1779 let report = affected_tests(&root, &run).unwrap();
1782 assert_eq!(bucket(&report, "unaffected"), ["test"], "{report}");
1783 assert!(bucket(&report, "undetermined").is_empty(), "{report}");
1784
1785 fs::write(
1788 root.join("src/app.js"),
1789 app.replace("return 1;", "return 3;"),
1790 )
1791 .unwrap();
1792 let report = affected_tests(&root, &run).unwrap();
1793 assert!(
1794 bucket(&report, "affected").is_empty(),
1795 "nothing proves it ran the change: {report}"
1796 );
1797 assert_eq!(
1798 bucket(&report, "undetermined"),
1799 ["test"],
1800 "the run reached it, so this test might have: {report}"
1801 );
1802 assert_eq!(
1803 report["undetermined"][0]["attribution"],
1804 crate::coverage_report::ATTRIBUTION_RUN_WIDE,
1805 "{report}"
1806 );
1807 fs::remove_dir_all(root).unwrap();
1808 }
1809
1810 #[test]
1811 fn a_lower_bound_still_proves_what_it_recorded() {
1812 let root = std::env::temp_dir().join(format!(
1819 "supercov-partial-{}-{}",
1820 std::process::id(),
1821 std::time::SystemTime::now()
1822 .duration_since(std::time::UNIX_EPOCH)
1823 .unwrap()
1824 .as_nanos()
1825 ));
1826 let _ = fs::remove_dir_all(&root);
1827 fs::create_dir_all(root.join("src")).unwrap();
1828 fs::create_dir_all(root.join("tests")).unwrap();
1829 let app = "function work() {\n return 1;\n}\nfunction idle() {\n return 2;\n}\n";
1830 let test = "import assert from 'node:assert/strict';\nimport { LIMIT } from '../src/config.js';\nassert.equal(work(), 1);\n";
1831 let config = "export const LIMIT = 5;\nfunction check(n) {\n return n < LIMIT;\n}\n";
1832 fs::write(root.join("src/app.js"), app).unwrap();
1833 fs::write(root.join("src/config.js"), config).unwrap();
1834 fs::write(root.join("tests/app.test.js"), test).unwrap();
1835 let inputs = crate::source_capture::capture(
1836 &root,
1837 "python",
1838 ["src/app.js".into(), "tests/app.test.js".into()],
1839 )
1840 .unwrap();
1841 let directory = crate::run_store::create_partial_test_run(&root, "partial");
1842 let path = directory.join("evidence.raw.gz");
1843 let entries = crate::source_capture::append(read_archive(&path).unwrap(), &inputs).unwrap();
1844 let archive = write_archive(entries, &path).unwrap();
1845 let metadata_path = directory.join("run.json");
1846 let mut metadata: RunMetadata =
1847 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1848 metadata.raw_evidence.files = archive.files;
1849 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1850 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1851 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1852 prepare_publication(&root, &directory, &metadata, None).unwrap();
1853 let run = discover_runs(&root).unwrap().runs.remove(0);
1854
1855 let executions = recorded(&run);
1856 let record = &executions.tests[0];
1857 assert_eq!(
1858 record.attribution,
1859 crate::coverage_report::ATTRIBUTION_PARTIAL
1860 );
1861 assert!(
1862 !record.files.is_empty(),
1863 "a lower bound is coverage it was credited with"
1864 );
1865
1866 let names = |value: &Value, key: &str| {
1867 value[key]
1868 .as_array()
1869 .unwrap()
1870 .iter()
1871 .map(|t| t["name"].as_str().unwrap().to_owned())
1872 .collect::<Vec<_>>()
1873 };
1874 fs::write(
1875 root.join("src/app.js"),
1876 app.replace("return 1;", "return 3;"),
1877 )
1878 .unwrap();
1879 let report = affected_tests(&root, &run).unwrap();
1880 assert_eq!(
1881 names(&report, "affected"),
1882 ["test"],
1883 "what its own record proves, it proves: {report}"
1884 );
1885 assert!(names(&report, "undetermined").is_empty(), "{report}");
1886 let reasons = report["affected"][0]["reasons"].as_array().unwrap();
1888 assert_eq!(reasons.len(), 1, "{report}");
1889 let reason = reasons[0].as_str().unwrap();
1890 assert!(
1891 reason.contains("this test ran it"),
1892 "the reason is its own: {report}"
1893 );
1894 assert!(
1895 !reason.contains("the run covered"),
1896 "and not the run's stand-in: {report}"
1897 );
1898 fs::remove_dir_all(root).unwrap();
1899 }
1900}