bynk_driver/coverage.rs
1//! `bynkc test --coverage` — remap V8 line coverage onto `.bynk` source.
2//!
3//! The test runner already owns the two artefacts a coverage tool needs and a
4//! user cannot reconstruct: it launches the `node` process that executes the
5//! suite, and it holds the source maps from `.bynk` → emitted `.ts`. This module
6//! is the "one genuinely new piece" the coverage proposal (issue #854, ADR
7//! recorded at merge) calls out: it reads the raw V8 coverage the runtime writes
8//! to `NODE_V8_COVERAGE`, and attributes each executed / unexecuted line back to
9//! `.bynk` source through **two** line-level source-map hops:
10//!
11//! 1. `out-js/**/*.js.map` — tsc's map, `.js` line → emitted `.ts` line. `tsc`
12//! does **not** chain input maps, so this hop only reaches the `.ts`.
13//! 2. `out/**/*.ts.map` — the emitter's map (ADR 0103), `.ts` line → `.bynk`
14//! line. Statement-anchored and line-level (generated column always 0).
15//!
16//! Composed, a covered `.js` line lands on a `.bynk` line. Emitted glue with no
17//! `.bynk` origin (codec wrappers, capability injection, the module header) is
18//! **unmapped** in hop 2, so it contributes nothing — it is counted as
19//! out-of-scope, never as uncovered user code (the proposal's map-fidelity
20//! mitigation, for free).
21//!
22//! **Decisions realised here** (recorded in the ADR): line/statement coverage
23//! only, no branch coverage (DECISION B) — a `.bynk` line is *covered* if any
24//! generated line mapping to it executed; and the measured set excludes the
25//! `tests/` tree and the workers scaffold (DECISION D), filtered once on the
26//! `out-js`-relative path of the executed `.js` — the emitted tree's own
27//! top-level `tests/`/`workers/` dirs — before the maps are even consulted. The
28//! `.bynk` side is deliberately *not* re-filtered, so a user source that merely
29//! lives under a dir named `tests`/`workers` is still measured.
30
31use std::collections::{BTreeMap, HashMap};
32use std::path::{Component, Path, PathBuf};
33
34use serde::Deserialize;
35
36/// Per-`.bynk`-file line coverage, keyed by a project-relative display path.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct FileCoverage {
39 /// Project-relative `.bynk` path (forward-slashed), e.g. `src/limiter.bynk`.
40 pub path: String,
41 /// Covered executable lines (1-based count).
42 pub covered: u32,
43 /// Total executable lines attributed to this file (1-based count).
44 pub total: u32,
45 /// The uncovered executable lines, 1-based and ascending — the exact set the
46 /// proposal's fixtures pin.
47 pub uncovered: Vec<u32>,
48 /// The covered executable lines, 1-based and ascending (#2104): with
49 /// `uncovered`, every executable line, so an editor can mark both.
50 pub covered_lines: Vec<u32>,
51}
52
53/// A whole-run coverage report: one [`FileCoverage`] per measured `.bynk` file,
54/// sorted by path, plus the derived totals.
55#[derive(Debug, Clone, PartialEq, Eq, Default)]
56pub struct CoverageReport {
57 pub files: Vec<FileCoverage>,
58}
59
60impl CoverageReport {
61 /// Total covered executable lines across every measured file.
62 pub fn total_covered(&self) -> u32 {
63 self.files.iter().map(|f| f.covered).sum()
64 }
65
66 /// Total executable lines across every measured file.
67 pub fn total_lines(&self) -> u32 {
68 self.files.iter().map(|f| f.total).sum()
69 }
70
71 /// Whole-run percentage (0–100), rounded to the nearest integer. A run that
72 /// attributed no executable line is reported as 100% (nothing to cover).
73 pub fn total_percent(&self) -> u32 {
74 percent(self.total_covered(), self.total_lines())
75 }
76
77 /// Whether the report attributed no `.bynk` line at all — an empty measured
78 /// set (e.g. an integration-only project whose only executed code is the
79 /// workers scaffold DECISION D drops).
80 pub fn is_empty(&self) -> bool {
81 self.files.is_empty()
82 }
83}
84
85/// Coverage percentage of `covered`/`total`, rounded; `total == 0` → 100. Only a
86/// genuinely complete run reads 100%: round-half-up would report `995/1000` as
87/// `100%` while lines are still uncovered — self-contradicting in the table and
88/// a false green for a CI gate keyed on the JSON `percent` — so a run with any
89/// uncovered line is clamped to at most 99.
90pub fn percent(covered: u32, total: u32) -> u32 {
91 if total == 0 || covered >= total {
92 100
93 } else {
94 let rounded = (covered as u64 * 100 + total as u64 / 2) / total as u64;
95 (rounded as u32).min(99)
96 }
97}
98
99// -- V8 coverage JSON (the `NODE_V8_COVERAGE` output shape) --
100
101#[derive(Deserialize)]
102struct V8Document {
103 #[serde(default)]
104 result: Vec<V8Script>,
105}
106
107#[derive(Deserialize)]
108struct V8Script {
109 url: String,
110 #[serde(default)]
111 functions: Vec<V8Function>,
112}
113
114#[derive(Deserialize)]
115struct V8Function {
116 #[serde(default)]
117 ranges: Vec<V8Range>,
118}
119
120#[derive(Deserialize)]
121struct V8Range {
122 #[serde(rename = "startOffset")]
123 start: usize,
124 #[serde(rename = "endOffset")]
125 end: usize,
126 count: i64,
127 /// Set after parsing on the first range of a V8 function entry, which is
128 /// the whole function's own range; the rest are nested block ranges.
129 #[serde(skip)]
130 function: bool,
131}
132
133/// Collect coverage from a finished run and attribute it to `.bynk` source.
134///
135/// - `v8_dir` — the directory `NODE_V8_COVERAGE` was pointed at.
136/// - `out_js_root` — where the executed `.js` and tsc's `.js.map` live.
137/// - `out_root` — where the emitted `.ts` and the emitter's `.ts.map` live.
138/// - `source_root` — the project root the `.bynk` paths are relativised against.
139///
140/// Any file it cannot read or parse is skipped rather than failing the run —
141/// coverage is a report *about* a run that already happened, so a partial map
142/// should degrade the numbers, never abort. Reading the V8 directory is the one
143/// hard error surfaced (it is the runner's own temp dir).
144pub fn collect_coverage(
145 v8_dir: &Path,
146 out_js_root: &Path,
147 out_root: &Path,
148 source_root: &Path,
149) -> std::io::Result<CoverageReport> {
150 let canonical_root = source_root.canonicalize().ok();
151 // 1. Fold every V8 document into per-script merged ranges. A single-process
152 // run writes one file, but Node may split across several; merging the
153 // ranges (and taking the innermost at lookup time) is correct either way.
154 let mut scripts: HashMap<PathBuf, Vec<V8Range>> = HashMap::new();
155 for entry in std::fs::read_dir(v8_dir)? {
156 let path = entry?.path();
157 if path.extension().and_then(|e| e.to_str()) != Some("json") {
158 continue;
159 }
160 let Ok(text) = std::fs::read_to_string(&path) else {
161 continue;
162 };
163 let Ok(doc) = serde_json::from_str::<V8Document>(&text) else {
164 continue;
165 };
166 for script in doc.result {
167 let Some(fs_path) = file_url_to_path(&script.url) else {
168 continue;
169 };
170 let canon = std::fs::canonicalize(&fs_path).unwrap_or(fs_path);
171 let ranges = scripts.entry(canon).or_default();
172 for func in script.functions {
173 let first = ranges.len();
174 ranges.extend(func.ranges);
175 if let Some(r) = ranges.get_mut(first) {
176 r.function = true;
177 }
178 }
179 }
180 }
181
182 let out_js_canon =
183 std::fs::canonicalize(out_js_root).unwrap_or_else(|_| out_js_root.to_path_buf());
184
185 // Accumulate per `.bynk` file: the executable lines seen, and which executed.
186 let mut acc: BTreeMap<String, FileAcc> = BTreeMap::new();
187
188 for (script_id, (script_path, ranges)) in scripts.iter().enumerate() {
189 let Ok(rel) = script_path.strip_prefix(&out_js_canon) else {
190 continue;
191 };
192 // DECISION D: drop the workers scaffold, the emitted test modules, and
193 // the runtime — before the maps are consulted. These are the emitted
194 // `out-js` paths, so the filter is on the generated tree, not `.bynk`.
195 if !is_measurable_emitted(rel) {
196 continue;
197 }
198 let Ok(js_text) = std::fs::read_to_string(script_path) else {
199 continue;
200 };
201 // Hop 1: tsc's `.js.map` sits beside the `.js`.
202 let js_map_path = append_ext(script_path, "map");
203 let Some(js_map) = std::fs::read_to_string(&js_map_path)
204 .ok()
205 .and_then(|s| SourceMap::parse(&s))
206 else {
207 continue;
208 };
209 // Hop 2: the emitter's `.ts.map`, mirrored under `out/` at the same rel
210 // path (tsc's `rootDir: "."`, `outDir: "../out-js"` keeps the tree 1:1).
211 let ts_rel = rel.with_extension("ts");
212 let ts_map_path = append_ext(&out_root.join(&ts_rel), "map");
213 let Some(ts_map) = std::fs::read_to_string(&ts_map_path)
214 .ok()
215 .and_then(|s| SourceMap::parse(&s))
216 else {
217 continue;
218 };
219
220 let line_reps = line_representatives(&js_text);
221 for (js_line, rep_off) in line_reps.iter().enumerate() {
222 let Some(off) = rep_off else { continue };
223 // The verdict for this generated line is the *innermost* V8 range
224 // covering it — see [`innermost_range`]. A generated line covered by
225 // no range (the trailing `sourceMappingURL` comments) attributes
226 // nothing.
227 let Some(hit) = innermost_range(*off, ranges) else {
228 continue;
229 };
230 let Some(js_segs) = js_map.lines.get(js_line) else {
231 continue;
232 };
233 for &(_js_src, ts_line) in js_segs {
234 let Some(ts_segs) = ts_map.lines.get(ts_line as usize) else {
235 continue;
236 };
237 for &(bynk_src, bynk_line) in ts_segs {
238 let Some(bynk_abs) = ts_map.sources.get(bynk_src) else {
239 continue;
240 };
241 let disp = relativise(bynk_abs, source_root, canonical_root.as_deref());
242 // DECISION D is enforced once, authoritatively, on the emitted
243 // tree by `is_measurable_emitted` (the `tests/` and `workers/`
244 // top-level dirs of `out-js`). We deliberately do *not* re-filter
245 // on the `.bynk` side: a user source that merely lives under a
246 // dir named `tests`/`workers` (e.g. `src/workers/helpers.bynk`)
247 // is real code whose `.js` already passed the emitted filter, so
248 // dropping it here would silently omit it from coverage.
249 let file = acc.entry(disp).or_default();
250 // Lines are 1-based in every report the user sees. See
251 // [`FileAcc::observe`] for how the generated lines mapping to
252 // one `.bynk` line combine into its verdict.
253 let line1 = bynk_line + 1;
254 file.observe(line1, script_id, hit);
255 }
256 }
257 }
258 }
259
260 let files = acc
261 .into_iter()
262 .map(|(path, a)| {
263 let total = a.lines.len() as u32;
264 let mut covered_lines = Vec::new();
265 let mut uncovered = Vec::new();
266 for (&line, verdict) in &a.lines {
267 if verdict.covered() {
268 covered_lines.push(line);
269 } else {
270 uncovered.push(line);
271 }
272 }
273 FileCoverage {
274 path,
275 covered: covered_lines.len() as u32,
276 total,
277 uncovered,
278 covered_lines,
279 }
280 })
281 .collect();
282 Ok(CoverageReport { files })
283}
284
285#[derive(Default)]
286struct FileAcc {
287 /// Per 1-based `.bynk` line, what the generated lines mapping to it showed.
288 /// `BTreeMap` keeps the uncovered list ascending for free.
289 lines: BTreeMap<u32, LineVerdict>,
290}
291
292#[derive(Default)]
293struct LineVerdict {
294 /// Executed (`count > 0`) ranges mapping to the line, as `(script, start, end)`.
295 executed: Vec<(usize, usize, usize)>,
296 /// Never-entered function ranges mapping to the line, in the same form.
297 uncalled_fns: Vec<(usize, usize, usize)>,
298}
299
300impl LineVerdict {
301 /// Covered if some executed range does not strictly contain an uncalled
302 /// function that maps to the line. Offsets are only comparable within one
303 /// script, hence the script id.
304 fn covered(&self) -> bool {
305 self.executed.iter().any(|&(es, estart, eend)| {
306 !self.uncalled_fns.iter().any(|&(fs, fstart, fend)| {
307 es == fs && estart <= fstart && fend <= eend && (fend - fstart) < (eend - estart)
308 })
309 })
310 }
311}
312
313impl FileAcc {
314 /// Record that a generated position in `script` whose innermost V8 range is
315 /// `hit` maps to `line`.
316 ///
317 /// The line is covered if some generated line mapping to it executed
318 /// (ADR 0271 DECISION B), with one exception: an uncalled *function* vetoes
319 /// an executed range that strictly contains it. That keeps a hoisted export
320 /// (a whole-module range enclosing the function, mapped to its declaration
321 /// line) from masking an uncalled function, while an unrelated sibling
322 /// function, whatever its size, decides nothing. A not-taken *block* never
323 /// vetoes: whether V8 split a block range out depends on which code ran, so
324 /// letting it decide made one line's verdict vary with the test subset
325 /// (#2270).
326 fn observe(&mut self, line: u32, script: usize, hit: Hit) {
327 let slot = self.lines.entry(line).or_default();
328 let entry = (script, hit.start, hit.end);
329 if hit.count > 0 {
330 slot.executed.push(entry);
331 } else if hit.function {
332 slot.uncalled_fns.push(entry);
333 }
334 }
335}
336
337/// A V8 range containing a sampled offset.
338#[derive(Clone, Copy, Debug, PartialEq, Eq)]
339struct Hit {
340 start: usize,
341 end: usize,
342 count: i64,
343 /// A whole-function range, as opposed to a nested block range.
344 function: bool,
345}
346
347/// The **innermost** (smallest-span) V8 range containing `off`. V8 block coverage
348/// nests a not-taken block's `count: 0` range inside
349/// its enclosing function's `count: N` range, so the smallest containing range
350/// is the precise verdict for that position. `None` if no range contains `off`.
351fn innermost_range(off: usize, ranges: &[V8Range]) -> Option<Hit> {
352 let mut best: Option<Hit> = None;
353 for r in ranges {
354 if r.start <= off && off < r.end {
355 let span = r.end - r.start;
356 match best {
357 Some(b)
358 if span > b.end - b.start
359 || (span == b.end - b.start && r.count <= b.count) => {}
360 _ => {
361 best = Some(Hit {
362 start: r.start,
363 end: r.end,
364 count: r.count,
365 function: r.function,
366 })
367 }
368 }
369 }
370 }
371 best
372}
373
374/// For each generated line, the **UTF-16 offset** of its first non-whitespace
375/// char — the position sampled against the V8 ranges. `None` for a blank line
376/// (nothing to attribute; such lines carry no mapping anyway).
377///
378/// #2146: a leading `export ` is skipped. The runner compiles to ES modules, so
379/// a function line reads `export function f(…) {`, and V8's range for `f`
380/// starts at `function`: sampling the `export` keyword would land in the
381/// module's range, which ran at load, and count an uncalled function's
382/// declaration line as covered.
383///
384/// V8 coverage `startOffset`/`endOffset` index the source as a JS string, i.e.
385/// in UTF-16 code units, not UTF-8 bytes (the same space `v8-to-istanbul`/`c8`
386/// use). For ASCII-only output the two coincide, but a single non-ASCII char
387/// earlier in the file (a Unicode `.bynk` string literal carried into the `.js`)
388/// shifts every later byte offset relative to V8's counting, which would select
389/// the wrong range. So offsets are accumulated in UTF-16 units to match.
390fn line_representatives(text: &str) -> Vec<Option<usize>> {
391 let mut out = Vec::new();
392 let mut u16_off = 0usize;
393 for line in text.split_inclusive('\n') {
394 let mut rep = None;
395 let mut o = u16_off;
396 for c in line.chars() {
397 if c.is_whitespace() {
398 o += c.len_utf16();
399 } else {
400 rep = Some(o);
401 break;
402 }
403 }
404 if let Some(start) = rep {
405 let rest = line.trim_start();
406 if let Some(after) = rest.strip_prefix("export ")
407 && !after.trim().is_empty()
408 {
409 // `export ` and the spaces after it are ASCII, so the bytes
410 // skipped are as many UTF-16 units.
411 rep = Some(start + rest.len() - after.trim_start().len());
412 }
413 }
414 out.push(rep);
415 u16_off += line.chars().map(char::len_utf16).sum::<usize>();
416 }
417 out
418}
419
420/// Whether an emitted `out-js`-relative path is a file we measure: not the
421/// `tests/` tree, not the `workers/` scaffold, not the shared runtime, and a
422/// `.js` module (DECISION D).
423fn is_measurable_emitted(rel: &Path) -> bool {
424 if rel.extension().and_then(|e| e.to_str()) != Some("js") {
425 return false;
426 }
427 let mut comps = rel.components();
428 match comps.next() {
429 Some(Component::Normal(c)) if c == "tests" || c == "workers" => return false,
430 _ => {}
431 }
432 // The runtime helpers are framework code, never user source: the whole
433 // `__bynk_runtime/` tree (ADR 0615). Only the first path component is
434 // matched, so a user path that merely contains the name is still measured.
435 if matches!(rel.components().next(), Some(Component::Normal(c)) if c == "__bynk_runtime") {
436 return false;
437 }
438 true
439}
440
441/// Relativise an absolute `.bynk` source path against the project root, forward-
442/// slashed. Falls back to the file name, then the path verbatim, so an
443/// out-of-tree source (a synthetic unit) still renders something legible.
444///
445/// `canonical_root` is `source_root` canonicalised (#2104): a relative root
446/// (`bynk test .`) never prefixes an absolute map source, which collapsed
447/// `src/window.bynk` to `window.bynk`, so the absolute form is tried too.
448fn relativise(abs: &str, source_root: &Path, canonical_root: Option<&Path>) -> String {
449 let p = Path::new(abs);
450 for root in std::iter::once(source_root).chain(canonical_root) {
451 if let Ok(rel) = p.strip_prefix(root) {
452 return forward_slash(rel);
453 }
454 }
455 // The map source is stored forward-slashed absolute; the root may be a
456 // different textual form of the same dir. Fall back to the file name.
457 p.file_name()
458 .map(|n| n.to_string_lossy().into_owned())
459 .unwrap_or_else(|| abs.to_string())
460}
461
462fn forward_slash(p: &Path) -> String {
463 p.components()
464 .filter_map(|c| match c {
465 Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
466 _ => None,
467 })
468 .collect::<Vec<_>>()
469 .join("/")
470}
471
472/// Convert a `file://` URL to a filesystem path, minimally percent-decoding.
473/// Returns `None` for a non-`file:` URL (e.g. `node:internal/...`).
474fn file_url_to_path(url: &str) -> Option<PathBuf> {
475 let rest = url.strip_prefix("file://")?;
476 // `file:///abs` → `/abs`; a host part is not expected for local coverage.
477 let path = rest
478 .strip_prefix('/')
479 .map(|r| format!("/{r}"))
480 .unwrap_or_else(|| rest.to_string());
481 Some(PathBuf::from(percent_decode(&path)))
482}
483
484/// Minimal `%XX` percent-decoding — enough for paths with spaces in a temp dir.
485fn percent_decode(s: &str) -> String {
486 let bytes = s.as_bytes();
487 let mut out = Vec::with_capacity(bytes.len());
488 let mut i = 0;
489 while i < bytes.len() {
490 if bytes[i] == b'%' && i + 2 < bytes.len() {
491 let hi = (bytes[i + 1] as char).to_digit(16);
492 let lo = (bytes[i + 2] as char).to_digit(16);
493 if let (Some(h), Some(l)) = (hi, lo) {
494 out.push((h * 16 + l) as u8);
495 i += 3;
496 continue;
497 }
498 }
499 out.push(bytes[i]);
500 i += 1;
501 }
502 String::from_utf8_lossy(&out).into_owned()
503}
504
505/// Append `.ext` to a path's file name (`foo.js` + `map` → `foo.js.map`), unlike
506/// [`Path::with_extension`] which would replace `js`.
507fn append_ext(path: &Path, ext: &str) -> PathBuf {
508 let mut name = path
509 .file_name()
510 .map(|n| n.to_os_string())
511 .unwrap_or_default();
512 name.push(".");
513 name.push(ext);
514 path.with_file_name(name)
515}
516
517// -- Source-map v3 (the subset needed for line attribution) --
518
519/// A decoded source-map: its `sources` list and, per generated line (0-based),
520/// the segments on that line as `(source_index, source_line_0based)`. Generated
521/// and source columns are decoded to keep the VLQ deltas honest, then dropped —
522/// attribution is line-level on both hops.
523struct SourceMap {
524 sources: Vec<String>,
525 lines: Vec<Vec<(usize, u32)>>,
526}
527
528impl SourceMap {
529 fn parse(json: &str) -> Option<SourceMap> {
530 #[derive(Deserialize)]
531 struct Raw {
532 #[serde(default)]
533 sources: Vec<String>,
534 #[serde(default)]
535 mappings: String,
536 }
537 let raw: Raw = serde_json::from_str(json).ok()?;
538 Some(SourceMap {
539 sources: raw.sources,
540 lines: decode_line_mappings(&raw.mappings),
541 })
542 }
543}
544
545/// Decode a v3 `mappings` string into, per generated line, its `(src_idx,
546/// src_line)` segments (both 0-based). Follows the VLQ delta rules: the source
547/// index/line/column deltas persist across segments *and* lines; the generated
548/// column resets at each line boundary. A one-field segment (generated column
549/// only, no source) carries no attribution and is skipped.
550fn decode_line_mappings(mappings: &str) -> Vec<Vec<(usize, u32)>> {
551 let mut lines = Vec::new();
552 // Source index and source line are independent running totals that persist
553 // across segments and lines (the source column would be a third, but line
554 // attribution never needs it). The generated column resets each line and is
555 // irrelevant here, so it is decoded but discarded.
556 let (mut src, mut src_line) = (0i64, 0i64);
557 for seg_line in mappings.split(';') {
558 let mut segs = Vec::new();
559 for seg in seg_line.split(',') {
560 if seg.is_empty() {
561 continue;
562 }
563 let fields = vlq_decode(seg);
564 if fields.len() >= 4 {
565 src += fields[1];
566 src_line += fields[2];
567 if src >= 0 && src_line >= 0 {
568 segs.push((src as usize, src_line as u32));
569 }
570 }
571 }
572 lines.push(segs);
573 }
574 lines
575}
576
577/// Base64-VLQ-decode one segment into its signed fields.
578fn vlq_decode(seg: &str) -> Vec<i64> {
579 const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
580 let mut out = Vec::new();
581 let (mut shift, mut acc) = (0u32, 0i64);
582 for &c in seg.as_bytes() {
583 let Some(d) = B64.iter().position(|&b| b == c).map(|p| p as i64) else {
584 continue;
585 };
586 acc += (d & 0b11111) << shift;
587 if d & 0b100000 != 0 {
588 shift += 5;
589 } else {
590 let value = if acc & 1 == 1 { -(acc >> 1) } else { acc >> 1 };
591 out.push(value);
592 shift = 0;
593 acc = 0;
594 }
595 }
596 out
597}
598
599// -- Rich rendering --
600
601/// Render the human coverage summary — a per-file bar, percentage, covered/total
602/// line count, and the uncovered-line ranges — plus a total row. Trailing
603/// newline. An empty report renders a single "no coverage attributed" note.
604pub fn render_rich(report: &CoverageReport) -> String {
605 let mut out = String::new();
606 out.push_str("\nCoverage\n");
607 if report.is_empty() {
608 out.push_str(" (no executable `.bynk` lines were attributed)\n");
609 return out;
610 }
611 let name_w = report
612 .files
613 .iter()
614 .map(|f| f.path.len())
615 .max()
616 .unwrap_or(0)
617 .max(5);
618 for f in &report.files {
619 let pct = percent(f.covered, f.total);
620 out.push_str(&format!(
621 " {:<name_w$} {} {:>3}% ({}/{} lines)",
622 f.path,
623 bar(f.covered, f.total),
624 pct,
625 f.covered,
626 f.total,
627 ));
628 if !f.uncovered.is_empty() {
629 out.push_str(&format!(" uncovered: {}", format_ranges(&f.uncovered)));
630 }
631 out.push('\n');
632 }
633 let dashes = "─".repeat(name_w + 24);
634 out.push_str(&format!(" {dashes}\n"));
635 out.push_str(&format!(
636 " {:<name_w$} {} {:>3}% ({}/{} lines)\n",
637 "total",
638 bar(report.total_covered(), report.total_lines()),
639 report.total_percent(),
640 report.total_covered(),
641 report.total_lines(),
642 ));
643 out
644}
645
646/// An 8-cell coverage bar: filled `▓` proportional to the covered fraction, the
647/// remainder `·`.
648fn bar(covered: u32, total: u32) -> String {
649 const CELLS: u32 = 8;
650 let filled = if total == 0 {
651 CELLS
652 } else {
653 (covered as u64 * CELLS as u64 / total as u64) as u32
654 };
655 let mut s = String::new();
656 for _ in 0..filled {
657 s.push('▓');
658 }
659 for _ in filled..CELLS {
660 s.push('·');
661 }
662 s
663}
664
665/// Compress a sorted line list into comma-separated ranges: `[6,7,8,51]` →
666/// `"6-8, 51"`.
667pub fn format_ranges(lines: &[u32]) -> String {
668 let mut parts = Vec::new();
669 let mut i = 0;
670 while i < lines.len() {
671 let start = lines[i];
672 let mut end = start;
673 while i + 1 < lines.len() && lines[i + 1] == end + 1 {
674 end += 1;
675 i += 1;
676 }
677 if start == end {
678 parts.push(format!("{start}"));
679 } else {
680 parts.push(format!("{start}-{end}"));
681 }
682 i += 1;
683 }
684 parts.join(", ")
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 #[test]
692 fn a_relative_root_still_relativises_through_its_canonical_form() {
693 // #2104: `bynk test .` hands a relative root; the map's sources are
694 // absolute, so only the canonical root prefixes them.
695 let abs = "/work/shop/src/window.bynk";
696 assert_eq!(
697 relativise(abs, Path::new("."), Some(Path::new("/work/shop"))),
698 "src/window.bynk"
699 );
700 assert_eq!(
701 relativise(abs, Path::new("/work/shop"), None),
702 "src/window.bynk"
703 );
704 // Outside the root: the file name, as before.
705 assert_eq!(
706 relativise(abs, Path::new("."), Some(Path::new("/elsewhere"))),
707 "window.bynk"
708 );
709 }
710
711 #[test]
712 fn vlq_decodes_signed_fields() {
713 // "AAAA" → [0,0,0,0]; "AAEA" → [0,0,2,0]; "AACE" → [0,0,1,2]; "D" → [-1].
714 assert_eq!(vlq_decode("AAAA"), vec![0, 0, 0, 0]);
715 assert_eq!(vlq_decode("AAEA"), vec![0, 0, 2, 0]);
716 assert_eq!(vlq_decode("AACE"), vec![0, 0, 1, 2]);
717 assert_eq!(vlq_decode("D"), vec![-1]);
718 }
719
720 #[test]
721 fn line_mappings_track_running_source_line() {
722 // The emitter's `.ts.map` for the two-fn spike: 5 blank lines, then
723 // src lines 2,3,3,6,7,8 (0-based) — running deltas across lines.
724 let m = ";;;;;AAEA;AACE;AAAA;AAGF;AACE;AACA";
725 let lines = decode_line_mappings(m);
726 assert_eq!(lines[0], vec![]);
727 assert_eq!(lines[5], vec![(0, 2)]);
728 assert_eq!(lines[6], vec![(0, 3)]);
729 assert_eq!(lines[7], vec![(0, 3)]);
730 assert_eq!(lines[8], vec![(0, 6)]);
731 assert_eq!(lines[9], vec![(0, 7)]);
732 assert_eq!(lines[10], vec![(0, 8)]);
733 }
734
735 #[test]
736 fn innermost_range_prefers_the_tightest_span() {
737 // A module range (count 1) with a nested uncalled-fn range (count 0):
738 // a position inside the fn is uncovered, one outside it is covered.
739 let ranges = vec![
740 V8Range {
741 start: 0,
742 end: 100,
743 count: 1,
744 function: true,
745 },
746 V8Range {
747 start: 40,
748 end: 60,
749 count: 0,
750 function: true,
751 },
752 ];
753 let hit = |start, end, count| Hit {
754 start,
755 end,
756 count,
757 function: true,
758 };
759 assert_eq!(innermost_range(10, &ranges), Some(hit(0, 100, 1)));
760 assert_eq!(innermost_range(50, &ranges), Some(hit(40, 60, 0)));
761 assert_eq!(innermost_range(200, &ranges), None); // outside every range
762 }
763
764 fn hit(start: usize, end: usize, count: i64, function: bool) -> Hit {
765 Hit {
766 start,
767 end,
768 count,
769 function,
770 }
771 }
772
773 #[test]
774 fn file_acc_lets_an_uncalled_function_veto_the_executed_range_enclosing_it() {
775 // The hoisted-export hazard: a `.bynk` line reached both by a wide
776 // module range that ran (the `exports.f = f;` line) and the function
777 // range inside it that did not (the never-called body). → uncovered.
778 let mut acc = FileAcc::default();
779 acc.observe(7, 0, hit(0, 352, 1, true));
780 acc.observe(7, 0, hit(100, 161, 0, true));
781 assert!(!acc.lines[&7].covered());
782 }
783
784 #[test]
785 fn file_acc_does_not_let_an_unrelated_uncalled_function_veto() {
786 // #2270 review: two sibling helpers anchored to one line. Whatever their
787 // relative sizes, an uncalled one does not veto a called one.
788 for (called, uncalled) in [((0, 200), (300, 350)), ((0, 50), (300, 600))] {
789 let mut acc = FileAcc::default();
790 acc.observe(5, 0, hit(called.0, called.1, 2, true));
791 acc.observe(5, 0, hit(uncalled.0, uncalled.1, 0, true));
792 assert!(acc.lines[&5].covered(), "{called:?} vs {uncalled:?}");
793 }
794 // The same ranges in different scripts are never nested.
795 let mut acc = FileAcc::default();
796 acc.observe(5, 0, hit(0, 500, 1, true));
797 acc.observe(5, 1, hit(10, 20, 0, true));
798 assert!(acc.lines[&5].covered());
799 }
800
801 #[test]
802 fn file_acc_ignores_a_not_taken_block_on_an_executed_line() {
803 // #2270: a not-taken block range (a guard's `throw`, an unused arm)
804 // mapping to a line that also ran does not make the line uncovered.
805 let mut acc = FileAcc::default();
806 acc.observe(5, 0, hit(0, 671, 2, true));
807 acc.observe(5, 0, hit(10, 59, 0, false));
808 acc.observe(5, 0, hit(100, 214, 2, false));
809 assert!(acc.lines[&5].covered());
810 // With only the not-taken block, the line is uncovered.
811 let mut only = FileAcc::default();
812 only.observe(5, 0, hit(10, 59, 0, false));
813 assert!(!only.lines[&5].covered());
814 }
815
816 #[test]
817 fn file_acc_covers_a_line_when_an_executed_range_is_inside_an_uncalled_function() {
818 // The executed range is narrower than the uncalled function and nested
819 // in it (a count-N block inside a count-0 function cannot happen in V8,
820 // but equal spans must not veto either): covered.
821 let mut acc = FileAcc::default();
822 acc.observe(3, 0, hit(10, 20, 1, false));
823 acc.observe(3, 0, hit(0, 100, 0, true));
824 assert!(acc.lines[&3].covered());
825 let mut tie = FileAcc::default();
826 tie.observe(3, 0, hit(0, 100, 1, true));
827 tie.observe(3, 0, hit(0, 100, 0, true));
828 assert!(tie.lines[&3].covered());
829 }
830
831 #[test]
832 fn line_representatives_pick_first_nonspace() {
833 let reps = line_representatives("ab\n cd\n\n \nx");
834 assert_eq!(reps[0], Some(0)); // "ab"
835 assert_eq!(reps[1], Some(7)); // " cd" → 'c' at 3+4
836 assert_eq!(reps[2], None); // blank
837 assert_eq!(reps[3], None); // whitespace-only
838 assert_eq!(reps[4], Some(14)); // "x"
839 }
840
841 #[test]
842 fn line_representatives_skip_a_leading_export() {
843 // #2146: an ES module's `export function f() {` samples at `function`,
844 // where V8's range for `f` starts, not at `export` (the module range).
845 let reps = line_representatives("export function f() {\n export const x = 1;\nexport\n");
846 assert_eq!(reps[0], Some(7)); // "function"
847 assert_eq!(reps[1], Some(32)); // 22 + 2 indent + "export " → 'c'
848 assert_eq!(reps[2], Some(45)); // a bare `export` keeps its own offset
849 }
850
851 #[test]
852 fn line_representatives_count_utf16_units_not_bytes() {
853 // An em-dash (`—`, U+2014: 3 UTF-8 bytes, 1 UTF-16 unit) on line 0 must
854 // not shift line 1's offset — V8 counts UTF-16 units, so a byte-based
855 // accumulator would report 9 (7 + 2) here and mis-sample the ranges.
856 let reps = line_representatives("a — b\nx");
857 assert_eq!(reps[0], Some(0)); // "a — b"
858 // "a — b\n" = 6 UTF-16 units (byte length would be 8: '—' is 3 bytes).
859 assert_eq!(reps[1], Some(6)); // "x" at UTF-16 offset 6, not byte 8
860 // An astral char (`🎉`, 2 UTF-16 units) shifts by 2, matching V8.
861 let reps = line_representatives("🎉\ny");
862 assert_eq!(reps[0], Some(0));
863 assert_eq!(reps[1], Some(3)); // 🎉(2) + '\n'(1) = 3
864 }
865
866 #[test]
867 fn format_ranges_compresses_runs() {
868 assert_eq!(format_ranges(&[6, 7, 8, 51]), "6-8, 51");
869 assert_eq!(format_ranges(&[3]), "3");
870 assert_eq!(format_ranges(&[1, 2, 4, 5, 6]), "1-2, 4-6");
871 assert_eq!(format_ranges(&[]), "");
872 }
873
874 #[test]
875 fn percent_rounds_and_guards_zero() {
876 assert_eq!(percent(0, 0), 100);
877 assert_eq!(percent(42, 49), 86);
878 assert_eq!(percent(2, 5), 40);
879 assert_eq!(percent(11, 11), 100);
880 }
881
882 #[test]
883 fn percent_never_reports_100_for_an_incomplete_run() {
884 // Round-half-up would give 100 here; a run with any uncovered line must
885 // read at most 99 so the table and the JSON `percent` never falsely green.
886 assert_eq!(percent(995, 1000), 99);
887 assert_eq!(percent(199, 200), 99);
888 assert_eq!(percent(1000, 1000), 100); // genuinely complete
889 assert_eq!(percent(0, 5), 0);
890 }
891
892 #[test]
893 fn measurable_filter_drops_tests_workers_runtime() {
894 assert!(is_measurable_emitted(Path::new("src/limiter.js")));
895 assert!(is_measurable_emitted(Path::new("limiter.js")));
896 assert!(!is_measurable_emitted(Path::new("tests/main.js")));
897 assert!(!is_measurable_emitted(Path::new("workers/api/handlers.js")));
898 assert!(!is_measurable_emitted(Path::new(
899 "__bynk_runtime/result.js"
900 )));
901 assert!(is_measurable_emitted(Path::new("src/__bynk_runtime/x.js")));
902 assert!(!is_measurable_emitted(Path::new("src/limiter.js.map")));
903 }
904
905 #[test]
906 fn file_url_round_trips_to_path() {
907 assert_eq!(
908 file_url_to_path("file:///private/tmp/a%20b/out-js/m.js"),
909 Some(PathBuf::from("/private/tmp/a b/out-js/m.js"))
910 );
911 assert_eq!(file_url_to_path("node:internal/modules"), None);
912 }
913}