1use std::collections::{BTreeMap, BTreeSet};
24use std::path::{Path, PathBuf};
25use std::process::Command;
26use std::sync::atomic::{AtomicU64, Ordering};
27
28use anyhow::{bail, Context, Result};
29use serde::Deserialize;
30
31const TEST_OMIT: &str = "*_test.py";
34
35const SUPPORT_OMIT: &str = "*conftest.py";
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct Thresholds {
43 pub fail_under: u8,
45 pub branch: bool,
47}
48
49#[derive(Debug, Clone, Deserialize)]
54pub struct CoverageReport {
55 pub totals: Totals,
56 #[serde(default)]
60 pub files: BTreeMap<String, FileCoverage>,
61}
62
63#[derive(Debug, Clone, Default, Deserialize)]
67pub struct FileCoverage {
68 #[serde(default)]
70 pub executed_lines: Vec<u64>,
71 #[serde(default)]
74 pub missing_lines: Vec<u64>,
75 #[serde(default)]
77 pub excluded_lines: Vec<u64>,
78 #[serde(default)]
82 pub missing_branches: Vec<Vec<i64>>,
83}
84
85#[derive(Debug, Clone, Deserialize)]
87pub struct Totals {
88 pub percent_covered: f64,
90 #[serde(default)]
92 pub num_branches: u64,
93}
94
95#[derive(Debug, Clone, PartialEq)]
97pub enum Outcome {
98 Pass,
100 Fail(String),
102}
103
104pub fn parse_report(json: &str) -> Result<CoverageReport> {
106 serde_json::from_str(json).context("parsing coverage.py JSON report")
107}
108
109pub fn evaluate(report: &CoverageReport, thresholds: Thresholds) -> Outcome {
114 if thresholds.branch && report.totals.num_branches == 0 {
115 return Outcome::Fail(
116 "branch coverage is required but the report measured no branches".to_string(),
117 );
118 }
119 let actual = report.totals.percent_covered;
120 let required = f64::from(thresholds.fail_under);
121 if actual + 1e-9 >= required {
124 Outcome::Pass
125 } else {
126 Outcome::Fail(format!(
127 "coverage {actual:.2}% is below the required {}%",
128 thresholds.fail_under
129 ))
130 }
131}
132
133pub fn measure(root: &Path, thresholds: Thresholds, omit: &[String]) -> Result<Outcome> {
142 let report = run_coverage(root, omit, false)?;
143 Ok(evaluate(&report, thresholds))
144}
145
146pub fn measure_patch_report(root: &Path, omit: &[String]) -> Result<CoverageReport> {
153 run_coverage(root, omit, true)
154}
155
156struct DataFile(PathBuf);
160
161impl DataFile {
162 fn new() -> Self {
163 static COUNTER: AtomicU64 = AtomicU64::new(0);
164 let name = format!(
165 "testing-conventions-{}-{}.coverage",
166 std::process::id(),
167 COUNTER.fetch_add(1, Ordering::Relaxed),
168 );
169 DataFile(std::env::temp_dir().join(name))
170 }
171}
172
173impl Drop for DataFile {
174 fn drop(&mut self) {
175 let _ = std::fs::remove_file(&self.0);
176 }
177}
178
179fn run_coverage(root: &Path, omit: &[String], include_all_sources: bool) -> Result<CoverageReport> {
186 let data = DataFile::new();
187 let omit = build_omit(omit);
188
189 let mut command = Command::new("coverage");
193 command
194 .current_dir(root)
195 .args(["run", "--branch"])
196 .arg(format!("--omit={omit}"));
197 if include_all_sources {
198 command.arg("--source=.");
199 }
200 let run = command
201 .args(["-m", "pytest", "-q", "-p", "no:cacheprovider", "."])
202 .env("COVERAGE_FILE", &data.0)
203 .env("PYTHONDONTWRITEBYTECODE", "1")
204 .output()
205 .context("running `coverage run -m pytest` (is coverage.py installed?)")?;
206 if !run.status.success() {
207 bail!(
208 "the unit suite did not run cleanly under coverage in `{}`:\n{}{}",
209 root.display(),
210 String::from_utf8_lossy(&run.stdout),
211 String::from_utf8_lossy(&run.stderr),
212 );
213 }
214
215 let json = Command::new("coverage")
217 .current_dir(root)
218 .args(["json", "-o", "-"])
219 .env("COVERAGE_FILE", &data.0)
220 .output()
221 .context("running `coverage json`")?;
222 if !json.status.success() {
223 bail!(
224 "`coverage json` failed:\n{}",
225 String::from_utf8_lossy(&json.stderr),
226 );
227 }
228
229 parse_report(&String::from_utf8_lossy(&json.stdout))
230}
231
232fn build_omit(omit: &[String]) -> String {
239 [TEST_OMIT.to_string(), SUPPORT_OMIT.to_string()]
240 .into_iter()
241 .chain(omit.iter().cloned())
242 .collect::<Vec<_>>()
243 .join(",")
244}
245
246const TS_INCLUDE: &str = "**/*.{ts,tsx,mts,cts}";
260const TS_TEST_EXCLUDE: &str = "**/*.test.*";
264const TS_DECL_EXCLUDE: &str = "**/*.d.{ts,mts,cts}";
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub struct TypeScriptThresholds {
270 pub lines: u8,
271 pub branches: u8,
272 pub functions: u8,
273 pub statements: u8,
274}
275
276#[derive(Debug, Clone, Copy, Deserialize)]
279pub struct VitestReport {
280 pub total: VitestTotals,
281}
282
283#[derive(Debug, Clone, Copy, Deserialize)]
286pub struct VitestTotals {
287 pub lines: VitestMetric,
288 pub branches: VitestMetric,
289 pub functions: VitestMetric,
290 pub statements: VitestMetric,
291}
292
293#[derive(Debug, Clone, Copy, Deserialize)]
296pub struct VitestMetric {
297 #[serde(deserialize_with = "deserialize_pct")]
300 pub pct: Option<f64>,
301 pub total: u64,
303}
304
305fn deserialize_pct<'de, D>(deserializer: D) -> std::result::Result<Option<f64>, D::Error>
309where
310 D: serde::Deserializer<'de>,
311{
312 struct PctVisitor;
313 impl serde::de::Visitor<'_> for PctVisitor {
314 type Value = Option<f64>;
315
316 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
317 f.write_str("a coverage percent number or the string \"Unknown\"")
318 }
319
320 fn visit_f64<E>(self, value: f64) -> std::result::Result<Self::Value, E> {
321 Ok(Some(value))
322 }
323
324 fn visit_u64<E>(self, value: u64) -> std::result::Result<Self::Value, E> {
327 Ok(Some(value as f64))
328 }
329
330 fn visit_str<E>(self, _value: &str) -> std::result::Result<Self::Value, E> {
333 Ok(None)
334 }
335 }
336 deserializer.deserialize_any(PctVisitor)
337}
338
339pub fn parse_vitest_report(json: &str) -> Result<VitestReport> {
341 serde_json::from_str(json).context("parsing vitest coverage-summary JSON report")
342}
343
344pub fn evaluate_typescript(report: &VitestReport, thresholds: TypeScriptThresholds) -> Outcome {
353 let total = &report.total;
354 if total.lines.total == 0 {
358 return Outcome::Fail(
359 "the unit suite measured no code — check the path and that the suite runs".to_string(),
360 );
361 }
362 let checks = [
363 ("lines", total.lines, thresholds.lines),
364 ("branches", total.branches, thresholds.branches),
365 ("functions", total.functions, thresholds.functions),
366 ("statements", total.statements, thresholds.statements),
367 ];
368 let mut shortfalls = Vec::new();
369 for (name, metric, required) in checks {
370 let actual = metric.pct.unwrap_or(100.0);
373 if actual + 1e-9 < f64::from(required) {
376 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
377 }
378 }
379 if shortfalls.is_empty() {
380 Outcome::Pass
381 } else {
382 Outcome::Fail(format!(
383 "coverage below thresholds: {}",
384 shortfalls.join(", ")
385 ))
386 }
387}
388
389pub fn measure_typescript(
399 root: &Path,
400 thresholds: TypeScriptThresholds,
401 exclude: &[String],
402) -> Result<Outcome> {
403 let report = run_vitest(root, exclude)?;
404 Ok(evaluate_typescript(&report, thresholds))
405}
406
407struct ReportDir(PathBuf);
411
412impl ReportDir {
413 fn new() -> Self {
414 static COUNTER: AtomicU64 = AtomicU64::new(0);
415 let name = format!(
416 "testing-conventions-vitest-{}-{}",
417 std::process::id(),
418 COUNTER.fetch_add(1, Ordering::Relaxed),
419 );
420 ReportDir(std::env::temp_dir().join(name))
421 }
422}
423
424impl Drop for ReportDir {
425 fn drop(&mut self) {
426 let _ = std::fs::remove_dir_all(&self.0);
427 }
428}
429
430fn run_vitest(root: &Path, exclude: &[String]) -> Result<VitestReport> {
432 let json = run_vitest_coverage(root, exclude, "json-summary", "coverage-summary.json")?;
433 parse_vitest_report(&json)
434}
435
436fn run_vitest_coverage(
449 root: &Path,
450 exclude: &[String],
451 reporter: &str,
452 report_file: &str,
453) -> Result<String> {
454 let reports = ReportDir::new();
455
456 let mut command = Command::new("npx");
457 command
458 .current_dir(root)
459 .args(["--yes", "vitest", "run", "--no-cache"])
460 .args(["--coverage.enabled", "--coverage.provider=v8"])
461 .arg(format!("--coverage.reporter={reporter}"))
462 .arg("--coverage.all=true")
463 .arg(format!(
464 "--coverage.reportsDirectory={}",
465 reports.0.display()
466 ))
467 .arg(format!("--coverage.include={TS_INCLUDE}"))
468 .arg(format!("--coverage.exclude={TS_TEST_EXCLUDE}"))
469 .arg(format!("--coverage.exclude={TS_DECL_EXCLUDE}"));
470 for path in exclude {
471 command.arg(format!("--coverage.exclude={path}"));
472 }
473 let run = command.env("CI", "1").output().context(
475 "running `npx vitest run --coverage` (are vitest and @vitest/coverage-v8 installed?)",
476 )?;
477 if !run.status.success() {
478 bail!(
479 "the unit suite did not run cleanly under vitest in `{}`:\n{}{}",
480 root.display(),
481 String::from_utf8_lossy(&run.stdout),
482 String::from_utf8_lossy(&run.stderr),
483 );
484 }
485
486 let path = reports.0.join(report_file);
487 std::fs::read_to_string(&path).with_context(|| {
488 format!(
489 "reading vitest coverage report `{}` (did the run produce a {reporter} report?)",
490 path.display()
491 )
492 })
493}
494
495pub fn measure_patch_typescript(
515 root: &Path,
516 exclude: &[String],
517) -> Result<BTreeMap<String, BTreeSet<u64>>> {
518 let json = run_vitest_coverage(root, exclude, "json", "coverage-final.json")?;
519 uncovered_istanbul_lines(&json)
520}
521
522#[derive(Debug, Clone, Deserialize)]
526struct IstanbulFile {
527 #[serde(rename = "statementMap", default)]
530 statement_map: BTreeMap<String, IstanbulSpan>,
531 #[serde(default)]
533 s: BTreeMap<String, u64>,
534 #[serde(rename = "branchMap", default)]
537 branch_map: BTreeMap<String, IstanbulBranch>,
538 #[serde(default)]
540 b: BTreeMap<String, Vec<u64>>,
541}
542
543#[derive(Debug, Clone, Deserialize)]
545struct IstanbulSpan {
546 start: IstanbulPos,
547 end: IstanbulPos,
548}
549
550#[derive(Debug, Clone, Deserialize)]
552struct IstanbulPos {
553 line: u64,
554}
555
556#[derive(Debug, Clone, Deserialize)]
559struct IstanbulBranch {
560 loc: IstanbulSpan,
561}
562
563fn uncovered_istanbul_lines(json: &str) -> Result<BTreeMap<String, BTreeSet<u64>>> {
570 let files: BTreeMap<String, IstanbulFile> = serde_json::from_str(json)
571 .context("parsing vitest coverage-final (Istanbul) JSON report")?;
572 let mut out = BTreeMap::new();
573 for (path, file) in files {
574 let mut lines = BTreeSet::new();
575 for (id, span) in &file.statement_map {
578 if file.s.get(id) == Some(&0) {
579 lines.extend(span.start.line..=span.end.line);
580 }
581 }
582 for (id, branch) in &file.branch_map {
585 if file.b.get(id).is_some_and(|counts| counts.contains(&0)) {
586 lines.insert(branch.loc.start.line);
587 }
588 }
589 out.insert(path, lines);
590 }
591 Ok(out)
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
599 CoverageReport {
600 totals: Totals {
601 percent_covered,
602 num_branches,
603 },
604 files: BTreeMap::new(),
605 }
606 }
607
608 #[test]
609 fn passes_when_total_meets_the_floor() {
610 assert_eq!(
611 evaluate(
612 &report(100.0, 12),
613 Thresholds {
614 fail_under: 100,
615 branch: true
616 }
617 ),
618 Outcome::Pass
619 );
620 }
621
622 #[test]
623 fn fails_when_total_is_below_the_floor() {
624 assert!(matches!(
625 evaluate(
626 &report(80.0, 12),
627 Thresholds {
628 fail_under: 100,
629 branch: true
630 }
631 ),
632 Outcome::Fail(_)
633 ));
634 }
635
636 #[test]
637 fn fails_when_branch_required_but_unmeasured() {
638 assert!(matches!(
640 evaluate(
641 &report(100.0, 0),
642 Thresholds {
643 fail_under: 90,
644 branch: true
645 }
646 ),
647 Outcome::Fail(_)
648 ));
649 }
650
651 #[test]
652 fn parses_a_coverage_py_report() {
653 let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
654 let report = parse_report(json).expect("valid coverage.py json");
655 assert_eq!(report.totals.percent_covered, 91.5);
656 assert_eq!(report.totals.num_branches, 8);
657 }
658
659 #[test]
660 fn parses_the_per_file_block_for_patch_coverage() {
661 let json = r#"{
664 "files": {
665 "widget.py": {
666 "executed_lines": [1, 2, 3, 4, 6],
667 "summary": {"percent_covered": 85.0},
668 "missing_lines": [5],
669 "excluded_lines": [],
670 "missing_branches": [[4, 5]]
671 }
672 },
673 "totals": {"percent_covered": 85.0, "num_branches": 4}
674 }"#;
675 let report = parse_report(json).expect("valid coverage.py json with files");
676 let widget = report.files.get("widget.py").expect("widget.py is present");
677 assert_eq!(widget.missing_lines, vec![5]);
678 assert_eq!(widget.missing_branches, vec![vec![4, 5]]);
679 assert_eq!(report.totals.percent_covered, 85.0);
681 }
682
683 #[test]
684 fn a_report_without_a_files_block_parses_with_an_empty_map() {
685 let report = parse_report(r#"{"totals":{"percent_covered":100.0,"num_branches":2}}"#)
687 .expect("valid coverage.py json");
688 assert!(report.files.is_empty());
689 }
690
691 #[test]
692 fn omit_is_the_test_and_support_globs_when_nothing_is_exempt() {
693 assert_eq!(build_omit(&[]), "*_test.py,*conftest.py");
694 }
695
696 #[test]
697 fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
698 let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
700 assert_eq!(
701 build_omit(&exempt),
702 "*_test.py,*conftest.py,pkg/gen.py,shim.py"
703 );
704 }
705
706 fn metric(pct: f64) -> VitestMetric {
709 VitestMetric {
710 pct: Some(pct),
711 total: 10,
712 }
713 }
714
715 fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
716 VitestReport {
717 total: VitestTotals {
718 lines: metric(lines),
719 branches: metric(branches),
720 functions: metric(functions),
721 statements: metric(statements),
722 },
723 }
724 }
725
726 const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
727 lines: 100,
728 branches: 100,
729 functions: 100,
730 statements: 100,
731 };
732 const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
733 lines: 80,
734 branches: 75,
735 functions: 80,
736 statements: 80,
737 };
738
739 #[test]
740 fn typescript_passes_when_every_metric_meets_its_floor() {
741 assert_eq!(
742 evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
743 Outcome::Pass
744 );
745 }
746
747 #[test]
748 fn typescript_fails_on_the_one_metric_below_its_floor() {
749 let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
753 assert!(
754 matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
755 "got: {outcome:?}"
756 );
757 }
758
759 #[test]
760 fn typescript_fail_message_names_every_metric_below() {
761 let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
762 assert!(
763 matches!(&outcome, Outcome::Fail(message)
764 if message.contains("lines")
765 && message.contains("branches")
766 && message.contains("functions")
767 && message.contains("statements")),
768 "got: {outcome:?}"
769 );
770 }
771
772 #[test]
773 fn typescript_tolerates_float_noise_at_the_floor() {
774 assert_eq!(
776 evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
777 Outcome::Pass
778 );
779 }
780
781 #[test]
782 fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
783 let report = VitestReport {
786 total: VitestTotals {
787 lines: metric(100.0),
788 branches: VitestMetric {
789 pct: None,
790 total: 0,
791 },
792 functions: metric(100.0),
793 statements: metric(100.0),
794 },
795 };
796 assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
797 }
798
799 #[test]
800 fn typescript_fails_a_vacuous_run_that_measured_no_code() {
801 let nothing = VitestMetric {
804 pct: None,
805 total: 0,
806 };
807 let report = VitestReport {
808 total: VitestTotals {
809 lines: nothing,
810 branches: nothing,
811 functions: nothing,
812 statements: nothing,
813 },
814 };
815 let outcome = evaluate_typescript(&report, TS_MID);
816 assert!(
817 matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
818 "got: {outcome:?}"
819 );
820 }
821
822 #[test]
823 fn parses_a_vitest_summary_report() {
824 let json = r#"{
827 "total": {
828 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
829 "statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
830 "functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
831 "branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
832 "branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
833 },
834 "/abs/widget.ts": {
835 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
836 }
837 }"#;
838 let report = parse_vitest_report(json).expect("valid vitest json-summary");
839 assert_eq!(report.total.lines.pct, Some(80.0));
841 assert_eq!(report.total.branches.pct, Some(66.66));
842 assert_eq!(report.total.functions.total, 2);
843 }
844
845 #[test]
846 fn parses_an_unknown_pct_as_unmeasured() {
847 let json = r#"{"total": {
848 "lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
849 "statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
850 "functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
851 "branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
852 }}"#;
853 let report = parse_vitest_report(json).expect("valid vitest json-summary");
854 assert_eq!(report.total.lines.pct, None);
855 assert_eq!(report.total.lines.total, 0);
856 }
857
858 #[test]
859 fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
860 let json = r#"{"total":{
863 "lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
864 "statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
865 "functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
866 "branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
867 }}"#;
868 assert!(parse_vitest_report(json).is_err());
869 }
870
871 #[test]
874 fn istanbul_flags_an_unexecuted_statement() {
875 let json = r#"{"/abs/widget.ts":{
877 "statementMap":{"0":{"start":{"line":1,"column":0},"end":{"line":1,"column":40}},
878 "1":{"start":{"line":2,"column":2},"end":{"line":2,"column":20}}},
879 "s":{"0":1,"1":0},
880 "branchMap":{},"b":{}
881 }}"#;
882 let out = uncovered_istanbul_lines(json).unwrap();
883 assert_eq!(out["/abs/widget.ts"], BTreeSet::from([2]));
884 }
885
886 #[test]
887 fn istanbul_flags_an_untaken_branch_source() {
888 let json = r#"{"/abs/widget.ts":{
891 "statementMap":{"0":{"start":{"line":3,"column":2},"end":{"line":3,"column":20}}},
892 "s":{"0":5},
893 "branchMap":{"0":{"loc":{"start":{"line":3,"column":2},"end":{"line":3,"column":40}}}},
894 "b":{"0":[4,0]}
895 }}"#;
896 let out = uncovered_istanbul_lines(json).unwrap();
897 assert_eq!(out["/abs/widget.ts"], BTreeSet::from([3]));
898 }
899
900 #[test]
901 fn istanbul_v8_single_arm_branch_counts_as_uncovered() {
902 let json = r#"{"/abs/widget.ts":{
905 "statementMap":{},"s":{},
906 "branchMap":{"0":{"loc":{"start":{"line":7,"column":0},"end":{"line":7,"column":3}}}},
907 "b":{"0":[0]}
908 }}"#;
909 let out = uncovered_istanbul_lines(json).unwrap();
910 assert_eq!(out["/abs/widget.ts"], BTreeSet::from([7]));
911 }
912
913 #[test]
914 fn istanbul_spans_every_line_of_an_unexecuted_multiline_statement() {
915 let json = r#"{"/abs/widget.ts":{
917 "statementMap":{"0":{"start":{"line":4,"column":2},"end":{"line":6,"column":3}}},
918 "s":{"0":0},
919 "branchMap":{},"b":{}
920 }}"#;
921 let out = uncovered_istanbul_lines(json).unwrap();
922 assert_eq!(out["/abs/widget.ts"], BTreeSet::from([4, 5, 6]));
923 }
924
925 #[test]
926 fn istanbul_fully_covered_file_has_no_uncovered_lines() {
927 let json = r#"{"/abs/widget.ts":{
928 "statementMap":{"0":{"start":{"line":1,"column":0},"end":{"line":1,"column":40}}},
929 "s":{"0":3},
930 "branchMap":{"0":{"loc":{"start":{"line":1,"column":0},"end":{"line":1,"column":40}}}},
931 "b":{"0":[2,1]}
932 }}"#;
933 let out = uncovered_istanbul_lines(json).unwrap();
934 assert!(out["/abs/widget.ts"].is_empty());
935 }
936
937 #[test]
938 fn istanbul_widget_report_flags_statement_and_branch_lines() {
939 let json = r#"{"/abs/widget.ts":{
943 "statementMap":{
944 "0":{"start":{"line":1,"column":0},"end":{"line":1,"column":43}},
945 "1":{"start":{"line":2,"column":2},"end":{"line":2,"column":25}},
946 "2":{"start":{"line":3,"column":2},"end":{"line":3,"column":16}},
947 "3":{"start":{"line":4,"column":4},"end":{"line":4,"column":20}},
948 "4":{"start":{"line":5,"column":2},"end":{"line":5,"column":3}},
949 "5":{"start":{"line":6,"column":2},"end":{"line":6,"column":15}}
950 },
951 "s":{"0":1,"1":2,"2":2,"3":0,"4":0,"5":1},
952 "branchMap":{
953 "0":{"loc":{"start":{"line":2,"column":2},"end":{"line":2,"column":25}}},
954 "1":{"loc":{"start":{"line":3,"column":2},"end":{"line":3,"column":16}}}
955 },
956 "b":{"0":[2],"1":[0]}
957 }}"#;
958 let out = uncovered_istanbul_lines(json).unwrap();
959 assert_eq!(out["/abs/widget.ts"], BTreeSet::from([3, 4, 5]));
960 }
961
962 #[test]
963 fn istanbul_malformed_json_is_an_error() {
964 assert!(uncovered_istanbul_lines("{ not json").is_err());
965 }
966}