mirror of
https://github.com/oven-sh/bun
synced 2026-02-05 16:38:55 +00:00
Compare commits
14 Commits
dylan/pyth
...
claude/htm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de39d9145b | ||
|
|
47e63edc3c | ||
|
|
add6343333 | ||
|
|
305f71410d | ||
|
|
0866653a69 | ||
|
|
ef428aeb9c | ||
|
|
7630d4ce47 | ||
|
|
e5dab11952 | ||
|
|
fd13664d78 | ||
|
|
2b2b61cf1e | ||
|
|
6dcfe4d51e | ||
|
|
342335b99b | ||
|
|
6e4b834a80 | ||
|
|
c0a0866d79 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
749
src/sourcemap/code_coverage/HTML.zig
Normal file
749
src/sourcemap/code_coverage/HTML.zig
Normal 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("<"),
|
||||
'>' => try writer.writeAll(">"),
|
||||
'&' => try writer.writeAll("&"),
|
||||
'"' => try writer.writeAll("""),
|
||||
'\'' => try writer.writeAll("'"),
|
||||
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;
|
||||
},
|
||||
}
|
||||
}
|
||||
10
test-html-coverage/demo.test.ts
Normal file
10
test-html-coverage/demo.test.ts
Normal 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);
|
||||
});
|
||||
11
test-html-coverage/demo.ts
Normal file
11
test-html-coverage/demo.ts
Normal 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";
|
||||
}
|
||||
153
test/cli/test/html-coverage.test.ts
Normal file
153
test/cli/test/html-coverage.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user