Compare commits

...

14 Commits

Author SHA1 Message Date
pfg
de39d9145b update html.zig 2025-08-06 17:47:07 -07:00
pfg
47e63edc3c update 2025-08-06 17:28:04 -07:00
pfg
add6343333 update 2025-08-06 15:32:41 -07:00
pfg
305f71410d upd 2025-08-06 14:40:31 -07:00
pfg
0866653a69 update report format part 1 2025-08-06 14:12:22 -07:00
pfg
ef428aeb9c Merge branch 'main' into claude/html-coverage-reporter 2025-08-06 13:41:08 -07:00
pfg
7630d4ce47 move it into its own file 2025-08-05 21:10:13 -07:00
pfg
e5dab11952 each file 2025-08-05 20:54:54 -07:00
pfg
fd13664d78 ... 2025-08-05 20:28:00 -07:00
pfg
2b2b61cf1e Merge remote-tracking branch 'origin/main' into claude/html-coverage-reporter 2025-08-05 20:23:29 -07:00
autofix-ci[bot]
6dcfe4d51e [autofix.ci] apply automated fixes 2025-08-05 21:26:47 +00:00
Claude Bot
342335b99b Use existing perf trace events for HTML coverage reporter
This avoids build issues while maintaining functionality.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-05 21:23:34 +00:00
Claude Bot
6e4b834a80 Simplify performance tracing for HTML coverage reporter
Temporarily use existing trace events to avoid build issues.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-05 21:22:25 +00:00
Claude Bot
c0a0866d79 Add HTML coverage reporter for bun:test
- Implements Html reporter alongside existing Text and Lcov reporters
- Generates interactive HTML coverage report with source code view
- Includes CLI support: --coverage-reporter html
- Adds bunfig.toml configuration support
- Creates index.html file with coverage statistics and line-by-line annotations
- Supports modern CSS styling with hover effects and color coding

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-05 21:22:05 +00:00
11 changed files with 1163 additions and 64 deletions

View File

@@ -788,6 +788,7 @@ src/shell/states/Subshell.zig
src/shell/subproc.zig
src/shell/util.zig
src/shell/Yield.zig
src/sourcemap/code_coverage/HTML.zig
src/sourcemap/CodeCoverage.zig
src/sourcemap/JSSourceMap.zig
src/sourcemap/LineOffsetTable.zig

View File

@@ -2,34 +2,34 @@
// clang-format off
#define FOR_EACH_TRACE_EVENT(macro) \
macro(Bundler.BindImportsToExports, 0) \
macro(Bundler.CloneLinkerGraph, 1) \
macro(Bundler.CreateNamespaceExports, 2) \
macro(Bundler.FigureOutCommonJS, 3) \
macro(Bundler.MatchImportsWithExports, 4) \
macro(Bundler.ParseJS, 5) \
macro(Bundler.ParseJSON, 6) \
macro(Bundler.ParseTOML, 7) \
macro(Bundler.ResolveExportStarStatements, 8) \
macro(Bundler.Worker.create, 9) \
macro(Bundler.WrapDependencies, 10) \
macro(Bundler.breakOutputIntoPieces, 11) \
macro(Bundler.cloneAST, 12) \
macro(Bundler.computeChunks, 13) \
macro(Bundler.findAllImportedPartsInJSOrder, 14) \
macro(Bundler.findReachableFiles, 15) \
macro(Bundler.generateChunksInParallel, 16) \
macro(Bundler.generateCodeForFileInChunkCss, 17) \
macro(Bundler.generateCodeForFileInChunkJS, 18) \
macro(Bundler.generateIsolatedHash, 19) \
macro(Bundler.generateSourceMapForChunk, 20) \
macro(Bundler.markFileLiveForTreeShaking, 21) \
macro(Bundler.markFileReachableForCodeSplitting, 22) \
macro(Bundler.onParseTaskComplete, 23) \
macro(Bundler.postProcessJSChunk, 24) \
macro(Bundler.readFile, 25) \
macro(Bundler.renameSymbolsInChunk, 26) \
macro(Bundler.scanImportsAndExports, 27) \
macro(Bundler.treeShakingAndCodeSplitting, 28) \
macro(Bundler.breakOutputIntoPieces, 1) \
macro(Bundler.cloneAST, 2) \
macro(Bundler.CloneLinkerGraph, 3) \
macro(Bundler.computeChunks, 4) \
macro(Bundler.CreateNamespaceExports, 5) \
macro(Bundler.FigureOutCommonJS, 6) \
macro(Bundler.findAllImportedPartsInJSOrder, 7) \
macro(Bundler.findReachableFiles, 8) \
macro(Bundler.generateChunksInParallel, 9) \
macro(Bundler.generateCodeForFileInChunkCss, 10) \
macro(Bundler.generateCodeForFileInChunkJS, 11) \
macro(Bundler.generateIsolatedHash, 12) \
macro(Bundler.generateSourceMapForChunk, 13) \
macro(Bundler.markFileLiveForTreeShaking, 14) \
macro(Bundler.markFileReachableForCodeSplitting, 15) \
macro(Bundler.MatchImportsWithExports, 16) \
macro(Bundler.onParseTaskComplete, 17) \
macro(Bundler.ParseJS, 18) \
macro(Bundler.ParseJSON, 19) \
macro(Bundler.ParseTOML, 20) \
macro(Bundler.postProcessJSChunk, 21) \
macro(Bundler.readFile, 22) \
macro(Bundler.renameSymbolsInChunk, 23) \
macro(Bundler.ResolveExportStarStatements, 24) \
macro(Bundler.scanImportsAndExports, 25) \
macro(Bundler.treeShakingAndCodeSplitting, 26) \
macro(Bundler.Worker.create, 27) \
macro(Bundler.WrapDependencies, 28) \
macro(Bundler.writeChunkToDisk, 29) \
macro(Bundler.writeOutputFilesToDisk, 30) \
macro(ExtractTarball.extract, 31) \
@@ -48,15 +48,14 @@
macro(JSPrinter.printWithSourceMap, 44) \
macro(ModuleResolver.resolve, 45) \
macro(PackageInstaller.install, 46) \
macro(PackageInstaller.installPatch, 47) \
macro(PackageManifest.Serializer.loadByFile, 48) \
macro(PackageManifest.Serializer.save, 49) \
macro(RuntimeTranspilerCache.fromFile, 50) \
macro(RuntimeTranspilerCache.save, 51) \
macro(RuntimeTranspilerCache.toFile, 52) \
macro(StandaloneModuleGraph.serialize, 53) \
macro(Symbols.followAll, 54) \
macro(TestCommand.printCodeCoverageLCov, 55) \
macro(TestCommand.printCodeCoverageLCovAndText, 56) \
macro(TestCommand.printCodeCoverageText, 57) \
macro(PackageManifest.Serializer.loadByFile, 47) \
macro(PackageManifest.Serializer.save, 48) \
macro(RuntimeTranspilerCache.fromFile, 49) \
macro(RuntimeTranspilerCache.save, 50) \
macro(RuntimeTranspilerCache.toFile, 51) \
macro(StandaloneModuleGraph.serialize, 52) \
macro(Symbols.followAll, 53) \
macro(TestCommand.printCodeCoverageLCov, 54) \
macro(TestCommand.printCodeCoverageLCovAndText, 55) \
macro(TestCommand.printCodeCoverageText, 56) \
// end

View File

@@ -251,13 +251,15 @@ pub const Bunfig = struct {
}
if (test_.get("coverageReporter")) |expr| brk: {
this.ctx.test_options.coverage.reporters = .{ .text = false, .lcov = false };
this.ctx.test_options.coverage.reporters = .{ .text = false, .lcov = false, .html = false };
if (expr.data == .e_string) {
const item_str = expr.asString(bun.default_allocator) orelse "";
if (bun.strings.eqlComptime(item_str, "text")) {
this.ctx.test_options.coverage.reporters.text = true;
} else if (bun.strings.eqlComptime(item_str, "lcov")) {
this.ctx.test_options.coverage.reporters.lcov = true;
} else if (bun.strings.eqlComptime(item_str, "html")) {
this.ctx.test_options.coverage.reporters.html = true;
} else {
try this.addErrorFormat(expr.loc, allocator, "Invalid coverage reporter \"{s}\"", .{item_str});
}
@@ -274,6 +276,8 @@ pub const Bunfig = struct {
this.ctx.test_options.coverage.reporters.text = true;
} else if (bun.strings.eqlComptime(item_str, "lcov")) {
this.ctx.test_options.coverage.reporters.lcov = true;
} else if (bun.strings.eqlComptime(item_str, "html")) {
this.ctx.test_options.coverage.reporters.html = true;
} else {
try this.addErrorFormat(item.loc, allocator, "Invalid coverage reporter \"{s}\"", .{item_str});
}

View File

@@ -185,7 +185,7 @@ pub const test_only_params = [_]ParamType{
clap.parseParam("--only Only run tests that are marked with \"test.only()\"") catch unreachable,
clap.parseParam("--todo Include tests that are marked with \"test.todo()\"") catch unreachable,
clap.parseParam("--coverage Generate a coverage profile") catch unreachable,
clap.parseParam("--coverage-reporter <STR>... Report coverage in 'text' and/or 'lcov'. Defaults to 'text'.") catch unreachable,
clap.parseParam("--coverage-reporter <STR>... Report coverage in 'text', 'lcov', and/or 'html'. Defaults to 'text'.") catch unreachable,
clap.parseParam("--coverage-dir <STR> Directory for coverage files. Defaults to 'coverage'.") catch unreachable,
clap.parseParam("--bail <NUMBER>? Exit the test suite after <NUMBER> failures. If you do not specify a number, it defaults to 1.") catch unreachable,
clap.parseParam("-t, --test-name-pattern <STR> Run only tests with a name that matches the given regex.") catch unreachable,
@@ -405,12 +405,14 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
}
if (args.options("--coverage-reporter").len > 0) {
ctx.test_options.coverage.reporters = .{ .text = false, .lcov = false };
ctx.test_options.coverage.reporters = .{ .text = false, .lcov = false, .html = false };
for (args.options("--coverage-reporter")) |reporter| {
if (bun.strings.eqlComptime(reporter, "text")) {
ctx.test_options.coverage.reporters.text = true;
} else if (bun.strings.eqlComptime(reporter, "lcov")) {
ctx.test_options.coverage.reporters.lcov = true;
} else if (bun.strings.eqlComptime(reporter, "html")) {
ctx.test_options.coverage.reporters.html = true;
} else {
Output.prettyErrorln("<r><red>error<r>: --coverage-reporter received invalid reporter: \"{s}\"", .{reporter});
Global.exit(1);

View File

@@ -935,7 +935,7 @@ pub const CommandLineReporter = struct {
}
pub fn generateCodeCoverage(this: *CommandLineReporter, vm: *jsc.VirtualMachine, opts: *TestCommand.CodeCoverageOptions, comptime reporters: TestCommand.Reporters, comptime enable_ansi_colors: bool) !void {
if (comptime !reporters.text and !reporters.lcov) {
if (comptime !reporters.text and !reporters.lcov and !reporters.html) {
return;
}
@@ -969,6 +969,14 @@ pub const CommandLineReporter = struct {
comptime reporters: TestCommand.Reporters,
comptime enable_ansi_colors: bool,
) !void {
var scoped_allocator = bun.AllocationScope.init(bun.default_allocator);
defer scoped_allocator.deinit();
const scoped = scoped_allocator.allocator();
var arena_allocator = bun.ArenaAllocator.init(scoped);
defer arena_allocator.deinit();
const arena = arena_allocator.allocator();
const trace = if (reporters.text and reporters.lcov)
bun.perf.trace("TestCommand.printCodeCoverageLCovAndText")
else if (reporters.text)
@@ -976,11 +984,11 @@ pub const CommandLineReporter = struct {
else if (reporters.lcov)
bun.perf.trace("TestCommand.printCodeCoverageLCov")
else
@compileError("No reporters enabled");
bun.perf.trace("TestCommand.printCodeCoverageText");
defer trace.end();
if (comptime !reporters.text and !reporters.lcov) {
if (comptime !reporters.text and !reporters.lcov and !reporters.html) {
@compileError("No reporters enabled");
}
@@ -997,7 +1005,7 @@ pub const CommandLineReporter = struct {
if (opts.ignore_patterns.len > 0) {
var should_ignore = false;
for (opts.ignore_patterns) |pattern| {
if (bun.glob.match(bun.default_allocator, pattern, relative_path).matches()) {
if (bun.glob.match(arena, pattern, relative_path).matches()) {
should_ignore = true;
break;
}
@@ -1031,7 +1039,7 @@ pub const CommandLineReporter = struct {
console.writeAll(Output.prettyFmt("|---------|---------|-------------------<r>\n", enable_ansi_colors)) catch return;
}
var console_buffer = bun.MutableString.initEmpty(bun.default_allocator);
var console_buffer = bun.MutableString.initEmpty(arena);
var console_buffer_buffer = console_buffer.bufferedWriter();
var console_writer = console_buffer_buffer.writer();
@@ -1082,7 +1090,7 @@ pub const CommandLineReporter = struct {
const buffered = buffered_writer: {
const writer = f.writer();
// Heap-allocate the buffered writer because we want a stable memory address + 64 KB is kind of a lot.
const ptr = try bun.default_allocator.create(std.io.BufferedWriter(64 * 1024, bun.sys.File.Writer));
const ptr = try arena.create(std.io.BufferedWriter(64 * 1024, bun.sys.File.Writer));
ptr.* = .{
.end = 0,
.unbuffered_writer = writer,
@@ -1109,6 +1117,39 @@ pub const CommandLineReporter = struct {
}
// --- LCOV ---
// --- HTML ---
// Structure to hold HTML report data in memory
const HtmlReportData = struct {
report: CodeCoverageReport,
sidebar_item: CodeCoverageReport.Html.SidebarItem,
};
var html_reports = if (comptime reporters.html) bun.MultiArrayList(HtmlReportData).empty else {};
defer if (comptime reporters.html) {
for (html_reports.items(.sidebar_item)) |*item| {
arena.free(item.filename);
arena.free(item.html_filename);
}
for (html_reports.items(.report)) |*item| {
item.deinit(arena);
}
html_reports.deinit(arena);
};
// Ensure the directory exists for HTML reports
if (comptime reporters.html) {
var fs = bun.jsc.Node.fs.NodeFS{};
_ = fs.mkdirRecursive(
.{
.path = bun.jsc.Node.PathLike{
.encoded_slice = jsc.ZigString.Slice.fromUTF8NeverFree(opts.reports_directory),
},
.always_return_none = true,
},
);
}
// --- HTML ---
for (byte_ranges) |*entry| {
// Check if this file should be ignored based on coveragePathIgnorePatterns
if (opts.ignore_patterns.len > 0) {
@@ -1128,8 +1169,13 @@ pub const CommandLineReporter = struct {
}
}
var report = CodeCoverageReport.generate(vm.global, bun.default_allocator, entry, opts.ignore_sourcemap) orelse continue;
defer report.deinit(bun.default_allocator);
var report = CodeCoverageReport.generate(vm.global, arena, entry, opts.ignore_sourcemap) orelse continue;
// Report deinit is now handled conditionally based on HTML reporter
defer {
if (comptime !reporters.html) {
report.deinit(arena);
}
}
if (comptime reporters.text) {
var fraction = base_fraction;
@@ -1152,6 +1198,123 @@ pub const CommandLineReporter = struct {
lcov_writer,
) catch continue;
}
if (comptime reporters.html) {
// Collect report data for later processing
var filename = report.source_url.byteSlice();
if (relative_dir.len > 0) {
filename = std.fs.path.relative(std.heap.page_allocator, relative_dir, filename) catch filename;
}
// Generate HTML filename (same logic as in Html.writeFormat)
var html_filename_buf: [std.fs.max_path_bytes]u8 = undefined;
var safe_filename_buf: [std.fs.max_path_bytes]u8 = undefined;
var safe_len: usize = 0;
for (filename) |char| {
if (char == '/' or char == '\\') {
safe_filename_buf[safe_len] = '_';
} else {
safe_filename_buf[safe_len] = char;
}
safe_len += 1;
}
const safe_filename = safe_filename_buf[0..safe_len];
const html_filename = std.fmt.bufPrint(&html_filename_buf, "{s}.html", .{safe_filename}) catch filename;
// Store filename strings in allocator so they remain valid
const stored_filename = try arena.dupe(u8, filename);
const stored_html_filename = try arena.dupe(u8, html_filename);
// Store the report data (transfer ownership instead of deinit)
try html_reports.append(arena, .{
.report = report,
.sidebar_item = .{
.filename = stored_filename,
.html_filename = stored_html_filename,
.coverage = report.linesCoverageFraction(),
},
});
}
}
// Generate all HTML files in one block
if (comptime reporters.html) {
// First, generate index.html
var html_name_buf: bun.PathBuffer = undefined;
var base64_bytes: [8]u8 = undefined;
var shortname_buf: [512]u8 = undefined;
bun.csprng(&base64_bytes);
const tmpname = std.fmt.bufPrintZ(&shortname_buf, ".index.html.{s}.tmp", .{std.fmt.fmtSliceHexLower(&base64_bytes)}) catch unreachable;
const html_name = bun.path.joinAbsStringBufZ(relative_dir, &html_name_buf, &.{ opts.reports_directory, tmpname }, .auto);
const html_file = bun.sys.File.openat(
.cwd(),
html_name,
bun.O.CREAT | bun.O.WRONLY | bun.O.TRUNC | bun.O.CLOEXEC,
0o644,
);
switch (html_file) {
.err => |err| {
Output.err(.lcovCoverageError, "Failed to create HTML coverage file", .{});
Output.printError("\n{s}", .{err});
Global.exit(1);
},
.result => |f| {
defer f.close();
const buffered = buffered_writer: {
const writer = f.writer();
// Heap-allocate the buffered writer because we want a stable memory address + 64 KB is kind of a lot.
const ptr = try bun.default_allocator.create(std.io.BufferedWriter(64 * 1024, bun.sys.File.Writer));
ptr.* = .{
.end = 0,
.unbuffered_writer = writer,
};
break :buffered_writer ptr;
};
defer bun.default_allocator.destroy(buffered);
const html_writer = buffered.writer();
const html_any_writer = html_writer.any();
// Write the complete index page
try CodeCoverageReport.Html.writeIndexPage(
html_reports.items(.report),
html_reports.items(.sidebar_item),
html_any_writer,
);
try buffered.flush();
},
}
// Atomically rename to final name
const cwd = bun.FD.cwd();
bun.sys.moveFileZ(
cwd,
html_name,
cwd,
bun.path.joinAbsStringZ(
relative_dir,
&.{ opts.reports_directory, "index.html" },
.auto,
),
) catch |err| {
Output.err(err, "Failed to save index.html file", .{});
Global.exit(1);
};
// Now generate all detail files with the complete sidebar
for (html_reports.items(.report)) |*report| {
const source_path = report.source_url.slice();
CodeCoverageReport.Html.createDetailFile(
report,
relative_dir,
opts.reports_directory,
source_path,
html_reports.items(.sidebar_item),
) catch continue;
}
}
if (comptime reporters.text) {
@@ -1214,6 +1377,8 @@ pub const CommandLineReporter = struct {
Global.exit(1);
};
}
// HTML footer and finalization is now handled in the block above
}
};
@@ -1259,7 +1424,7 @@ pub const TestCommand = struct {
pub const name = "test";
pub const CodeCoverageOptions = struct {
skip_test_files: bool = !Environment.allow_assert,
reporters: Reporters = .{ .text = true, .lcov = false },
reporters: Reporters = .{ .text = true, .lcov = false, .html = false },
reports_directory: string = "coverage",
fractions: bun.sourcemap.coverage.Fraction = .{},
ignore_sourcemap: bool = false,
@@ -1270,10 +1435,12 @@ pub const TestCommand = struct {
pub const Reporter = enum {
text,
lcov,
html,
};
const Reporters = struct {
text: bool,
lcov: bool,
html: bool,
};
pub const FileReporter = enum {
@@ -1608,8 +1775,10 @@ pub const TestCommand = struct {
switch (Output.enable_ansi_colors_stderr) {
inline else => |colors| switch (coverage_options.reporters.text) {
inline else => |console| switch (coverage_options.reporters.lcov) {
inline else => |lcov| {
try reporter.generateCodeCoverage(vm, &coverage_options, .{ .text = console, .lcov = lcov }, colors);
inline else => |lcov| switch (coverage_options.reporters.html) {
inline else => |html| {
try reporter.generateCodeCoverage(vm, &coverage_options, .{ .text = console, .lcov = lcov, .html = html }, colors);
},
},
},
},

View File

@@ -1,19 +1,12 @@
// Generated with scripts/generate-perf-trace-events.sh
pub const PerfEvent = enum(i32) {
@"Bundler.BindImportsToExports",
@"Bundler.CloneLinkerGraph",
@"Bundler.CreateNamespaceExports",
@"Bundler.FigureOutCommonJS",
@"Bundler.MatchImportsWithExports",
@"Bundler.ParseJS",
@"Bundler.ParseJSON",
@"Bundler.ParseTOML",
@"Bundler.ResolveExportStarStatements",
@"Bundler.Worker.create",
@"Bundler.WrapDependencies",
@"Bundler.breakOutputIntoPieces",
@"Bundler.cloneAST",
@"Bundler.CloneLinkerGraph",
@"Bundler.computeChunks",
@"Bundler.CreateNamespaceExports",
@"Bundler.FigureOutCommonJS",
@"Bundler.findAllImportedPartsInJSOrder",
@"Bundler.findReachableFiles",
@"Bundler.generateChunksInParallel",
@@ -23,12 +16,19 @@ pub const PerfEvent = enum(i32) {
@"Bundler.generateSourceMapForChunk",
@"Bundler.markFileLiveForTreeShaking",
@"Bundler.markFileReachableForCodeSplitting",
@"Bundler.MatchImportsWithExports",
@"Bundler.onParseTaskComplete",
@"Bundler.ParseJS",
@"Bundler.ParseJSON",
@"Bundler.ParseTOML",
@"Bundler.postProcessJSChunk",
@"Bundler.readFile",
@"Bundler.renameSymbolsInChunk",
@"Bundler.ResolveExportStarStatements",
@"Bundler.scanImportsAndExports",
@"Bundler.treeShakingAndCodeSplitting",
@"Bundler.Worker.create",
@"Bundler.WrapDependencies",
@"Bundler.writeChunkToDisk",
@"Bundler.writeOutputFilesToDisk",
@"ExtractTarball.extract",
@@ -47,7 +47,6 @@ pub const PerfEvent = enum(i32) {
@"JSPrinter.printWithSourceMap",
@"ModuleResolver.resolve",
@"PackageInstaller.install",
@"PackageInstaller.installPatch",
@"PackageManifest.Serializer.loadByFile",
@"PackageManifest.Serializer.save",
@"RuntimeTranspilerCache.fromFile",

View File

@@ -261,6 +261,8 @@ pub const Report = struct {
}
};
pub const Html = @import("code_coverage/html.zig");
pub fn deinit(this: *Report, allocator: std.mem.Allocator) void {
this.executable_lines.deinit(allocator);
this.lines_which_have_executed.deinit(allocator);

View File

@@ -0,0 +1,749 @@
pub fn writeFormatWithSidebarItem(
report: *const Report,
sidebar_item: SidebarItem,
writer: anytype,
) !void {
const filename = sidebar_item.filename;
const html_filename = sidebar_item.html_filename;
const functions_fraction = report.functionCoverageFraction();
const lines_fraction = report.linesCoverageFraction();
// Write HTML structure for this file's coverage
try writer.print(
\\ <tr data-file="{s}">
\\ <td><a href="./{s}">{s}</a></td>
\\ <td class="coverage {s}">{d:.2}%</td>
\\ <td class="coverage {s}">{d:.2}%</td>
\\ <td class="uncovered-lines">
, .{ filename, html_filename, filename, if (functions_fraction >= 0.8) "good" else "bad", functions_fraction * 100.0, if (lines_fraction >= 0.8) "good" else "bad", lines_fraction * 100.0 });
// Add uncovered line ranges
const allocator = std.heap.page_allocator;
var executable_lines_that_havent_been_executed = report.lines_which_have_executed.clone(allocator) catch return;
defer executable_lines_that_havent_been_executed.deinit(allocator);
executable_lines_that_havent_been_executed.toggleAll();
executable_lines_that_havent_been_executed.setIntersection(report.executable_lines);
var iter = executable_lines_that_havent_been_executed.iterator(.{});
var start_of_line_range: usize = 0;
var prev_line: usize = 0;
var is_first = true;
while (iter.next()) |next_line| {
if (next_line == (prev_line + 1)) {
prev_line = next_line;
continue;
} else if (is_first and start_of_line_range == 0 and prev_line == 0) {
start_of_line_range = next_line;
prev_line = next_line;
continue;
}
if (is_first) {
is_first = false;
} else {
try writer.writeAll(", ");
}
if (start_of_line_range == prev_line) {
try writer.print("{d}", .{start_of_line_range + 1});
} else {
try writer.print("{d}-{d}", .{ start_of_line_range + 1, prev_line + 1 });
}
prev_line = next_line;
start_of_line_range = next_line;
}
if (prev_line != start_of_line_range or (prev_line > 0 and start_of_line_range > 0)) {
if (is_first) {
is_first = false;
} else {
try writer.writeAll(", ");
}
if (prev_line == start_of_line_range) {
try writer.print("{d}", .{prev_line + 1});
} else {
try writer.print("{d}-{d}", .{ start_of_line_range + 1, prev_line + 1 });
}
}
try writer.writeAll(
\\ </td>
\\ </tr>
);
}
pub fn writeDetailedFile(
report: *const Report,
base_path: []const u8,
source_path: []const u8,
writer: anytype,
) !void {
_ = writeDetailedFileWithTree(report, base_path, source_path, null, writer) catch |err| return err;
}
pub fn writeDetailedFileWithTree(
report: *const Report,
base_path: []const u8,
source_path: []const u8,
sidebar_items: []const SidebarItem,
writer: anytype,
) !void {
var filename = report.source_url.byteSlice();
if (base_path.len > 0) {
filename = std.fs.path.relative(std.heap.page_allocator, base_path, filename) catch filename;
}
const functions_fraction = report.functionCoverageFraction();
const lines_fraction = report.linesCoverageFraction();
const covered = report.lines_which_have_executed.count();
const total = report.executable_lines.count();
// Write common page header
var title_buf: [256]u8 = undefined;
const title = std.fmt.bufPrint(&title_buf, "Coverage: {s}", .{std.fs.path.basename(filename)}) catch "Coverage Report";
try writePageHeader(title, sidebar_items, filename, writer);
// Write the main content specific to detailed file view
try writer.writeAll(
\\ <div class="main-content">
\\ <div class="header">
\\ <h1>Coverage Report</h1>
);
try writer.print(" <div class=\"path\">{s}</div>\n", .{filename});
try writer.print(
\\ <div class="stats">
\\ <div class="stat {s}">
\\ <span class="stat-label">Lines:</span>
\\ <span class="stat-value">{d:.1}%</span>
\\ <span class="stat-label">({d}/{d})</span>
\\ </div>
\\ <div class="stat {s}">
\\ <span class="stat-label">Functions:</span>
\\ <span class="stat-value">{d:.1}%</span>
\\ </div>
\\ </div>
\\ </div>
\\ <div class="source-container">
\\ <div class="source-code">
, .{
if (lines_fraction >= 0.8) "good" else "bad",
lines_fraction * 100.0,
covered,
total,
if (functions_fraction >= 0.8) "good" else "bad",
functions_fraction * 100.0,
});
// Try to read the source file
const allocator = std.heap.page_allocator;
const source_file = std.fs.cwd().openFile(source_path, .{}) catch {
try writer.print("<div class=\"line\">Could not read source file: {s}</div>", .{source_path});
try writer.writeAll(" </div>\n </div>\n");
try writePageFooter(writer);
return;
};
defer source_file.close();
const source_contents = source_file.readToEndAlloc(allocator, std.math.maxInt(usize)) catch {
try writer.print("<div class=\"line\">Could not read source file: {s}</div>", .{source_path});
try writer.writeAll(" </div>\n </div>\n");
try writePageFooter(writer);
return;
};
defer allocator.free(source_contents);
// Split source into lines and annotate with coverage
var lines = std.mem.splitScalar(u8, source_contents, '\n');
var line_number: u32 = 1;
const line_hits = report.line_hits.slice();
while (lines.next()) |line| {
const line_index = line_number - 1;
const is_executable = line_index < report.executable_lines.bit_length and report.executable_lines.isSet(line_index);
const is_covered = line_index < report.lines_which_have_executed.bit_length and report.lines_which_have_executed.isSet(line_index);
const hit_count = if (line_index < line_hits.len) line_hits[line_index] else 0;
const css_class = if (!is_executable)
"line non-executable"
else if (is_covered)
"line covered"
else
"line uncovered";
try writer.print("<div class=\"{s}\" id=\"{d}\">", .{ css_class, line_number });
try writer.print("<span class=\"line-number\">{d}</span>", .{line_number});
if (is_executable and hit_count > 0) {
try writer.print("<span class=\"hit-count\">{d}x</span>", .{hit_count});
} else if (is_executable) {
try writer.writeAll("<span class=\"hit-count\"></span>");
} else {
try writer.writeAll("<span class=\"hit-count\" style=\"visibility: hidden;\"></span>");
}
try writer.writeAll("<span class=\"code-content\">");
// HTML escape the source line
for (line) |char| {
switch (char) {
'<' => try writer.writeAll("&lt;"),
'>' => try writer.writeAll("&gt;"),
'&' => try writer.writeAll("&amp;"),
'"' => try writer.writeAll("&quot;"),
'\'' => try writer.writeAll("&#39;"),
else => try writer.writeByte(char),
}
}
try writer.writeAll("</span></div>\n");
line_number += 1;
}
try writer.writeAll(" </div>\n </div>\n");
try writePageFooter(writer);
}
const bun = @import("bun");
const Report = bun.sourcemap.coverage.Report;
const std = @import("std");
const Output = bun.Output;
const Global = bun.Global;
/// Simplified sidebar data - only what's needed for the file tree
pub const SidebarItem = struct {
filename: []const u8,
html_filename: []const u8,
coverage: f64,
};
/// Writes the common HTML header including HEAD, CSS, and sidebar opening
/// Stops at <div class="main-content"> so content can be added after
pub fn writePageHeader(
title: []const u8,
sidebar_items: []const SidebarItem,
active_filename: ?[]const u8,
writer: anytype,
) !void {
// Write HTML header
try writer.writeAll(
\\<!DOCTYPE html>
\\<html lang="en">
\\<head>
\\ <meta charset="UTF-8">
\\ <meta name="viewport" content="width=device-width, initial-scale=1.0">
);
try writer.print(" <title>{s}</title>\n", .{title});
try writer.writeAll(
\\ <style>
\\ body { font-family: 'SF Mono', Monaco, monospace; margin: 0; padding: 0; background: #1e1e1e; color: #d4d4d4; display: flex; height: 100vh; }
\\ .sidebar { width: 280px; background: #252526; border-right: 1px solid #3e3e3e; overflow-y: auto; flex-shrink: 0; }
\\ .sidebar-header { padding: 15px 20px; background: #2d2d2d; border-bottom: 1px solid #3e3e3e; position: sticky; top: 0; z-index: 10; }
\\ .sidebar-header h2 { margin: 0; font-size: 14px; font-weight: normal; color: #cccccc; }
\\ .file-tree { padding: 10px 0; }
\\ .tree-folder summary { padding: 5px 10px; cursor: pointer; display: flex; align-items: center; color: #cccccc; font-size: 13px; user-select: none; list-style: none; }
\\ .tree-folder summary::-webkit-details-marker { display: none; }
\\ .tree-folder summary:hover { background: #2a2d2e; }
\\ .tree-folder:not([open]):has(.file-tree-item.active) > summary { background-color: #37373d; }
\\ .tree-chevron { display: inline-block; width: 12px; margin-right: 4px; transition: transform 0.2s; }
\\ .tree-folder[open] .tree-chevron { transform: rotate(0deg); }
\\ .tree-folder:not([open]) .tree-chevron { transform: rotate(-90deg); }
\\ .file-tree-item { padding: 5px 10px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; text-decoration: none; color: #cccccc; font-size: 13px; }
\\ .file-tree-item:hover { background: #2a2d2e; }
\\ .file-tree-item.active { background: #37373d; }
\\ .file-tree-item .file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
\\ .file-tree-item .coverage-badge { padding: 2px 6px; border-radius: 3px; font-size: 11px; font-weight: bold; }
\\ .file-tree-item .coverage-badge.good { background: #2d4f3e; color: #4ec9b0; }
\\ .file-tree-item .coverage-badge.medium { background: #4a3c28; color: #dcdcaa; }
\\ .file-tree-item .coverage-badge.bad { background: #5a2d2d; color: #f48771; }
\\ .main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
\\ .header { background: #2d2d2d; padding: 20px; border-bottom: 1px solid #3e3e3e; }
\\ .header h1 { margin: 0 0 10px 0; font-size: 18px; font-weight: normal; color: #cccccc; }
\\ .header .path { color: #858585; font-size: 14px; margin-bottom: 15px; }
\\ .stats { display: flex; gap: 30px; font-size: 14px; }
\\ .stat { display: flex; align-items: center; gap: 8px; }
\\ .stat-label { color: #858585; }
\\ .stat-value { font-weight: bold; }
\\ .stat.good .stat-value { color: #4ec9b0; }
\\ .stat.bad .stat-value { color: #f48771; }
\\ .source-container { flex: 1; overflow-y: auto; }
\\ .source-code { margin: 0; padding: 20px 0; font-size: 14px; line-height: 1.5; }
\\ .line { display: flex; position: relative; align-items: flex-start; min-height: 1.5em; }
\\ .line:hover { background: #2a2a2a; }
\\ .line-number { display: inline-block; width: 60px; text-align: right; color: #858585; user-select: none; }
\\ .line.covered { background: linear-gradient(90deg, #2d4f3e 0%, transparent 70%); }
\\ .line.covered .line-number { color: #4ec9b0; }
\\ .line.uncovered { background: linear-gradient(90deg, #5a2d2d 0%, transparent 70%); }
\\ .line.uncovered .line-number { color: #f48771; }
\\ .line.non-executable { opacity: 0.6; }
\\ .hit-count { display: inline-block; width: 45px; text-align: right; color: #858585; font-size: 12px; margin-right: 15px; margin-left: 10px; user-select: none; }
\\ .line.covered .hit-count { color: #4ec9b0; }
\\ pre { margin: 0; display: inline; }
\\ .code-content { white-space: pre; }
\\ /* Index page specific styles */
\\ .main-content.index { padding: 20px; overflow-y: auto; }
\\ h1 { color: #569cd6; margin: 0 0 20px 0; font-size: 24px; }
\\ .summary { background: #252526; padding: 20px; border-radius: 5px; margin-bottom: 30px; }
\\ .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
\\ .summary-item { }
\\ .summary-label { color: #858585; font-size: 12px; margin-bottom: 5px; }
\\ .summary-value { font-size: 24px; font-weight: bold; }
\\ .summary-value.good { color: #4ec9b0; }
\\ .summary-value.medium { color: #dcdcaa; }
\\ .summary-value.bad { color: #f48771; }
\\ .summary-detail { color: #858585; font-size: 12px; margin-top: 5px; }
\\ .files-table { background: #252526; border-radius: 5px; overflow: hidden; }
\\ table { width: 100%; border-collapse: collapse; }
\\ th { background: #2d2d2d; padding: 10px; text-align: left; font-weight: normal; color: #cccccc; }
\\ td { padding: 10px; border-top: 1px solid #3e3e3e; }
\\ tr:hover { background: #2a2d2e; }
\\ a { color: #569cd6; text-decoration: none; }
\\ a:hover { text-decoration: underline; }
\\ .coverage { text-align: right; font-weight: bold; }
\\ .coverage.good { color: #4ec9b0; }
\\ .coverage.bad { color: #f48771; }
\\ .uncovered-lines { color: #858585; font-size: 0.9em; }
\\ </style>
\\ <script>
\\ // SessionStorage keys
\\ const SIDEBAR_SCROLL_KEY = 'coverage-sidebar-scroll';
\\ const FOLDER_STATES_KEY = 'coverage-folder-states';
\\
\\ // Save sidebar scroll position
\\ function saveSidebarScroll() {
\\ const sidebar = document.querySelector('.sidebar');
\\ if (sidebar) {
\\ sessionStorage.setItem(SIDEBAR_SCROLL_KEY, sidebar.scrollTop);
\\ }
\\ }
\\
\\ // Restore sidebar scroll position
\\ function restoreSidebarScroll() {
\\ const sidebar = document.querySelector('.sidebar');
\\ const scrollPos = sessionStorage.getItem(SIDEBAR_SCROLL_KEY);
\\ if (sidebar && scrollPos) {
\\ sidebar.scrollTop = parseInt(scrollPos, 10);
\\ }
\\ }
\\
\\ // Save folder states (open/closed)
\\ function saveFolderStates() {
\\ const folders = document.querySelectorAll('.tree-folder');
\\ const states = {};
\\ folders.forEach((folder, index) => {
\\ const folderPath = getFolderPath(folder);
\\ states[folderPath] = folder.open;
\\ });
\\ sessionStorage.setItem(FOLDER_STATES_KEY, JSON.stringify(states));
\\ }
\\
\\ // Get a unique path identifier for a folder
\\ function getFolderPath(folder) {
\\ const summary = folder.querySelector('summary');
\\ const pathComponents = [];
\\ let current = folder;
\\
\\ while (current) {
\\ const summaryText = current.querySelector('summary > span:last-child');
\\ if (summaryText) {
\\ pathComponents.unshift(summaryText.textContent);
\\ }
\\ current = current.parentElement.closest('.tree-folder');
\\ }
\\
\\ return pathComponents.join('/');
\\ }
\\
\\ // Restore folder states
\\ function restoreFolderStates() {
\\ const statesJson = sessionStorage.getItem(FOLDER_STATES_KEY);
\\ if (!statesJson) return;
\\
\\ try {
\\ const states = JSON.parse(statesJson);
\\ const folders = document.querySelectorAll('.tree-folder');
\\
\\ folders.forEach((folder) => {
\\ const folderPath = getFolderPath(folder);
\\ if (folderPath in states) {
\\ folder.open = states[folderPath];
\\ }
\\ });
\\ } catch (e) {
\\ console.error('Failed to restore folder states:', e);
\\ }
\\ }
\\
\\ // Initialize when DOM is ready
\\ document.addEventListener('DOMContentLoaded', function() {
\\ // Restore states
\\ restoreFolderStates();
\\ restoreSidebarScroll();
\\
\\ // Save scroll position on scroll
\\ const sidebar = document.querySelector('.sidebar');
\\ if (sidebar) {
\\ sidebar.addEventListener('scroll', saveSidebarScroll);
\\ }
\\
\\ // Save folder states when folders are toggled
\\ const folders = document.querySelectorAll('.tree-folder');
\\ folders.forEach(folder => {
\\ folder.addEventListener('toggle', saveFolderStates);
\\ });
\\
\\ // Save state when clicking file links
\\ const fileLinks = document.querySelectorAll('.file-tree-item');
\\ fileLinks.forEach(link => {
\\ link.addEventListener('click', function() {
\\ saveSidebarScroll();
\\ saveFolderStates();
\\ });
\\ });
\\ });
\\
\\ // Save state before unload (for browser back/forward)
\\ window.addEventListener('beforeunload', function() {
\\ saveSidebarScroll();
\\ saveFolderStates();
\\ });
\\
\\ // Restore states when navigating with browser back/forward buttons
\\ window.addEventListener('popstate', function() {
\\ restoreFolderStates();
\\ restoreSidebarScroll();
\\ });
\\
\\ // Also handle pageshow event for better browser compatibility
\\ window.addEventListener('pageshow', function(event) {
\\ restoreFolderStates();
\\ restoreSidebarScroll();
\\ });
\\ </script>
\\</head>
\\<body>
\\ <div class="sidebar">
\\ <div class="sidebar-header">
\\ <h2>Files</h2>
\\ </div>
\\ <div class="file-tree">
);
// Add Home/Summary tab at the top
const is_index = active_filename == null;
try writer.print(
\\ <a href="./index.html" class="file-tree-item{s}">
\\ <span class="file-name">Summary</span>
\\ </a>
\\ <div style="height: 1px; background: #3e3e3e; margin: 10px 0;"></div>
\\
, .{if (is_index) " active" else ""});
// Build tree structure from file paths
try writeFileTreeItems(sidebar_items, active_filename, writer);
try writer.writeAll(
\\ </div>
\\ </div>
);
}
fn writeFileTreeItems(
sidebar_items: []const SidebarItem,
active_filename: ?[]const u8,
writer: anytype,
) !void {
const allocator = std.heap.page_allocator;
// Stack to track current directory path
var path_stack = std.ArrayList([]const u8).init(allocator);
defer path_stack.deinit();
for (sidebar_items) |item| {
// Split the filename into path components
var path_components = std.ArrayList([]const u8).init(allocator);
defer path_components.deinit();
var iter = std.mem.tokenizeAny(u8, item.filename, "/\\");
while (iter.next()) |component| {
try path_components.append(component);
}
// Compare with current stack to find common prefix
var common_depth: usize = 0;
while (common_depth < path_stack.items.len and
common_depth < path_components.items.len - 1)
{
if (!std.mem.eql(u8, path_stack.items[common_depth], path_components.items[common_depth])) {
break;
}
common_depth += 1;
}
// Close folders that are no longer in the path
var i = path_stack.items.len;
while (i > common_depth) {
i -= 1;
// Write closing details tag for each level
for (0..i + 1) |_| {
try writer.writeAll(" ");
}
try writer.writeAll("</details>\n");
}
// Open new folders
i = common_depth;
while (i < path_components.items.len - 1) {
// Update stack
if (i >= path_stack.items.len) {
try path_stack.append(path_components.items[i]);
} else {
path_stack.items[i] = path_components.items[i];
}
// Write folder opening with proper indentation
for (0..i + 1) |_| {
try writer.writeAll(" ");
}
try writer.writeAll("<details class=\"tree-folder\" open>\n");
for (0..i + 2) |_| {
try writer.writeAll(" ");
}
try writer.print(
\\<summary style="padding-left: {d}px;">
\\ <span class="tree-chevron">▼</span>
\\ <span>{s}</span>
\\</summary>
\\
, .{
10 + (i * 17), // Base padding of 10px + 17px per depth level
path_components.items[i],
});
i += 1;
}
// Resize stack to current depth
try path_stack.resize(path_components.items.len - 1);
// Write the file item with proper indentation
const coverage_class = if (item.coverage >= 0.8) "good" else if (item.coverage >= 0.5) "medium" else "bad";
const is_active = if (active_filename) |active| std.mem.eql(u8, item.filename, active) else false;
// Indent based on the depth (number of parent folders)
for (0..path_stack.items.len + 1) |_| {
try writer.writeAll(" ");
}
const file_basename = path_components.items[path_components.items.len - 1];
try writer.print(
\\<a href="./{s}" class="file-tree-item{s}" style="padding-left: {d}px;">
\\ <span class="file-name">{s}</span>
\\ <span class="coverage-badge {s}">{d:.0}%</span>
\\</a>
\\
, .{
item.html_filename,
if (is_active) " active" else "",
10 + (path_stack.items.len * 17), // Base padding of 26px + 17px per depth level
file_basename,
coverage_class,
item.coverage * 100.0,
});
}
// Close any remaining open folders
var i = path_stack.items.len;
while (i > 0) {
i -= 1;
for (0..i + 1) |_| {
try writer.writeAll(" ");
}
try writer.writeAll("</details>\n");
}
}
/// Writes the common HTML footer closing all tags
pub fn writePageFooter(writer: anytype) !void {
try writer.writeAll("</body>\n</html>\n");
}
pub fn writeIndexPage(
reports: []const Report,
sidebar_items: []const SidebarItem,
writer: anytype,
) !void {
// Calculate overall statistics
var total_functions: usize = 0;
var covered_functions: usize = 0;
var total_lines: usize = 0;
var covered_lines: usize = 0;
for (reports) |report| {
const exec_lines = report.executable_lines.count();
const covered_exec_lines = report.lines_which_have_executed.count();
total_functions += report.functions_which_have_executed.bit_length;
covered_functions += report.functions_which_have_executed.count();
total_lines += exec_lines;
covered_lines += covered_exec_lines;
}
const overall_functions = if (total_functions > 0) @as(f64, @floatFromInt(covered_functions)) / @as(f64, @floatFromInt(total_functions)) else 0.0;
const overall_lines = if (total_lines > 0) @as(f64, @floatFromInt(covered_lines)) / @as(f64, @floatFromInt(total_lines)) else 0.0;
// Write common page header (null for active_filename means index page is active)
try writePageHeader("Coverage Report", sidebar_items, null, writer);
// Write the main content specific to index page
try writer.writeAll(
\\ <div class="main-content index">
\\ <div class="header">
\\ <h1>Coverage Report</h1>
\\ </div>
\\ <div class="summary">
\\ <div class="summary-grid">
\\ <div class="summary-item">
\\ <div class="summary-label">Overall Lines</div>
);
const lines_class = if (overall_lines >= 0.8) "good" else if (overall_lines >= 0.5) "medium" else "bad";
try writer.print(
\\ <div class="summary-value {s}">{d:.1}%</div>
\\ <div class="summary-detail">{d} / {d} lines</div>
\\ </div>
\\ <div class="summary-item">
\\ <div class="summary-label">Overall Functions</div>
, .{
lines_class,
overall_lines * 100.0,
covered_lines,
total_lines,
});
const func_class = if (overall_functions >= 0.8) "good" else if (overall_functions >= 0.5) "medium" else "bad";
try writer.print(
\\ <div class="summary-value {s}">{d:.1}%</div>
\\ <div class="summary-detail">{d} / {d} functions</div>
\\ </div>
\\ <div class="summary-item">
\\ <div class="summary-label">Generated</div>
\\ <div class="summary-value" style="font-size: 14px; color: #858585;">
, .{
func_class,
overall_functions * 100.0,
covered_functions,
total_functions,
});
// Add timestamp
const timestamp_ms = std.time.milliTimestamp();
const seconds = @divTrunc(timestamp_ms, std.time.ms_per_s);
const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(seconds) };
const epoch_day = epoch_seconds.getEpochDay();
const year_day = epoch_day.calculateYearDay();
const month_day = year_day.calculateMonthDay();
try writer.print("{d}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2}", .{
year_day.year,
month_day.month.numeric(),
month_day.day_index + 1,
epoch_seconds.getDaySeconds().getHoursIntoDay(),
epoch_seconds.getDaySeconds().getMinutesIntoHour(),
epoch_seconds.getDaySeconds().getSecondsIntoMinute(),
});
try writer.writeAll(
\\ </div>
\\ </div>
\\ </div>
\\ </div>
\\ <div class="files-table">
\\ <table>
\\ <thead>
\\ <tr>
\\ <th>File</th>
\\ <th class="coverage">Functions</th>
\\ <th class="coverage">Lines</th>
\\ <th>Uncovered Lines</th>
\\ </tr>
\\ </thead>
\\ <tbody>
);
// Write each file's summary row
for (reports, sidebar_items) |report, sidebar_item| {
try writeFormatWithSidebarItem(&report, sidebar_item, writer);
}
try writer.writeAll(
\\ </tbody>
\\ </table>
\\ </div>
);
try writePageFooter(writer);
}
pub fn createDetailFile(
report: *const Report,
relative_dir: []const u8,
reports_directory: []const u8,
source_path: []const u8,
sidebar_items: []const SidebarItem,
) !void {
const relative_source_path = if (relative_dir.len > 0) bun.path.relative(relative_dir, source_path) else source_path;
// Create HTML filename for this source file using the same logic as in writeFormat
var detail_html_name_buf: bun.PathBuffer = undefined;
var safe_filename_buf: [std.fs.max_path_bytes]u8 = undefined;
// Replace slashes with underscores in the filename
var safe_len: usize = 0;
for (relative_source_path) |char| {
if (char == '/' or char == '\\') {
safe_filename_buf[safe_len] = '_';
} else {
safe_filename_buf[safe_len] = char;
}
safe_len += 1;
}
const safe_filename = safe_filename_buf[0..safe_len];
const detail_html_filename = std.fmt.bufPrint(&detail_html_name_buf, "{s}.html", .{safe_filename}) catch return;
// Write directly to final path
const detail_path = bun.path.joinAbsStringBufZ(relative_dir, &detail_html_name_buf, &.{ reports_directory, detail_html_filename }, .auto);
const detail_file = bun.sys.File.openat(
.cwd(),
detail_path,
bun.O.WRONLY | bun.O.CREAT | bun.O.TRUNC,
0o644,
);
switch (detail_file) {
.err => |err| {
Output.err(.lcovCoverageError, "Failed to create HTML detail file", .{});
Output.printError("\n{s}", .{err});
return;
},
.result => |file| {
defer file.close();
var detail_buffered_writer = std.io.bufferedWriter(file.writer());
const detail_writer = detail_buffered_writer.writer();
// Write detailed coverage HTML for this source file
writeDetailedFileWithTree(
report,
relative_dir,
source_path,
sidebar_items,
detail_writer,
) catch return;
detail_buffered_writer.flush() catch return;
},
}
}

View File

@@ -0,0 +1,10 @@
import { test, expect } from "bun:test";
import { add, subtract } from "./demo";
test("add function", () => {
expect(add(2, 3)).toBe(5);
});
test("subtract function", () => {
expect(subtract(5, 3)).toBe(2);
});

View File

@@ -0,0 +1,11 @@
export function add(a: number, b: number): number {
return a + b;
}
export function subtract(a: number, b: number): number {
return a - b;
}
export function uncoveredFunction(): string {
return "this function is not covered";
}

View File

@@ -0,0 +1,153 @@
import { describe, expect, it } from "bun:test";
import { existsSync, readFileSync } from "fs";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
import { join } from "path";
describe("HTML coverage reporter", () => {
it("should generate an HTML coverage report", async () => {
const dir = tempDirWithFiles("html-coverage-test", {
"demo.ts": `
export function add(a: number, b: number): number {
return a + b;
}
export function subtract(a: number, b: number): number {
return a - b;
}
export function uncoveredFunction(): string {
return "this function is not covered";
}
`,
"demo.test.ts": `
import { test, expect } from "bun:test";
import { add, subtract } from "./demo";
test("add function", () => {
expect(add(2, 3)).toBe(5);
});
test("subtract function", () => {
expect(subtract(5, 3)).toBe(2);
});
`,
});
const result = Bun.spawn({
cmd: [bunExe(), "test", "--coverage", "--coverage-reporter", "html", "./demo.test.ts"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(result.stdout).text(),
new Response(result.stderr).text(),
result.exited,
]);
expect(exitCode).toBe(0);
// Check that the index.html file was created
const htmlPath = join(dir, "coverage", "index.html");
expect(existsSync(htmlPath)).toBe(true);
// Check the index HTML content
const htmlContent = readFileSync(htmlPath, "utf-8");
// Should contain basic HTML structure
expect(htmlContent).toContain("<!DOCTYPE html>");
expect(htmlContent).toContain("<title>Bun Coverage Report</title>");
expect(htmlContent).toContain("<h1>Bun Coverage Report</h1>");
// Should contain the demo.ts file with link to detail page
expect(htmlContent).toContain("demo.ts");
expect(htmlContent).toContain("demo.ts.html");
// Should contain coverage information
expect(htmlContent).toContain("Functions");
expect(htmlContent).toContain("Lines");
expect(htmlContent).toContain("Uncovered Lines");
// Should have CSS styling
expect(htmlContent).toContain(".coverage");
expect(htmlContent).toContain("font-family");
// Check that the detail HTML file was created for demo.ts
const detailHtmlPath = join(dir, "coverage", "demo.ts.html");
expect(existsSync(detailHtmlPath)).toBe(true);
// Check the detail HTML content
const detailHtmlContent = readFileSync(detailHtmlPath, "utf-8");
// Should contain detailed coverage view
expect(detailHtmlContent).toContain("<!DOCTYPE html>");
expect(detailHtmlContent).toContain("Coverage: demo.ts");
expect(detailHtmlContent).toContain("Back to summary");
// Should show the source code with coverage highlighting
expect(detailHtmlContent).toContain("export function add");
expect(detailHtmlContent).toContain("export function subtract");
expect(detailHtmlContent).toContain("export function uncoveredFunction");
// Should have line numbers and coverage indicators
expect(detailHtmlContent).toContain("line covered");
expect(detailHtmlContent).toContain("line uncovered");
expect(detailHtmlContent).toContain("line-number");
});
it("should generate HTML coverage alongside other reporters", async () => {
const dir = tempDirWithFiles("html-multiple-reporters", {
"lib.ts": `
export function multiply(a: number, b: number): number {
return a * b;
}
`,
"lib.test.ts": `
import { test, expect } from "bun:test";
import { multiply } from "./lib";
test("multiply function", () => {
expect(multiply(3, 4)).toBe(12);
});
`,
});
const result = Bun.spawn({
cmd: [
bunExe(),
"test",
"--coverage",
"--coverage-reporter",
"text",
"--coverage-reporter",
"html",
"--coverage-reporter",
"lcov",
"./lib.test.ts",
],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(result.stdout).text(),
new Response(result.stderr).text(),
result.exited,
]);
expect(exitCode).toBe(0);
// Check that all coverage files were created
expect(existsSync(join(dir, "coverage", "index.html"))).toBe(true);
expect(existsSync(join(dir, "coverage", "lcov.info"))).toBe(true);
// Check text output contains coverage table
expect(stderr).toContain("lib.ts");
expect(stderr).toContain("% Funcs");
expect(stderr).toContain("% Lines");
});
});