From 132785af7db49f9e11b1a9e3b2540777e5498622 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:30:56 +0000 Subject: [PATCH 1/7] test(coverage): attribute blocks to the lines of their code, not the lines around them Line coverage attributed every byte of a JSC basic block to a line. A block that follows a statement starts on the newline that ends that statement's line, and the indentation and closing delimiters of a generated line resolve through the printer's column-0 source map entry to the previous original line, so an unexecuted block blamed the brace line above it and the executed block around it marked its last dead statement as covered. - Only attribute offsets inside a line's trimmed code span, and ignore source map entries that merely copy the previous line's last mapping. - Treat inverted ranges as empty. JSC cuts nested functions out of a block; the initializer it synthesizes for class fields spans the whole file, and the leftovers came back as ranges like [len, 0], which were walked as 0..len and marked every line of such files as executed. - Mark the code lines of uncalled functions from their range instead of filling min..max-1 lines, which blamed comments and blank lines and skipped the last statement. - Drop the function entry JSC records for a class's synthesized default constructor; it has offsets into JSC's own source string and never executes. - Print every run in the text reporter's "Uncovered Line #s" column; a trailing single line was dropped. - Share one pass between the source map and generated-text paths; remove the now unused LineOffsetTable::find_index. --- src/sourcemap/LineOffsetTable.rs | 31 -- src/sourcemap/lib.rs | 2 +- src/sourcemap_jsc/CodeCoverage.rs | 502 ++++++++---------- .../test/__snapshots__/coverage.test.ts.snap | 22 +- test/cli/test/coverage.test.ts | 218 +++++++- 5 files changed, 438 insertions(+), 337 deletions(-) diff --git a/src/sourcemap/LineOffsetTable.rs b/src/sourcemap/LineOffsetTable.rs index de4051acf294..88a465c944f5 100644 --- a/src/sourcemap/LineOffsetTable.rs +++ b/src/sourcemap/LineOffsetTable.rs @@ -118,37 +118,6 @@ impl LineOffsetTable { Self::find_line(offsets, loc) } - pub fn find_index(byte_offsets_to_start_of_line: &[u32], loc: Loc) -> Option { - debug_assert!(loc.start > -1); // checked by caller - let mut original_line: usize = 0; - let loc_start = usize::try_from(loc.start).expect("int cast"); - - let mut count = byte_offsets_to_start_of_line.len(); - while count > 0 { - let step = count / 2; - let i = original_line + step; - let byte_offset = byte_offsets_to_start_of_line[i] as usize; - if byte_offset == loc_start { - return Some(i); - } - if i + 1 < byte_offsets_to_start_of_line.len() { - let next_byte_offset = byte_offsets_to_start_of_line[i + 1] as usize; - if byte_offset < loc_start && loc_start < next_byte_offset { - return Some(i); - } - } - - if byte_offset < loc_start { - original_line = i + 1; - count = count - step - 1; - } else { - count = step; - } - } - - None - } - /// `Global`-allocator convenience wrapper around [`generate_in`]. pub fn generate(contents: &[u8], approximate_line_count: i32) -> Result { Self::generate_in::(contents, approximate_line_count) diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index a2c098339941..657051c2577f 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -119,7 +119,7 @@ pub struct ParseFail { /// byte-compatible with this struct and bulk-copy them into the /// `MultiArrayList` column. #[repr(C)] -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq, Eq)] pub struct LineColumnOffset { /// The zero-based line offset pub lines: Ordinal, diff --git a/src/sourcemap_jsc/CodeCoverage.rs b/src/sourcemap_jsc/CodeCoverage.rs index 28a2e4e1808c..02f170c3c440 100644 --- a/src/sourcemap_jsc/CodeCoverage.rs +++ b/src/sourcemap_jsc/CodeCoverage.rs @@ -3,14 +3,12 @@ use core::ffi::{c_int, c_void}; use core::ptr::NonNull; use std::borrow::Cow; -use bun_ast::Loc; use bun_collections::VecExt; use bun_collections::bit_set::DynamicBitSet; use bun_core::{self, Utf8Bytes}; use bun_jsc::{JSGlobalObject, JSValue, VM, bun_string_jsc}; use bun_sourcemap::{ LineOffsetTable, LineOffsetTableColumns as _, Ordinal, ParsedSourceMap, internal_source_map, - line_offset_table, }; type LinesHits = Vec; @@ -610,6 +608,14 @@ impl Generator<'_, '_> { } } +/// A textual basic block (or, after `function_start_offset`, a whole function) as JSC's +/// control flow profiler reports it: inclusive `[start_offset, end_offset]` offsets into the +/// source text JSC executed. +/// +/// JSC cuts the functions nested in a block out of its range, so one block arrives as several +/// ranges. When those cut-outs overlap (the initializer JSC synthesizes for class fields spans +/// the whole file) the leftovers come out inverted, `[file_length, 0]` and the like, and +/// describe nothing. #[repr(C)] #[derive(Clone, Copy, Default)] pub struct BasicBlockRange { @@ -619,8 +625,64 @@ pub struct BasicBlockRange { execution_count: usize, } +impl BasicBlockRange { + fn has_executed(&self) -> bool { + self.has_executed || self.execution_count > 0 + } + + /// The offsets attributed to lines; empty for inverted ranges and for the `-1` JSC uses + /// when a range maps to nothing. + /// + /// `end_offset` itself is left out: a block runs up to the byte before the next block + /// starts, so its last byte is either the closing brace of the construct it ends at or the + /// last character of a statement whose other bytes already mark the line. Attributing it + /// would report every `}` line that follows a `return`/`throw`/`break` as uncovered. + /// (Function ranges end on their closing brace too.) + fn byte_offsets(&self) -> core::ops::Range { + match ( + u32::try_from(self.start_offset), + u32::try_from(self.end_offset), + ) { + (Ok(start), Ok(end)) => start..end, + _ => 0..0, + } + } + + /// A class without a `constructor` gets one synthesized by JSC from a fixed source string. + /// JSC records it in the function table of the file defining the class, but with offsets + /// into that string, and nothing ever marks that entry as executed. Without this filter + /// every such class counts as an uncalled function whose range lands on the first line of + /// the file. + fn is_synthesized_default_constructor(&self) -> bool { + // BuiltinExecutables::defaultConstructorSourceCode; the range runs from the + // `function` keyword to the closing brace. + const BASE: &str = "(function () { })"; + const DERIVED: &str = "(function (...args) { super(...args); })"; + const FUNCTION_KEYWORD: c_int = "(".len() as c_int; + const BASE_CLOSING_BRACE: c_int = (BASE.len() - "})".len()) as c_int; + const DERIVED_CLOSING_BRACE: c_int = (DERIVED.len() - "})".len()) as c_int; + + !self.has_executed() + && self.start_offset == FUNCTION_KEYWORD + && (self.end_offset == BASE_CLOSING_BRACE || self.end_offset == DERIVED_CLOSING_BRACE) + } +} + +/// One line of the source text JSC executed (the transpiled output, not the file on disk). +#[derive(Clone, Copy)] +struct Line { + start: u32, + /// `[code_start, code_end)` is the line with its indentation and line terminator trimmed + /// off. Only offsets inside it are attributed to the line: a block that begins after a + /// statement begins on the terminator of that statement's line, and the indentation of a + /// generated line maps back to the previous original line (see `OriginalLines`), so + /// attributing either would shift the block up by one line. + code_start: u32, + code_end: u32, +} + pub struct ByteRangeMapping { - pub(crate) line_offset_table: line_offset_table::List, + lines: Box<[Line]>, pub(crate) source_id: i32, pub source_url: Utf8Bytes<'static>, } @@ -686,10 +748,6 @@ impl ByteRangeMapping { ignore_sourcemap: bool, ) -> Result, bun_alloc::AllocError> { let source_url = self.source_url.slice(); - let line_starts = self.line_offset_table.items_byte_offset_to_start_of_line(); - - let mut executable_lines: Bitset; - let mut lines_which_have_executed: Bitset; // `SavedSourceMap::get` returns an `Option>`, so the // +1 ref is released automatically when `parsed_mappings_` drops at scope // exit — no explicit guard is required. @@ -699,297 +757,96 @@ impl ByteRangeMapping { bun_jsc::VirtualMachine::VirtualMachine::get().as_mut() .source_mappings() .get(source_url); - let mut line_hits: LinesHits; + // Without a source map (or with `coverageIgnoreSourcemaps`) the report is about the + // executed text itself and its line numbers are used as they are. + let mut original = match parsed_mappings_.as_deref() { + Some(source_map) if !ignore_sourcemap => Some(OriginalLines { + source_map, + cursor: source_map.internal_cursor(), + line_count: source_map.input_line_count + 1, + }), + _ => None, + }; + let line_count = original + .as_ref() + .map_or(self.lines.len(), |original| original.line_count); - let mut functions: Vec = Vec::new(); - functions.reserve_exact(function_blocks.len()); - let mut functions_which_have_executed: Bitset = Bitset::init_empty(function_blocks.len())?; - let mut stmts_which_have_executed: Bitset = Bitset::init_empty(blocks.len())?; + let mut executable_lines = Bitset::init_empty(line_count)?; + let mut lines_which_have_executed = Bitset::init_empty(line_count)?; + let mut line_hits: LinesHits = vec![0u32; line_count]; let mut stmts: Vec = Vec::new(); stmts.reserve_exact(blocks.len()); + let mut stmts_which_have_executed = Bitset::init_empty(blocks.len())?; - let line_count: u32; + for block in blocks { + let has_executed = block.has_executed(); + let byte_offsets = block.byte_offsets(); + let mut has_lines = false; - if ignore_sourcemap || parsed_mappings_.is_none() { - line_count = line_starts.len() as u32; - executable_lines = Bitset::init_empty(line_count as usize)?; - lines_which_have_executed = Bitset::init_empty(line_count as usize)?; - line_hits = vec![0u32; line_count as usize]; - let line_hits_slice = line_hits.as_mut_slice(); - - for block in blocks { - if block.end_offset < 0 || block.start_offset < 0 { - continue; // does not map to anything - } - - let min: usize = - usize::try_from(block.start_offset.min(block.end_offset)).expect("int cast"); - let max: usize = - usize::try_from(block.start_offset.max(block.end_offset)).expect("int cast"); - let mut min_line: u32 = u32::MAX; - let mut max_line: u32 = 0; - - let has_executed = block.has_executed || block.execution_count > 0; - - for byte_offset in min..max { - let Some(new_line_index) = LineOffsetTable::find_index( - line_starts, - Loc { - start: i32::try_from(byte_offset).expect("int cast"), - }, - ) else { - continue; - }; - let line_start_byte_offset = line_starts[new_line_index]; - if (line_start_byte_offset as usize) >= byte_offset { - continue; - } - - let line: u32 = u32::try_from(new_line_index).expect("int cast"); - min_line = min_line.min(line); - max_line = max_line.max(line); - - executable_lines.set(line as usize); - if has_executed { - lines_which_have_executed.set(line as usize); - line_hits_slice[line as usize] += 1; - } - } - - if min_line != u32::MAX { - if has_executed { - stmts_which_have_executed.set(stmts.len()); - } - - stmts.push(ByteRange::of(min, max)); + for byte_offset in byte_offsets.clone() { + let Some(line) = self.line_of(byte_offset, original.as_mut()) else { + continue; + }; + has_lines = true; + executable_lines.set(line); + if has_executed { + lines_which_have_executed.set(line); + line_hits[line] += 1; } } - for function in function_blocks { - if function.end_offset < 0 || function.start_offset < 0 { - continue; // does not map to anything - } - - let min: usize = usize::try_from(function.start_offset.min(function.end_offset)) - .expect("int cast"); - let max: usize = usize::try_from(function.start_offset.max(function.end_offset)) - .expect("int cast"); - let mut min_line: u32 = u32::MAX; - let mut max_line: u32 = 0; - - for byte_offset in min..max { - let Some(new_line_index) = LineOffsetTable::find_index( - line_starts, - Loc { - start: i32::try_from(byte_offset).expect("int cast"), - }, - ) else { - continue; - }; - let line_start_byte_offset = line_starts[new_line_index]; - if (line_start_byte_offset as usize) >= byte_offset { - continue; - } - - let line: u32 = u32::try_from(new_line_index).expect("int cast"); - min_line = min_line.min(line); - max_line = max_line.max(line); - } - - let did_fn_execute = function.execution_count > 0 || function.has_executed; - - // only mark the lines as executable if the function has not executed - // functions that have executed have non-executable lines in them and thats fine. - if !did_fn_execute { - let end = max_line.min(line_count); - line_hits_slice[min_line as usize..end as usize].fill(0); - for line in min_line..end { - executable_lines.set(line as usize); - lines_which_have_executed.unset(line as usize); - } + if has_lines { + if has_executed { + stmts_which_have_executed.set(stmts.len()); } - - if did_fn_execute { - functions_which_have_executed.set(functions.len()); - } - functions.push(ByteRange::of(min, max)); + stmts.push(ByteRange { + start: byte_offsets.start, + end: byte_offsets.end, + }); } - } else if let Some(parsed_mapping) = parsed_mappings_.as_deref() { - line_count = (parsed_mapping.input_line_count as u32) + 1; - executable_lines = Bitset::init_empty(line_count as usize)?; - lines_which_have_executed = Bitset::init_empty(line_count as usize)?; - line_hits = vec![0u32; line_count as usize]; - let line_hits_slice = line_hits.as_mut_slice(); - - let mut cur_: Option = parsed_mapping.internal_cursor(); - - for block in blocks { - if block.end_offset < 0 || block.start_offset < 0 { - continue; // does not map to anything - } + } - let min: usize = - usize::try_from(block.start_offset.min(block.end_offset)).expect("int cast"); - let max: usize = - usize::try_from(block.start_offset.max(block.end_offset)).expect("int cast"); - let mut min_line: u32 = u32::MAX; - let mut max_line: u32 = 0; - let has_executed = block.has_executed || block.execution_count > 0; - - for byte_offset in min..max { - let Some(new_line_index) = LineOffsetTable::find_index( - line_starts, - Loc { - start: i32::try_from(byte_offset).expect("int cast"), - }, - ) else { - continue; - }; - let line_start_byte_offset = line_starts[new_line_index]; - if (line_start_byte_offset as usize) >= byte_offset { - continue; - } - let column_position = - byte_offset.saturating_sub(line_start_byte_offset as usize); - - let found: Option = if let Some(c) = cur_.as_mut() { - c.move_to( - Ordinal::from_zero_based( - i32::try_from(new_line_index).expect("int cast"), - ), - Ordinal::from_zero_based( - i32::try_from(column_position).expect("int cast"), - ), - ) - } else { - parsed_mapping.find_mapping( - Ordinal::from_zero_based( - i32::try_from(new_line_index).expect("int cast"), - ), - Ordinal::from_zero_based( - i32::try_from(column_position).expect("int cast"), - ), - ) - }; - if let Some(point) = found.as_ref() { - if point.original.lines.zero_based() < 0 { - continue; - } - - let line: u32 = - u32::try_from(point.original.lines.zero_based()).expect("int cast"); - if line >= line_count { - continue; - } - - executable_lines.set(line as usize); - if has_executed { - lines_which_have_executed.set(line as usize); - line_hits_slice[line as usize] += 1; - } - - min_line = min_line.min(line); - max_line = max_line.max(line); - } - } + let mut functions: Vec = Vec::new(); + functions.reserve_exact(function_blocks.len()); + let mut functions_which_have_executed = Bitset::init_empty(function_blocks.len())?; - if min_line != u32::MAX { - if has_executed { - stmts_which_have_executed.set(stmts.len()); - } - stmts.push(ByteRange::of(min, max)); - } + for function in function_blocks { + if function.is_synthesized_default_constructor() { + continue; } - for function in function_blocks { - if function.end_offset < 0 || function.start_offset < 0 { - continue; // does not map to anything - } - - let min: usize = usize::try_from(function.start_offset.min(function.end_offset)) - .expect("int cast"); - let max: usize = usize::try_from(function.start_offset.max(function.end_offset)) - .expect("int cast"); - let mut min_line: u32 = u32::MAX; - let mut max_line: u32 = 0; - - for byte_offset in min..max { - let Some(new_line_index) = LineOffsetTable::find_index( - line_starts, - Loc { - start: i32::try_from(byte_offset).expect("int cast"), - }, - ) else { - continue; - }; - let line_start_byte_offset = line_starts[new_line_index]; - if (line_start_byte_offset as usize) >= byte_offset { - continue; - } - - let column_position = - byte_offset.saturating_sub(line_start_byte_offset as usize); - - let found: Option = if let Some(c) = cur_.as_mut() { - c.move_to( - Ordinal::from_zero_based( - i32::try_from(new_line_index).expect("int cast"), - ), - Ordinal::from_zero_based( - i32::try_from(column_position).expect("int cast"), - ), - ) - } else { - parsed_mapping.find_mapping( - Ordinal::from_zero_based( - i32::try_from(new_line_index).expect("int cast"), - ), - Ordinal::from_zero_based( - i32::try_from(column_position).expect("int cast"), - ), - ) - }; - if let Some(point) = found { - if point.original.lines.zero_based() < 0 { - continue; - } - - let line: u32 = - u32::try_from(point.original.lines.zero_based()).expect("int cast"); - if line >= line_count { - continue; - } - min_line = min_line.min(line); - max_line = max_line.max(line); - } - } - - // no sourcemaps? ignore it - if min_line == u32::MAX && max_line == 0 { + let did_fn_execute = function.has_executed(); + let byte_offsets = function.byte_offsets(); + let mut has_lines = false; + + // A function that never ran has no basic blocks (JSC never compiled it), so its + // range is all we know: every line with code in it is executable and unexecuted. + // One that ran is already described by its blocks; its range only tells whether it + // exists in the reported file at all (functions the transpiler synthesized don't, + // and are not counted). + for byte_offset in byte_offsets.clone() { + let Some(line) = self.line_of(byte_offset, original.as_mut()) else { continue; + }; + has_lines = true; + if did_fn_execute { + break; } + executable_lines.set(line); + lines_which_have_executed.unset(line); + line_hits[line] = 0; + } - let did_fn_execute = function.execution_count > 0 || function.has_executed; - - // only mark the lines as executable if the function has not executed - // functions that have executed have non-executable lines in them and thats fine. - if !did_fn_execute { - let end = max_line.min(line_count); - for line in min_line..end { - executable_lines.set(line as usize); - lines_which_have_executed.unset(line as usize); - line_hits_slice[line as usize] = 0; - } - } - + if has_lines { if did_fn_execute { functions_which_have_executed.set(functions.len()); } - functions.push(ByteRange::of(min, max)); + functions.push(ByteRange { + start: byte_offsets.start, + end: byte_offsets.end, + }); } - } else { - unreachable!(); } functions_which_have_executed.resize(functions.len(), false)?; @@ -1007,20 +864,100 @@ impl ByteRangeMapping { }) } + /// The reported line for `byte_offset`, or `None` when the byte is not part of any line's + /// code (see `Line::code_start`) or has no position in the original file. + fn line_of(&self, byte_offset: u32, original: Option<&mut OriginalLines<'_>>) -> Option { + let index = self + .lines + .partition_point(|line| line.start <= byte_offset) + .checked_sub(1)?; + let line = self.lines[index]; + if !(line.code_start..line.code_end).contains(&byte_offset) { + return None; + } + match original { + Some(original) => original.line_of(index, byte_offset - line.start), + None => Some(index), + } + } + pub(crate) fn compute( source_contents: &[u8], source_id: i32, source_url: Utf8Bytes<'static>, ) -> ByteRangeMapping { + let mut line_offset_table = LineOffsetTable::generate(source_contents, 0) + .unwrap_or_else(|_| bun_alloc::out_of_memory()); + let line_starts = line_offset_table.items_byte_offset_to_start_of_line(); + + let lines = line_starts + .iter() + .enumerate() + .map(|(i, &start)| { + let end = line_starts + .get(i + 1) + .map_or(source_contents.len(), |&next| next as usize); + let text = &source_contents[start as usize..end]; + let code_start = start + (text.len() - text.trim_ascii_start().len()) as u32; + Line { + start, + code_start, + code_end: code_start + text.trim_ascii().len() as u32, + } + }) + .collect(); + // `MultiArrayList`'s `Drop` only frees the slab; the per-line column tables need this. + line_offset_table.drop_elements(); + ByteRangeMapping { - line_offset_table: LineOffsetTable::generate(source_contents, 0) - .unwrap_or_else(|_| bun_alloc::out_of_memory()), + lines, source_id, source_url, } } } +/// Maps positions in the text JSC executed back to lines of the file the user wrote, through +/// the source map the transpiler produced for it. +struct OriginalLines<'a> { + source_map: &'a ParsedSourceMap, + cursor: Option, + line_count: usize, +} + +impl OriginalLines<'_> { + fn line_of(&mut self, generated_line: usize, column: u32) -> Option { + let generated_line = i32::try_from(generated_line).ok()?; + let column = Ordinal::from_zero_based(i32::try_from(column).ok()?); + let mapping = match &mut self.cursor { + Some(cursor) => cursor.move_to(Ordinal::from_zero_based(generated_line), column), + None => self + .source_map + .find_mapping(Ordinal::from_zero_based(generated_line), column), + }?; + + // The printer starts every generated line with a mapping. When the line's first token + // has none of its own, that mapping is a copy of the previous line's last one + // (`cover_lines_without_mappings` in bun_sourcemap's Chunk.rs). A byte that resolves to + // the copy, e.g. the `)` and `;` of a `});` line, has no position of its own in the + // original file; charging it to the previous line would give that line the coverage of + // whatever construct the byte belongs to. + if mapping.generated.columns == Ordinal::START + && generated_line > 0 + && let Some(previous) = self.source_map.find_mapping( + Ordinal::from_zero_based(generated_line - 1), + Ordinal::from_zero_based(i32::MAX), + ) + && previous.original == mapping.original + { + return None; + } + + let line = usize::try_from(mapping.original.lines.zero_based()).ok()?; + (line < self.line_count).then_some(line) + } +} + #[unsafe(no_mangle)] extern "C" fn ByteRangeMapping__generate( str_: &bun_core::String, @@ -1124,12 +1061,3 @@ pub struct ByteRange { pub start: u32, pub end: u32, } - -impl ByteRange { - fn of(min: usize, max: usize) -> ByteRange { - ByteRange { - start: u32::try_from(min).expect("int cast"), - end: u32::try_from(max).expect("int cast"), - } - } -} diff --git a/test/cli/test/__snapshots__/coverage.test.ts.snap b/test/cli/test/__snapshots__/coverage.test.ts.snap index 23af59233aa9..e4a4d85bd2ad 100644 --- a/test/cli/test/__snapshots__/coverage.test.ts.snap +++ b/test/cli/test/__snapshots__/coverage.test.ts.snap @@ -3,26 +3,24 @@ exports[`lcov coverage reporter: lcov-coverage-reporter-output 1`] = ` "TN: SF:demo1.ts -FNF:1 +FNF:0 FNH:0 -DA:2,19 -DA:3,16 +DA:3,7 DA:4,1 -LF:3 -LH:3 +LF:2 +LH:2 end_of_record TN: SF:demo2.ts FNF:2 FNH:1 DA:2,28 -DA:4,11 -DA:6,9 +DA:4,10 +DA:6,8 DA:9,0 -DA:10,0 -DA:11,1 -DA:14,9 -LF:7 -LH:5 +DA:11,0 +DA:14,10 +LF:6 +LH:4 end_of_record" `; diff --git a/test/cli/test/coverage.test.ts b/test/cli/test/coverage.test.ts index e5cd264e0c05..8be9e43264e1 100644 --- a/test/cli/test/coverage.test.ts +++ b/test/cli/test/coverage.test.ts @@ -57,6 +57,212 @@ export class Y { ); }); +/** Runs `bun test --coverage` in `dir` and returns the text table row for `file` plus the lcov `DA:` records for it. */ +async function coverageOf(dir: string, file: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--coverage", "--coverage-reporter=text", "--coverage-reporter=lcov", "--coverage-dir=cov"], + cwd: dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(exitCode).toBe(0); + + const row = stderr + .split("\n") + .find(line => line.startsWith(` ${file} `))! + .trimEnd(); + + const record = readFileSync(path.join(dir, "cov", "lcov.info"), "utf-8") + .split("end_of_record") + .find(record => record.includes(`SF:${file}\n`))!; + const hits: Record = {}; + for (const [, line, count] of record.matchAll(/^DA:(\d+),(\d+)$/gm)) { + hits[Number(line)] = Number(count); + } + /** Per source line (1-based): hit, missed, or not an executable line at all. */ + const status = (lineCount: number) => + Object.fromEntries( + Array.from({ length: lineCount }, (_, i) => { + const line = i + 1; + return [line, !(line in hits) ? "-" : hits[line] > 0 ? "hit" : "MISSED"]; + }), + ); + return { row, record, status }; +} + +const skipTestFiles = `[test]\ncoverageSkipTestFiles = true\n`; + +test.concurrent("coverage blames the statements that never ran, not the braces around them", async () => { + using dir = tempDir("cov", { + "bunfig.toml": skipTestFiles, + "lib.js": `export function earlyReturn(a) { + if (a > 0) { + return a; + } + return -a; +} +export function untakenElse(a) { + if (a) { + return 1; + } else { + return 2; + } +} +export function guarded(f) { + try { + return f(); + } catch (e) { + return e; + } +} +export function make() { + return { + get(n) { + if (n !== undefined) { + return n; + } + return -1; + }, + }; +} +export function neverCalled(a) { + // a comment inside a function that never runs + const b = a + 1; + + return b * 2; +} +export function neverCalledOneLiner() { return 1; } +`, + "lib.test.js": `import { test, expect } from "bun:test"; +import { earlyReturn, untakenElse, guarded, make } from "./lib.js"; +test("only the happy paths", () => { + expect(earlyReturn(2)).toBe(2); + expect(untakenElse(true)).toBe(1); + expect(guarded(() => 3)).toBe(3); + expect(make().get(4)).toBe(4); +}); +`, + }); + + const { row, status } = await coverageOf(String(dir), "lib.js"); + expect(row).toBe(" lib.js | 71.43 | 60.00 | 5,10-11,17-18,27,31,33,35,37"); + expect(status(37)).toEqual({ + 1: "hit", + 2: "hit", + 3: "hit", + 4: "-", // `}` closing the if block: the dead block after `return a` starts here + 5: "MISSED", + 6: "-", + 7: "hit", + 8: "hit", + 9: "hit", + 10: "MISSED", // `} else {` + 11: "MISSED", + 12: "-", + 13: "-", + 14: "hit", + 15: "hit", + 16: "hit", + 17: "MISSED", // `} catch (e) {` + 18: "MISSED", + 19: "-", + 20: "-", + 21: "hit", + 22: "hit", + 23: "hit", + 24: "hit", + 25: "hit", + 26: "-", + 27: "MISSED", // `return -1`: the executed `},` after it must not count as this line + 28: "-", + 29: "hit", // `};` ends the executed return statement + 30: "-", + 31: "MISSED", // uncalled function: its declaration line... + 32: "-", // ...but not the comment + 33: "MISSED", + 34: "-", // ...nor the blank line + 35: "MISSED", // ...and its last statement + 36: "-", + 37: "MISSED", // uncalled function written on one line + }); +}); + +// JSC gives the initializer it synthesizes for class fields a range spanning the whole file and +// cuts it out of the module's top-level block, which then comes back as inverted ranges like +// `[file_length, 0]`. Those used to be walked as `0..file_length`, marking every line of any +// file declaring a class field as executed. +test.concurrent("coverage of a file with class fields still reports its unexecuted lines", async () => { + using dir = tempDir("cov", { + "bunfig.toml": skipTestFiles, + "lib.js": `export class WithField { + count = 0; +} +export function pick(a) { + if (a) { + return 1; + } + return 2; +} +`, + "lib.test.js": `import { test, expect } from "bun:test"; +import { WithField, pick } from "./lib.js"; +test("pick", () => { + expect(new WithField().count).toBe(0); + expect(pick(true)).toBe(1); +}); +`, + }); + + const { row, record, status } = await coverageOf(String(dir), "lib.js"); + expect(row).toBe(" lib.js | 100.00 | 85.71 | 8"); + expect(status(9)).toEqual({ + 1: "hit", + 2: "hit", + 3: "hit", + 4: "hit", + 5: "hit", + 6: "hit", + 7: "-", + 8: "MISSED", + 9: "-", + }); + // `pick` is the only function: the constructor JSC synthesizes for `WithField` is not one. + expect(record).toContain("\nFNF:1\nFNH:1\n"); +}); + +test.concurrent("coverageIgnoreSourcemaps reports the line of the dead statement itself", async () => { + using dir = tempDir("cov", { + "bunfig.toml": `${skipTestFiles}coverageIgnoreSourcemaps = true\n`, + // Transpiles to itself line for line, so the generated line numbers are the source's. + "lib.js": `export function earlyReturn(a) { + if (a > 0) { + return a; + } + return -a; +} +`, + "lib.test.js": `import { test, expect } from "bun:test"; +import { earlyReturn } from "./lib.js"; +test("positive", () => { + expect(earlyReturn(2)).toBe(2); +}); +`, + }); + + const { row, status } = await coverageOf(String(dir), "lib.js"); + expect(row).toBe(" lib.js | 100.00 | 75.00 | 5"); + expect(status(6)).toEqual({ + 1: "hit", + 2: "hit", + 3: "hit", + 4: "-", + 5: "MISSED", + 6: "-", + }); +}); + test("coverage excludes node_modules directory", () => { using dir = tempDir("cov", { "node_modules/pi/index.js": ` @@ -192,8 +398,8 @@ test("should call only some functions", () => { ---------------|---------|---------|------------------- File | % Funcs | % Lines | Uncovered Line #s ---------------|---------|---------|------------------- -All files | 75.00 | 83.33 | - include-me.ts | 50.00 | 66.67 | 6 +All files | 75.00 | 75.00 | + include-me.ts | 50.00 | 50.00 | 6-7 test.test.ts | 100.00 | 100.00 | ---------------|---------|---------|------------------- @@ -385,7 +591,7 @@ test("should call both functions", () => { SF:include-me.ts FNF:1 FNH:1 -DA:2,11 +DA:2,10 DA:3,17 LF:2 LH:2 @@ -397,9 +603,9 @@ FNH:1 DA:2,40 DA:3,41 DA:4,39 -DA:6,42 -DA:7,39 -DA:8,36 +DA:6,41 +DA:7,37 +DA:8,35 DA:9,2 LF:7 LH:7 From 517bb6c304e1397832d9036b8c07543f6c74dbcc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:53:47 +0000 Subject: [PATCH 2/7] test(coverage): drain both pipes in the helper; add the class method shapes The helper left stdout piped but unread and asserted the exit code before the output. The new test covers uncalled methods next to the synthesized constructors of a base class, a class expression and a derived class. --- test/cli/test/coverage.test.ts | 49 +++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/test/cli/test/coverage.test.ts b/test/cli/test/coverage.test.ts index 8be9e43264e1..3143d1276be0 100644 --- a/test/cli/test/coverage.test.ts +++ b/test/cli/test/coverage.test.ts @@ -66,13 +66,13 @@ async function coverageOf(dir: string, file: string) { stdout: "pipe", stderr: "pipe", }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - expect(exitCode).toBe(0); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); const row = stderr .split("\n") - .find(line => line.startsWith(` ${file} `))! - .trimEnd(); + .find(line => line.startsWith(` ${file} `)) + ?.trimEnd(); + expect({ stdout, stderr, exitCode, row }).toMatchObject({ exitCode: 0, row: expect.any(String) }); const record = readFileSync(path.join(dir, "cov", "lcov.info"), "utf-8") .split("end_of_record") @@ -232,6 +232,47 @@ test("pick", () => { expect(record).toContain("\nFNF:1\nFNH:1\n"); }); +// Shapes from #8290, #7025 and #29691: uncalled methods are reported on their own lines, and the +// constructors JSC synthesizes (base class, class expression, derived class) are neither counted +// as uncalled functions nor charged to the first line of the file. +test.concurrent("coverage of classes counts methods, not synthesized constructors", async () => { + using dir = tempDir("cov", { + "bunfig.toml": skipTestFiles, + "classes.js": `export class Base { + calls = []; + async consume(event) { + this.calls.push(event); + } + covered() { return 1; } + another() { return 2; } +} +export const factory = () => class {}; +export class Derived extends factory() {} +`, + "classes.test.js": `import { test, expect } from "bun:test"; +import { Base, Derived } from "./classes.js"; +test("covered", () => { + expect(new Base().covered()).toBe(1); + expect(Derived.name).toBe("Derived"); +}); +`, + }); + + const { row, record, status } = await coverageOf(String(dir), "classes.js"); + // consume, covered, another and factory; covered and factory ran. + expect(record).toContain("\nFNF:4\nFNH:2\n"); + expect(row).toMatch(/^ classes\.js \| 50\.00 \| +\d+\.\d\d \| 3-4,7$/); + expect(status(10)).toMatchObject({ + 3: "MISSED", + 4: "MISSED", + 5: "-", + 6: "hit", + 7: "MISSED", + 9: "hit", + 10: "hit", + }); +}); + test.concurrent("coverageIgnoreSourcemaps reports the line of the dead statement itself", async () => { using dir = tempDir("cov", { "bunfig.toml": `${skipTestFiles}coverageIgnoreSourcemaps = true\n`, From a59720233f6a6beaf552c8d002163f56f874c6e6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:59:42 +0000 Subject: [PATCH 3/7] test(coverage): shorten the comments in CodeCoverage.rs --- src/sourcemap_jsc/CodeCoverage.rs | 68 ++++++++++--------------------- 1 file changed, 22 insertions(+), 46 deletions(-) diff --git a/src/sourcemap_jsc/CodeCoverage.rs b/src/sourcemap_jsc/CodeCoverage.rs index 02f170c3c440..22c785937d0a 100644 --- a/src/sourcemap_jsc/CodeCoverage.rs +++ b/src/sourcemap_jsc/CodeCoverage.rs @@ -608,14 +608,8 @@ impl Generator<'_, '_> { } } -/// A textual basic block (or, after `function_start_offset`, a whole function) as JSC's -/// control flow profiler reports it: inclusive `[start_offset, end_offset]` offsets into the -/// source text JSC executed. -/// -/// JSC cuts the functions nested in a block out of its range, so one block arrives as several -/// ranges. When those cut-outs overlap (the initializer JSC synthesizes for class fields spans -/// the whole file) the leftovers come out inverted, `[file_length, 0]` and the like, and -/// describe nothing. +/// A basic block (or, after `function_start_offset`, a whole function) of JSC's control flow +/// profiler: inclusive offsets into the text JSC executed. #[repr(C)] #[derive(Clone, Copy, Default)] pub struct BasicBlockRange { @@ -630,14 +624,10 @@ impl BasicBlockRange { self.has_executed || self.execution_count > 0 } - /// The offsets attributed to lines; empty for inverted ranges and for the `-1` JSC uses - /// when a range maps to nothing. - /// - /// `end_offset` itself is left out: a block runs up to the byte before the next block - /// starts, so its last byte is either the closing brace of the construct it ends at or the - /// last character of a statement whose other bytes already mark the line. Attributing it - /// would report every `}` line that follows a `return`/`throw`/`break` as uncovered. - /// (Function ranges end on their closing brace too.) + /// `end_offset` is deliberately excluded: it is the closing brace of the construct the + /// block ends at, which would otherwise be reported uncovered after every `return`. + /// Inverted ranges (what JSC leaves of a block after cutting out nested functions whose + /// ranges overlap, e.g. `[file_length, 0]`) and JSC's `-1` yield nothing. fn byte_offsets(&self) -> core::ops::Range { match ( u32::try_from(self.start_offset), @@ -648,14 +638,10 @@ impl BasicBlockRange { } } - /// A class without a `constructor` gets one synthesized by JSC from a fixed source string. - /// JSC records it in the function table of the file defining the class, but with offsets - /// into that string, and nothing ever marks that entry as executed. Without this filter - /// every such class counts as an uncalled function whose range lands on the first line of - /// the file. + /// JSC registers the constructor it synthesizes for a class without one in the defining + /// file's function table, with offsets into its template string + /// (`BuiltinExecutables::defaultConstructorSourceCode`), and never marks it executed. fn is_synthesized_default_constructor(&self) -> bool { - // BuiltinExecutables::defaultConstructorSourceCode; the range runs from the - // `function` keyword to the closing brace. const BASE: &str = "(function () { })"; const DERIVED: &str = "(function (...args) { super(...args); })"; const FUNCTION_KEYWORD: c_int = "(".len() as c_int; @@ -668,15 +654,13 @@ impl BasicBlockRange { } } -/// One line of the source text JSC executed (the transpiled output, not the file on disk). +/// One line of the text JSC executed (the transpiled output, not the file on disk). #[derive(Clone, Copy)] struct Line { start: u32, - /// `[code_start, code_end)` is the line with its indentation and line terminator trimmed - /// off. Only offsets inside it are attributed to the line: a block that begins after a - /// statement begins on the terminator of that statement's line, and the indentation of a - /// generated line maps back to the previous original line (see `OriginalLines`), so - /// attributing either would shift the block up by one line. + /// The line minus indentation and terminator. Only these offsets count as the line: a block + /// that follows a statement starts on that statement's line terminator, and indentation maps + /// back to the previous original line. code_start: u32, code_end: u32, } @@ -757,8 +741,6 @@ impl ByteRangeMapping { bun_jsc::VirtualMachine::VirtualMachine::get().as_mut() .source_mappings() .get(source_url); - // Without a source map (or with `coverageIgnoreSourcemaps`) the report is about the - // executed text itself and its line numbers are used as they are. let mut original = match parsed_mappings_.as_deref() { Some(source_map) if !ignore_sourcemap => Some(OriginalLines { source_map, @@ -820,11 +802,9 @@ impl ByteRangeMapping { let byte_offsets = function.byte_offsets(); let mut has_lines = false; - // A function that never ran has no basic blocks (JSC never compiled it), so its - // range is all we know: every line with code in it is executable and unexecuted. - // One that ran is already described by its blocks; its range only tells whether it - // exists in the reported file at all (functions the transpiler synthesized don't, - // and are not counted). + // A function that never ran has no blocks, so its range is what marks its lines. One + // that ran is described by its blocks; its range only tells whether it has lines in + // the reported file at all (functions the transpiler synthesized don't count). for byte_offset in byte_offsets.clone() { let Some(line) = self.line_of(byte_offset, original.as_mut()) else { continue; @@ -864,8 +844,8 @@ impl ByteRangeMapping { }) } - /// The reported line for `byte_offset`, or `None` when the byte is not part of any line's - /// code (see `Line::code_start`) or has no position in the original file. + /// The reported line for `byte_offset`; `None` for whitespace and for bytes without a position + /// in the original file. fn line_of(&self, byte_offset: u32, original: Option<&mut OriginalLines<'_>>) -> Option { let index = self .lines @@ -917,8 +897,7 @@ impl ByteRangeMapping { } } -/// Maps positions in the text JSC executed back to lines of the file the user wrote, through -/// the source map the transpiler produced for it. +/// Maps positions in the executed text back to lines of the file on disk. struct OriginalLines<'a> { source_map: &'a ParsedSourceMap, cursor: Option, @@ -936,12 +915,9 @@ impl OriginalLines<'_> { .find_mapping(Ordinal::from_zero_based(generated_line), column), }?; - // The printer starts every generated line with a mapping. When the line's first token - // has none of its own, that mapping is a copy of the previous line's last one - // (`cover_lines_without_mappings` in bun_sourcemap's Chunk.rs). A byte that resolves to - // the copy, e.g. the `)` and `;` of a `});` line, has no position of its own in the - // original file; charging it to the previous line would give that line the coverage of - // whatever construct the byte belongs to. + // The printer opens every generated line with a copy of the previous mapping + // (`cover_lines_without_mappings`). Bytes that only resolve to the copy, such as a `});` + // line, have no position of their own and must not be charged to the previous line. if mapping.generated.columns == Ordinal::START && generated_line > 0 && let Some(previous) = self.source_map.find_mapping( From d791c275bc6958bad1b2bd30af5a8ccbf12d69ed Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:34:42 +0000 Subject: [PATCH 4/7] test(coverage): skip the synthesized default constructors for every consumer Move the filter for the constructor JSC synthesizes for a class without one from the Rust report generator to the C++ code that reads JSC's function table. `bun test --coverage` and `bun:jsc` `codeCoverageForFile` share `Bun::appendFunctionRangesForCoverage`; `node:inspector` `Profiler.takePreciseCoverage` applies the same predicate, so c8 and v8-to-istanbul no longer see a count-0 function over the first characters of every file that defines such a class. A file whose only function range is the module body keeps that range, as every file without functions does, so `demo1.ts` in the lcov snapshot reports `FNF:1 FNH:1`. Fold the tests from #36614, #36651 and #36655: the TypeScript file from reported, and the inspector entry for classes without a constructor. --- src/jsc/bindings/CodeCoverage.cpp | 40 ++- src/jsc/bindings/CodeCoverage.h | 26 ++ src/jsc/bindings/JSInspectorProfiler.cpp | 12 +- src/jsc/modules/BunJSCModule.h | 16 +- src/sourcemap_jsc/CodeCoverage.rs | 19 -- .../test/__snapshots__/coverage.test.ts.snap | 4 +- test/cli/test/coverage.test.ts | 245 ++++++++++++++++-- .../node/inspector/inspector-profiler.test.ts | 46 ++++ 8 files changed, 331 insertions(+), 77 deletions(-) create mode 100644 src/jsc/bindings/CodeCoverage.h diff --git a/src/jsc/bindings/CodeCoverage.cpp b/src/jsc/bindings/CodeCoverage.cpp index 51ea142f9e4e..43ebd8818228 100644 --- a/src/jsc/bindings/CodeCoverage.cpp +++ b/src/jsc/bindings/CodeCoverage.cpp @@ -1,9 +1,34 @@ #include "root.h" +#include "CodeCoverage.h" #include "ZigSourceProvider.h" #include using namespace JSC; +namespace Bun { + +void appendFunctionRangesForCoverage(Vector& basicBlocks, VM& vm, SourceID sourceID) +{ + const Vector>& functionRanges = vm.functionHasExecutedCache()->getFunctionRanges(sourceID); + + basicBlocks.reserveCapacity(functionRanges.size() + basicBlocks.size()); + + for (const auto& functionRange : functionRanges) { + BasicBlockRange range; + range.m_hasExecuted = std::get<0>(functionRange); + range.m_startOffset = static_cast(std::get<1>(functionRange)); + range.m_endOffset = static_cast(std::get<2>(functionRange)); + range.m_executionCount = range.m_hasExecuted + ? 1 + : 0; // This is a hack. We don't actually count this. + if (isSynthesizedDefaultConstructorRange(range.m_hasExecuted, range.m_startOffset, range.m_endOffset)) + continue; + basicBlocks.append(range); + } +} + +} + extern "C" bool CodeCoverage__withBlocksAndFunctions( JSC::VM* vmPtr, JSC::SourceID sourceID, @@ -24,20 +49,7 @@ extern "C" bool CodeCoverage__withBlocksAndFunctions( size_t functionStartOffset = basicBlocks.size(); - const Vector>& functionRanges = vm.functionHasExecutedCache()->getFunctionRanges(sourceID); - - basicBlocks.reserveCapacity(functionRanges.size() + basicBlocks.size()); - - for (const auto& functionRange : functionRanges) { - BasicBlockRange range; - range.m_hasExecuted = std::get<0>(functionRange); - range.m_startOffset = static_cast(std::get<1>(functionRange)); - range.m_endOffset = static_cast(std::get<2>(functionRange)); - range.m_executionCount = range.m_hasExecuted - ? 1 - : 0; // This is a hack. We don't actually count this. - basicBlocks.append(range); - } + Bun::appendFunctionRangesForCoverage(basicBlocks, vm, sourceID); blockCallback(ctx, basicBlocks.begin(), basicBlocks.size(), functionStartOffset, ignoreSourceMap); return true; diff --git a/src/jsc/bindings/CodeCoverage.h b/src/jsc/bindings/CodeCoverage.h new file mode 100644 index 000000000000..55e4d0ab13ee --- /dev/null +++ b/src/jsc/bindings/CodeCoverage.h @@ -0,0 +1,26 @@ +#pragma once + +#include "root.h" +#include + +namespace Bun { + +// JSC records the constructor it synthesizes for a class without one in the defining file's +// function table (CodeBlock::finishCreation), with offsets into its own template string +// (BuiltinExecutables::defaultConstructorSourceCode), and never marks it executed because its +// code block is linked against the template (UnlinkedFunctionExecutable::linkedSourceCode). +constexpr bool isSynthesizedDefaultConstructorRange(bool hasExecuted, int start, int end) +{ + // The range starts at the `function` keyword and ends at the closing brace of the body. + constexpr int functionKeyword = static_cast(sizeof("(") - 1); + constexpr int baseClosingBrace = static_cast(sizeof("(function () { })") - sizeof("})")); + constexpr int derivedClosingBrace = static_cast(sizeof("(function (...args) { super(...args); })") - sizeof("})")); + static_assert(functionKeyword == 1 && baseClosingBrace == 15 && derivedClosingBrace == 38); + return !hasExecuted && start == functionKeyword && (end == baseClosingBrace || end == derivedClosingBrace); +} + +// Appends every function range of `sourceID` to `basicBlocks` as a BasicBlockRange, except the +// synthesized default constructors above. +void appendFunctionRangesForCoverage(Vector& basicBlocks, JSC::VM& vm, JSC::SourceID sourceID); + +} diff --git a/src/jsc/bindings/JSInspectorProfiler.cpp b/src/jsc/bindings/JSInspectorProfiler.cpp index f98e9e8b5799..47afe6e0c1a8 100644 --- a/src/jsc/bindings/JSInspectorProfiler.cpp +++ b/src/jsc/bindings/JSInspectorProfiler.cpp @@ -1,6 +1,7 @@ #include "root.h" #include "helpers.h" #include "BunCPUProfiler.h" +#include "CodeCoverage.h" #include "NodeValidator.h" #include #include @@ -144,10 +145,15 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_collectPreciseCoverage, (JSGlobalObject * gl auto functionArray = JSON::Array::create(); for (const auto& functionRange : functionRanges) { + bool hasExecuted = std::get<0>(functionRange); + int start = static_cast(std::get<1>(functionRange)); + int end = static_cast(std::get<2>(functionRange)); + if (Bun::isSynthesizedDefaultConstructorRange(hasExecuted, start, end)) + continue; auto range = JSON::Array::create(); - range->pushDouble(static_cast(std::get<1>(functionRange))); - range->pushDouble(static_cast(std::get<2>(functionRange))); - range->pushBoolean(std::get<0>(functionRange)); + range->pushDouble(static_cast(start)); + range->pushDouble(static_cast(end)); + range->pushBoolean(hasExecuted); functionArray->pushValue(WTF::move(range)); } script->setValue("functions"_s, WTF::move(functionArray)); diff --git a/src/jsc/modules/BunJSCModule.h b/src/jsc/modules/BunJSCModule.h index 88f2ea816f2b..8378078599e9 100644 --- a/src/jsc/modules/BunJSCModule.h +++ b/src/jsc/modules/BunJSCModule.h @@ -53,6 +53,7 @@ extern "C" char* mi_stats_get_json(size_t, char*); extern "C" char* mi_heap_dump_json(bool include_blocks, bool hash_addresses); #include +#include "CodeCoverage.h" #if OS(DARWIN) #if ASSERT_ENABLED @@ -933,20 +934,7 @@ JSC_DEFINE_HOST_FUNCTION(functionCodeCoverageForFile, size_t functionStartOffset = basicBlocks.size(); - const Vector>& functionRanges = vm.functionHasExecutedCache()->getFunctionRanges(sourceID); - - basicBlocks.reserveCapacity(functionRanges.size() + basicBlocks.size()); - - for (const auto& functionRange : functionRanges) { - BasicBlockRange range; - range.m_hasExecuted = std::get<0>(functionRange); - range.m_startOffset = static_cast(std::get<1>(functionRange)); - range.m_endOffset = static_cast(std::get<2>(functionRange)); - range.m_executionCount = range.m_hasExecuted - ? 1 - : 0; // This is a hack. We don't actually count this. - basicBlocks.append(range); - } + Bun::appendFunctionRangesForCoverage(basicBlocks, vm, sourceID); BunString fileNameBunString = Bun::toString(fileName); return ByteRangeMapping__findExecutedLines( diff --git a/src/sourcemap_jsc/CodeCoverage.rs b/src/sourcemap_jsc/CodeCoverage.rs index 22c785937d0a..25ba41ffb4aa 100644 --- a/src/sourcemap_jsc/CodeCoverage.rs +++ b/src/sourcemap_jsc/CodeCoverage.rs @@ -637,21 +637,6 @@ impl BasicBlockRange { _ => 0..0, } } - - /// JSC registers the constructor it synthesizes for a class without one in the defining - /// file's function table, with offsets into its template string - /// (`BuiltinExecutables::defaultConstructorSourceCode`), and never marks it executed. - fn is_synthesized_default_constructor(&self) -> bool { - const BASE: &str = "(function () { })"; - const DERIVED: &str = "(function (...args) { super(...args); })"; - const FUNCTION_KEYWORD: c_int = "(".len() as c_int; - const BASE_CLOSING_BRACE: c_int = (BASE.len() - "})".len()) as c_int; - const DERIVED_CLOSING_BRACE: c_int = (DERIVED.len() - "})".len()) as c_int; - - !self.has_executed() - && self.start_offset == FUNCTION_KEYWORD - && (self.end_offset == BASE_CLOSING_BRACE || self.end_offset == DERIVED_CLOSING_BRACE) - } } /// One line of the text JSC executed (the transpiled output, not the file on disk). @@ -794,10 +779,6 @@ impl ByteRangeMapping { let mut functions_which_have_executed = Bitset::init_empty(function_blocks.len())?; for function in function_blocks { - if function.is_synthesized_default_constructor() { - continue; - } - let did_fn_execute = function.has_executed(); let byte_offsets = function.byte_offsets(); let mut has_lines = false; diff --git a/test/cli/test/__snapshots__/coverage.test.ts.snap b/test/cli/test/__snapshots__/coverage.test.ts.snap index e4a4d85bd2ad..7e994a9fa512 100644 --- a/test/cli/test/__snapshots__/coverage.test.ts.snap +++ b/test/cli/test/__snapshots__/coverage.test.ts.snap @@ -3,8 +3,8 @@ exports[`lcov coverage reporter: lcov-coverage-reporter-output 1`] = ` "TN: SF:demo1.ts -FNF:0 -FNH:0 +FNF:1 +FNH:1 DA:3,7 DA:4,1 LF:2 diff --git a/test/cli/test/coverage.test.ts b/test/cli/test/coverage.test.ts index 3143d1276be0..7bad053dc4ca 100644 --- a/test/cli/test/coverage.test.ts +++ b/test/cli/test/coverage.test.ts @@ -57,8 +57,8 @@ export class Y { ); }); -/** Runs `bun test --coverage` in `dir` and returns the text table row for `file` plus the lcov `DA:` records for it. */ -async function coverageOf(dir: string, file: string) { +/** Runs `bun test --coverage` in `dir` with the text and lcov reporters. */ +async function runCoverage(dir: string) { await using proc = Bun.spawn({ cmd: [bunExe(), "test", "--coverage", "--coverage-reporter=text", "--coverage-reporter=lcov", "--coverage-dir=cov"], cwd: dir, @@ -67,29 +67,41 @@ async function coverageOf(dir: string, file: string) { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toMatchObject({ exitCode: 0 }); - const row = stderr + /** The rows of the text table, without the dashed rules. */ + const table = stderr .split("\n") - .find(line => line.startsWith(` ${file} `)) - ?.trimEnd(); - expect({ stdout, stderr, exitCode, row }).toMatchObject({ exitCode: 0, row: expect.any(String) }); - - const record = readFileSync(path.join(dir, "cov", "lcov.info"), "utf-8") - .split("end_of_record") - .find(record => record.includes(`SF:${file}\n`))!; - const hits: Record = {}; - for (const [, line, count] of record.matchAll(/^DA:(\d+),(\d+)$/gm)) { - hits[Number(line)] = Number(count); - } - /** Per source line (1-based): hit, missed, or not an executable line at all. */ - const status = (lineCount: number) => - Object.fromEntries( - Array.from({ length: lineCount }, (_, i) => { - const line = i + 1; - return [line, !(line in hits) ? "-" : hits[line] > 0 ? "hit" : "MISSED"]; - }), - ); - return { row, record, status }; + .filter(line => line.includes(" | ")) + .map(line => line.trimEnd()); + const lcov = readFileSync(path.join(dir, "cov", "lcov.info"), "utf-8"); + + /** The text table row for `file` plus the lcov `DA:` records for it. */ + const of = (file: string) => { + const row = table.find(line => line.startsWith(` ${file} `)); + expect({ stderr, row }).toMatchObject({ row: expect.any(String) }); + + const record = lcov.split("end_of_record").find(record => record.includes(`SF:${file}\n`))!; + const hits: Record = {}; + for (const [, line, count] of record.matchAll(/^DA:(\d+),(\d+)$/gm)) { + hits[Number(line)] = Number(count); + } + /** Per source line (1-based): hit, missed, or not an executable line at all. */ + const status = (lineCount: number) => + Object.fromEntries( + Array.from({ length: lineCount }, (_, i) => { + const line = i + 1; + return [line, !(line in hits) ? "-" : hits[line] > 0 ? "hit" : "MISSED"]; + }), + ); + return { row: row!, record, status }; + }; + return { table, of }; +} + +/** Runs `bun test --coverage` in `dir` and returns the text table row for `file` plus the lcov `DA:` records for it. */ +async function coverageOf(dir: string, file: string) { + return (await runCoverage(dir)).of(file); } const skipTestFiles = `[test]\ncoverageSkipTestFiles = true\n`; @@ -248,20 +260,38 @@ test.concurrent("coverage of classes counts methods, not synthesized constructor } export const factory = () => class {}; export class Derived extends factory() {} +`, + // The file from #8290: blank lines and four-space indentation between the members. The + // uncalled `consume` was reported as `3-5` (shifted onto `calls = []`) and `another` not at all. + "live.mock.mjs": `export class MockLive { + + calls = [] + + async consume(event) { + this.calls.push({ consume: { event } }) + } + + covered() { return 1 } + + another() { return 2 } +} `, "classes.test.js": `import { test, expect } from "bun:test"; import { Base, Derived } from "./classes.js"; +import { MockLive } from "./live.mock.mjs"; test("covered", () => { expect(new Base().covered()).toBe(1); expect(Derived.name).toBe("Derived"); + expect(new MockLive().covered()).toBe(1); }); `, }); - const { row, record, status } = await coverageOf(String(dir), "classes.js"); + const { of } = await runCoverage(String(dir)); + const { row, record, status } = of("classes.js"); // consume, covered, another and factory; covered and factory ran. expect(record).toContain("\nFNF:4\nFNH:2\n"); - expect(row).toMatch(/^ classes\.js \| 50\.00 \| +\d+\.\d\d \| 3-4,7$/); + expect(row).toMatch(/^ classes\.js +\| 50\.00 \| +\d+\.\d\d \| 3-4,7$/); expect(status(10)).toMatchObject({ 3: "MISSED", 4: "MISSED", @@ -271,6 +301,24 @@ test("covered", () => { 9: "hit", 10: "hit", }); + + const mock = of("live.mock.mjs"); + expect(mock.record).toContain("\nFNF:3\nFNH:1\n"); + expect(mock.row).toMatch(/^ live\.mock\.mjs \| 33\.33 \| +\d+\.\d\d \| 5-6,11$/); + expect(mock.status(12)).toEqual({ + 1: "hit", + 2: "-", + 3: "hit", + 4: "-", + 5: "MISSED", + 6: "MISSED", + 7: "-", + 8: "-", + 9: "hit", + 10: "-", + 11: "MISSED", + 12: "hit", + }); }); test.concurrent("coverageIgnoreSourcemaps reports the line of the dead statement itself", async () => { @@ -304,6 +352,153 @@ test("positive", () => { }); }); +// The file from #5307, in TypeScript: the type annotations are stripped, so generated columns +// differ from the source's and every byte goes through the source map. The `return result` that +// the test never reaches was reported as covered, with the column below 100% but empty. +test.concurrent("coverage of a TypeScript file reports the unreached tail of a nested function", async () => { + using dir = tempDir("cov", { + "bunfig.toml": skipTestFiles, + "table.ts": `export function make() { + const cache: Record = {} + return { + get(n: number) { + const result = cache[n] + if (result === undefined) { + const v = n * 2 + cache[n] = v + return v + } + return result + }, + } +} +export const table = make() +`, + "table.test.ts": `import { test, expect } from "bun:test"; +import { table } from "./table"; +test("get", () => { + expect(table.get(1)).toBe(2); + expect(table.get(2)).toBe(4); +}); +`, + }); + + const { row, status } = await coverageOf(String(dir), "table.ts"); + expect(row).toBe(" table.ts | 100.00 | 91.67 | 11"); + expect(status(15)).toEqual({ + 1: "hit", + 2: "hit", + 3: "hit", + 4: "hit", + 5: "hit", + 6: "hit", + 7: "hit", + 8: "hit", + 9: "hit", + 10: "-", // `}` closing the if block + 11: "MISSED", + 12: "-", // `},` + 13: "hit", // `}` ends the executed return statement + 14: "-", + 15: "hit", + }); +}); + +// Every shape of the uncovered-lines column, in TypeScript: a run that starts on line 1 (the old +// reporter used line 0 as "no run yet"), isolated lines, a run followed by an isolated line, and a +// lone line (the old reporter dropped a trailing single-line run, #9008). The classes have no +// constructor of their own, so JSC's synthesized ones must not count as functions (#7025). +test.concurrent("coverage prints every uncovered run and counts no synthesized constructors", async () => { + using dir = tempDir("cov", { + "bunfig.toml": skipTestFiles, + "classes.ts": `export class Base { + #x = 0; + get x(): number { + return this.#x; + } + set x(v: number) { + this.#x = v; + } +} +export class Derived extends Base { + y = 1; +} +`, + "line1.ts": `export function miss(): never { + throw new Error("a"); +} +export function also(): never { + throw new Error("b"); +} +export function ok2() { return "ok"; } +`, + "many.ts": `export function check(n: number): string { + if (n === 1) { + throw new Error("one"); + } + if (n === 2) { + throw new Error("two"); + } + if (n === 3) { + throw new Error("three"); + } + return "ok"; +} +`, + "mix.ts": `export function mix(n: number): string { + if (n === 1) { + n += 1; + throw new Error("a"); + } + const x = n * 2; + if (x === -1) { + throw new Error("b"); + } + return "ok"; +} +`, + "one.ts": `export function only(n: number): string { + if (n < 0) { + throw new Error("negative"); + } + return "ok"; +} +`, + "demo.test.ts": `import { test, expect } from "bun:test"; +import { Base, Derived } from "./classes"; +import { ok2 } from "./line1"; +import { check } from "./many"; +import { mix } from "./mix"; +import { only } from "./one"; +test("paths", () => { + const d = new Derived(); + d.x = 5; + expect(d.x).toBe(5); + expect(d.y).toBe(1); + expect(new Base().x).toBe(0); + expect(ok2()).toBe("ok"); + expect(check(0)).toBe("ok"); + expect(mix(2)).toBe("ok"); + expect(only(1)).toBe("ok"); +}); +`, + }); + + const { table, of } = await runCoverage(String(dir)); + expect(table).toEqual([ + "File | % Funcs | % Lines | Uncovered Line #s", + "All files | 86.67 | 64.00 |", + " classes.ts | 100.00 | 100.00 |", + " line1.ts | 33.33 | 20.00 | 1-2,4-5", + " many.ts | 100.00 | 62.50 | 3,6,9", + " mix.ts | 100.00 | 62.50 | 3-4,8", + " one.ts | 100.00 | 75.00 | 3", + ]); + // The getter and the setter; `miss`, `also` and `ok2`. + expect(of("classes.ts").record).toContain("\nFNF:2\nFNH:2\n"); + expect(of("line1.ts").record).toContain("\nFNF:3\nFNH:1\n"); +}); + test("coverage excludes node_modules directory", () => { using dir = tempDir("cov", { "node_modules/pi/index.js": ` diff --git a/test/js/node/inspector/inspector-profiler.test.ts b/test/js/node/inspector/inspector-profiler.test.ts index 9b47d593d08e..f320acd67b93 100644 --- a/test/js/node/inspector/inspector-profiler.test.ts +++ b/test/js/node/inspector/inspector-profiler.test.ts @@ -689,6 +689,52 @@ console.log(JSON.stringify({ count: fn?.ranges[0].count })); expect(functionCounts).toContain(0); }); + // JSC records the constructor it synthesizes for a class without one as a function of the + // file that defines the class, with offsets into its own template string ([1, 15] for a base + // class, [1, 38] for a derived one), and never marks it executed. Reported as is, every such + // class adds one count-0 function over the first characters of the file (#7025). + test.concurrent("does not report synthesized default constructors as uncovered functions", async () => { + using dir = tempDir("inspector-coverage-class", { + "fixture.mjs": ` +import { Session } from "node:inspector/promises"; + +const session = new Session(); +session.connect(); +await session.post("Profiler.enable"); +await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true }); + +const { Derived } = await import("./classes.mjs"); +new Derived().x; + +const coverage = await session.post("Profiler.takePreciseCoverage"); +await session.post("Profiler.stopPreciseCoverage"); +session.disconnect(); + +const entry = coverage.result.find(s => s.url.endsWith("classes.mjs")); +const uncovered = entry.functions.filter(f => f.ranges[0].count === 0); +console.log(JSON.stringify({ functionCount: entry.functions.length, uncovered })); +`, + "classes.mjs": ` +export class Base { + get x() { return 1; } +} +export class Derived extends Base {} +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.mjs"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stderrIfFailed: exitCode === 0 ? "" : stderr, exitCode }).toEqual({ stderrIfFailed: "", exitCode: 0 }); + // The whole-script entry, the module body and the getter all ran. + expect(JSON.parse(stdout)).toEqual({ functionCount: 3, uncovered: [] }); + }); + // V8 never emits startOffset === endOffset; @bcoe/v8-coverage (vitest/c8 // --coverage) recurses forever on a zero-width range (issue #39821). test.concurrent("never emits zero-width ranges", async () => { From dd65d36b12bf255f9e82fe7f8aeda9c2141b77e8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:20:09 +0000 Subject: [PATCH 5/7] test(inspector): assert on the counts, not on the number of entries The number of entries Profiler.takePreciseCoverage returns for a script depends on how the raw data is reshaped (#35755 removes the duplicate whole-script entry). The behavior under test is that no entry has a count of 0. --- test/js/node/inspector/inspector-profiler.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/js/node/inspector/inspector-profiler.test.ts b/test/js/node/inspector/inspector-profiler.test.ts index f320acd67b93..53f0ebd02af3 100644 --- a/test/js/node/inspector/inspector-profiler.test.ts +++ b/test/js/node/inspector/inspector-profiler.test.ts @@ -711,8 +711,8 @@ await session.post("Profiler.stopPreciseCoverage"); session.disconnect(); const entry = coverage.result.find(s => s.url.endsWith("classes.mjs")); -const uncovered = entry.functions.filter(f => f.ranges[0].count === 0); -console.log(JSON.stringify({ functionCount: entry.functions.length, uncovered })); +const counts = entry.functions.map(f => f.ranges[0].count); +console.log(JSON.stringify({ counts })); `, "classes.mjs": ` export class Base { @@ -731,8 +731,11 @@ export class Derived extends Base {} const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stderrIfFailed: exitCode === 0 ? "" : stderr, exitCode }).toEqual({ stderrIfFailed: "", exitCode: 0 }); - // The whole-script entry, the module body and the getter all ran. - expect(JSON.parse(stdout)).toEqual({ functionCount: 3, uncovered: [] }); + // Everything the file defines ran (the script, the module body, the getter), so no entry has + // a count of 0. Before the fix the two template ranges were reported with count 0. + const { counts } = JSON.parse(stdout); + expect(counts.length).toBeGreaterThanOrEqual(2); + expect(counts).toEqual(counts.map(() => 1)); }); // V8 never emits startOffset === endOffset; @bcoe/v8-coverage (vitest/c8 From 35cc2637e47850db0da945a69591a491e7a979ef Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:30:29 +0000 Subject: [PATCH 6/7] test(inspector): keep an uncalled method in the synthesized-constructor fixture A consumer that dropped every count-0 function would have passed the previous assertions. The fixture now has one method that never runs and the test checks that exactly its entry keeps a count of 0. --- .../node/inspector/inspector-profiler.test.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/test/js/node/inspector/inspector-profiler.test.ts b/test/js/node/inspector/inspector-profiler.test.ts index 53f0ebd02af3..20ca00136b1f 100644 --- a/test/js/node/inspector/inspector-profiler.test.ts +++ b/test/js/node/inspector/inspector-profiler.test.ts @@ -711,12 +711,18 @@ await session.post("Profiler.stopPreciseCoverage"); session.disconnect(); const entry = coverage.result.find(s => s.url.endsWith("classes.mjs")); -const counts = entry.functions.map(f => f.ranges[0].count); -console.log(JSON.stringify({ counts })); +const source = await Bun.file(new URL("./classes.mjs", import.meta.url)).text(); +console.log( + JSON.stringify({ + unusedBody: source.indexOf("return 2"), + functions: entry.functions.map(f => ({ ...f.ranges[0] })), + }), +); `, "classes.mjs": ` export class Base { get x() { return 1; } + unused() { return 2; } } export class Derived extends Base {} `, @@ -731,11 +737,14 @@ export class Derived extends Base {} const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stderrIfFailed: exitCode === 0 ? "" : stderr, exitCode }).toEqual({ stderrIfFailed: "", exitCode: 0 }); - // Everything the file defines ran (the script, the module body, the getter), so no entry has - // a count of 0. Before the fix the two template ranges were reported with count 0. - const { counts } = JSON.parse(stdout); - expect(counts.length).toBeGreaterThanOrEqual(2); - expect(counts).toEqual(counts.map(() => 1)); + const { unusedBody, functions } = JSON.parse(stdout); + // The only function that did not run is `unused`. Before the fix the two template ranges, + // [1, 15] and [1, 38], were reported with count 0 as well. + const uncovered = functions.filter((range: { count: number }) => range.count === 0); + expect(uncovered).toHaveLength(1); + expect(uncovered[0].startOffset).toBeLessThanOrEqual(unusedBody); + expect(uncovered[0].endOffset).toBeGreaterThan(unusedBody); + expect(functions.length).toBeGreaterThanOrEqual(3); }); // V8 never emits startOffset === endOffset; @bcoe/v8-coverage (vitest/c8 From b20ba8245c867a8f66480f1c2ab6c70c004311c6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 03:57:22 +0000 Subject: [PATCH 7/7] test(coverage): describe the merge rule in terms of the range an uncalled function marks --- src/sourcemap_jsc/CodeCoverage.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sourcemap_jsc/CodeCoverage.rs b/src/sourcemap_jsc/CodeCoverage.rs index 25ba41ffb4aa..b3381fce7e8d 100644 --- a/src/sourcemap_jsc/CodeCoverage.rs +++ b/src/sourcemap_jsc/CodeCoverage.rs @@ -251,11 +251,11 @@ pub mod wire { /// `bun test --parallel` where each worker that loaded the file reports it. /// /// Hits and executed lines/functions/blocks union across reports. Executable -/// lines do not: a process that never ran a function marks the function's -/// whole line span (blank lines included) executable, while one that ran it -/// knows the real lines. So a line counts as executable only if it executed +/// lines do not: a process that never ran a function marks every line of the +/// function's range executable, while one that ran it knows the lines its +/// blocks reached. So a line counts as executable only if it executed /// somewhere or every report agrees it is executable; otherwise the coarse -/// span from an import-only worker would show a fully executed function as +/// range from an import-only worker would show a fully executed function as /// partially covered (#39930). #[derive(Default)] pub struct MergedReport {