mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 23:18:47 +00:00
Compare commits
6 Commits
dylan/pyth
...
claude/cov
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf43feebd7 | ||
|
|
7387522215 | ||
|
|
6434022e77 | ||
|
|
3c33410666 | ||
|
|
993d44ccf4 | ||
|
|
d329278996 |
@@ -212,6 +212,7 @@ pub const test_only_params = [_]ParamType{
|
||||
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-dir <STR> Directory for coverage files. Defaults to 'coverage'.") catch unreachable,
|
||||
clap.parseParam("--coverage-changes <STR>? Report coverage for changed lines vs base branch. Defaults to HEAD (uncommitted changes).") 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,
|
||||
clap.parseParam("--reporter <STR> Test output reporter format. Available: 'junit' (requires --reporter-outfile), 'dots'. Default: console output.") catch unreachable,
|
||||
@@ -512,6 +513,12 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
|
||||
ctx.test_options.coverage.reports_directory = dir;
|
||||
}
|
||||
|
||||
if (args.option("--coverage-changes")) |base_branch| {
|
||||
ctx.test_options.coverage.changes_base_branch = if (base_branch.len > 0) base_branch else "HEAD";
|
||||
ctx.test_options.coverage.enabled = true;
|
||||
ctx.test_options.coverage.fail_on_low_coverage = true;
|
||||
}
|
||||
|
||||
if (args.option("--bail")) |bail| {
|
||||
if (bail.len > 0) {
|
||||
ctx.test_options.bail = std.fmt.parseInt(u32, bail, 10) catch |e| {
|
||||
|
||||
@@ -991,7 +991,20 @@ pub const CommandLineReporter = struct {
|
||||
bun.SourceMap.coverage.ByteRangeMapping.isLessThan,
|
||||
);
|
||||
|
||||
try this.printCodeCoverage(vm, opts, byte_ranges.items, reporters, enable_ansi_colors);
|
||||
// Get git diff if --coverage-changes was specified
|
||||
var git_diff: ?bun.SourceMap.GitDiff.GitDiffResult = null;
|
||||
defer if (git_diff) |*gd| gd.deinit();
|
||||
|
||||
if (opts.changes_base_branch) |base_branch| {
|
||||
git_diff = try bun.SourceMap.GitDiff.getChangedFiles(bun.default_allocator, base_branch, vm.transpiler.fs.top_level_dir);
|
||||
if (git_diff.?.error_message) |err_msg| {
|
||||
Output.prettyErrorln("<r><red>error<r>: Failed to get git diff: {s}", .{err_msg});
|
||||
git_diff.?.deinit();
|
||||
git_diff = null;
|
||||
}
|
||||
}
|
||||
|
||||
try this.printCodeCoverage(vm, opts, byte_ranges.items, if (git_diff) |*gd| gd else null, reporters, enable_ansi_colors);
|
||||
}
|
||||
|
||||
pub fn printCodeCoverage(
|
||||
@@ -999,6 +1012,7 @@ pub const CommandLineReporter = struct {
|
||||
vm: *jsc.VirtualMachine,
|
||||
opts: *TestCommand.CodeCoverageOptions,
|
||||
byte_ranges: []bun.SourceMap.coverage.ByteRangeMapping,
|
||||
git_diff: ?*bun.SourceMap.GitDiff.GitDiffResult,
|
||||
comptime reporters: TestCommand.Reporters,
|
||||
comptime enable_ansi_colors: bool,
|
||||
) !void {
|
||||
@@ -1018,6 +1032,7 @@ pub const CommandLineReporter = struct {
|
||||
}
|
||||
|
||||
const relative_dir = vm.transpiler.fs.top_level_dir;
|
||||
const has_git_diff = git_diff != null and git_diff.?.files.count() > 0;
|
||||
|
||||
// --- Text ---
|
||||
const max_filepath_length: usize = if (reporters.text) brk: {
|
||||
@@ -1047,21 +1062,51 @@ pub const CommandLineReporter = struct {
|
||||
break :brk len;
|
||||
} else 0;
|
||||
|
||||
var console = Output.errorWriter();
|
||||
const console = Output.errorWriter();
|
||||
const base_fraction = opts.fractions;
|
||||
var failing = false;
|
||||
|
||||
// Track changed lines coverage
|
||||
var total_changed_executable: u32 = 0;
|
||||
var total_covered_changed: u32 = 0;
|
||||
var changes_failing = false;
|
||||
|
||||
// For AI agent prompts - collect uncovered files info
|
||||
const AIPromptFile = struct {
|
||||
path: []const u8,
|
||||
uncovered_ranges: []const coverage.ChangesResult.LineRange,
|
||||
uncovered_functions: []const coverage.ChangesResult.UncoveredFunction,
|
||||
};
|
||||
var ai_prompt_files: std.ArrayListUnmanaged(AIPromptFile) = .empty;
|
||||
defer ai_prompt_files.deinit(bun.default_allocator);
|
||||
|
||||
// Skip table output for AI agents when using --coverage-changes (only show <errors> prompt)
|
||||
const skip_table_for_ai = Output.isAIAgent() and has_git_diff;
|
||||
|
||||
if (comptime reporters.text) {
|
||||
console.writeAll(Output.prettyFmt("<r><d>", enable_ansi_colors)) catch return;
|
||||
console.splatByteAll('-', max_filepath_length + 2) catch return;
|
||||
console.writeAll(Output.prettyFmt("|---------|---------|-------------------<r>\n", enable_ansi_colors)) catch return;
|
||||
console.writeAll("File") catch return;
|
||||
console.splatByteAll(' ', max_filepath_length - "File".len + 1) catch return;
|
||||
// writer.writeAll(Output.prettyFmt(" <d>|<r> % Funcs <d>|<r> % Blocks <d>|<r> % Lines <d>|<r> Uncovered Line #s\n", enable_ansi_colors)) catch return;
|
||||
console.writeAll(Output.prettyFmt(" <d>|<r> % Funcs <d>|<r> % Lines <d>|<r> Uncovered Line #s\n", enable_ansi_colors)) catch return;
|
||||
console.writeAll(Output.prettyFmt("<d>", enable_ansi_colors)) catch return;
|
||||
console.splatByteAll('-', max_filepath_length + 2) catch return;
|
||||
console.writeAll(Output.prettyFmt("|---------|---------|-------------------<r>\n", enable_ansi_colors)) catch return;
|
||||
if (!skip_table_for_ai) {
|
||||
console.writeAll(Output.prettyFmt("<r><d>", enable_ansi_colors)) catch return;
|
||||
console.splatByteAll('-', max_filepath_length + 2) catch return;
|
||||
if (has_git_diff) {
|
||||
console.writeAll(Output.prettyFmt("|---------|---------|---------|-------------------<r>\n", enable_ansi_colors)) catch return;
|
||||
} else {
|
||||
console.writeAll(Output.prettyFmt("|---------|---------|-------------------<r>\n", enable_ansi_colors)) catch return;
|
||||
}
|
||||
console.writeAll("File") catch return;
|
||||
console.splatByteAll(' ', max_filepath_length - "File".len + 1) catch return;
|
||||
if (has_git_diff) {
|
||||
console.writeAll(Output.prettyFmt(" <d>|<r> % Funcs <d>|<r> % Lines <d>|<r> % Chang <d>|<r> Uncovered Line #s\n", enable_ansi_colors)) catch return;
|
||||
} else {
|
||||
console.writeAll(Output.prettyFmt(" <d>|<r> % Funcs <d>|<r> % Lines <d>|<r> Uncovered Line #s\n", enable_ansi_colors)) catch return;
|
||||
}
|
||||
console.writeAll(Output.prettyFmt("<d>", enable_ansi_colors)) catch return;
|
||||
console.splatByteAll('-', max_filepath_length + 2) catch return;
|
||||
if (has_git_diff) {
|
||||
console.writeAll(Output.prettyFmt("|---------|---------|---------|-------------------<r>\n", enable_ansi_colors)) catch return;
|
||||
} else {
|
||||
console.writeAll(Output.prettyFmt("|---------|---------|-------------------<r>\n", enable_ansi_colors)) catch return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var console_buffer = std.Io.Writer.Allocating.init(bun.default_allocator);
|
||||
@@ -1138,11 +1183,11 @@ pub const CommandLineReporter = struct {
|
||||
// --- LCOV ---
|
||||
|
||||
for (byte_ranges) |*entry| {
|
||||
const utf8 = entry.source_url.slice();
|
||||
const relative_path = bun.path.relative(relative_dir, utf8);
|
||||
|
||||
// Check if this file should be ignored based on coveragePathIgnorePatterns
|
||||
if (opts.ignore_patterns.len > 0) {
|
||||
const utf8 = entry.source_url.slice();
|
||||
const relative_path = bun.path.relative(relative_dir, utf8);
|
||||
|
||||
var should_ignore = false;
|
||||
for (opts.ignore_patterns) |pattern| {
|
||||
if (bun.glob.match(pattern, relative_path).matches()) {
|
||||
@@ -1159,9 +1204,156 @@ 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);
|
||||
|
||||
// Compute changed lines coverage if we have git diff
|
||||
var changed_coverage: ?f64 = null;
|
||||
var uncovered_changed_ranges: ?std.ArrayListUnmanaged(coverage.ChangesResult.LineRange) = null;
|
||||
defer if (uncovered_changed_ranges) |*ranges| ranges.deinit(bun.default_allocator);
|
||||
var uncovered_changed_functions: ?std.ArrayListUnmanaged(coverage.ChangesResult.UncoveredFunction) = null;
|
||||
defer if (uncovered_changed_functions) |*funcs| funcs.deinit(bun.default_allocator);
|
||||
|
||||
if (git_diff) |gd| {
|
||||
// Skip test files for changed lines tracking
|
||||
var is_test_file = false;
|
||||
if (opts.skip_test_files) {
|
||||
const ext = std.fs.path.extension(relative_path);
|
||||
const name_without_extension = relative_path[0 .. relative_path.len - ext.len];
|
||||
inline for (Scanner.test_name_suffixes) |suffix| {
|
||||
if (bun.strings.endsWithComptime(name_without_extension, suffix)) {
|
||||
is_test_file = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_test_file) {
|
||||
if (gd.files.getPtr(relative_path)) |changed_file| {
|
||||
const changes_stats = try coverage.ChangesReport.computeForReport(&report, changed_file, bun.default_allocator);
|
||||
uncovered_changed_ranges = changes_stats.uncovered_ranges;
|
||||
uncovered_changed_functions = changes_stats.uncovered_functions;
|
||||
|
||||
if (changes_stats.total_changed_executable > 0) {
|
||||
total_changed_executable += changes_stats.total_changed_executable;
|
||||
total_covered_changed += changes_stats.covered_changed;
|
||||
changed_coverage = @as(f64, @floatFromInt(changes_stats.covered_changed)) / @as(f64, @floatFromInt(changes_stats.total_changed_executable));
|
||||
|
||||
if (changed_coverage.? < base_fraction.lines) {
|
||||
changes_failing = true;
|
||||
// Collect for AI prompts (only if there are uncovered ranges or functions)
|
||||
const has_uncovered_ranges = uncovered_changed_ranges.?.items.len > 0;
|
||||
const has_uncovered_funcs = uncovered_changed_functions.?.items.len > 0;
|
||||
if (has_uncovered_ranges or has_uncovered_funcs) {
|
||||
try ai_prompt_files.append(bun.default_allocator, .{
|
||||
.path = try bun.default_allocator.dupe(u8, relative_path),
|
||||
.uncovered_ranges = try bun.default_allocator.dupe(coverage.ChangesResult.LineRange, uncovered_changed_ranges.?.items),
|
||||
.uncovered_functions = try bun.default_allocator.dupe(coverage.ChangesResult.UncoveredFunction, uncovered_changed_functions.?.items),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (comptime reporters.text) {
|
||||
var fraction = base_fraction;
|
||||
CodeCoverageReport.Text.writeFormat(&report, max_filepath_length, &fraction, relative_dir, console_writer, enable_ansi_colors) catch continue;
|
||||
|
||||
// If we have git diff, use writeFormatWithValues (without uncovered lines)
|
||||
// so we can insert % Chang column before uncovered lines
|
||||
if (has_git_diff) {
|
||||
const fns = report.functionCoverageFraction();
|
||||
const lines = report.linesCoverageFraction();
|
||||
const stmts = report.stmtsCoverageFraction();
|
||||
fraction.functions = fns;
|
||||
fraction.lines = lines;
|
||||
fraction.stmts = stmts;
|
||||
|
||||
const failed = fns < base_fraction.functions or lines < base_fraction.lines;
|
||||
fraction.failing = failed;
|
||||
|
||||
if (!skip_table_for_ai) {
|
||||
CodeCoverageReport.Text.writeFormatWithValues(
|
||||
relative_path,
|
||||
max_filepath_length,
|
||||
fraction,
|
||||
base_fraction,
|
||||
failed,
|
||||
console_writer,
|
||||
true,
|
||||
enable_ansi_colors,
|
||||
) catch continue;
|
||||
|
||||
// Add % Changed column
|
||||
if (changed_coverage) |cc| {
|
||||
try console_writer.writeAll(Output.prettyFmt("<r><d> | <r>", enable_ansi_colors));
|
||||
if (cc < base_fraction.lines) {
|
||||
try console_writer.writeAll(Output.prettyFmt("<b><red>", enable_ansi_colors));
|
||||
} else {
|
||||
try console_writer.writeAll(Output.prettyFmt("<b><green>", enable_ansi_colors));
|
||||
}
|
||||
try console_writer.print("{d: >7.2}", .{cc * 100.0});
|
||||
try console_writer.writeAll(Output.prettyFmt("<r>", enable_ansi_colors));
|
||||
} else {
|
||||
try console_writer.writeAll(Output.prettyFmt("<r><d> | -<r>", enable_ansi_colors));
|
||||
}
|
||||
|
||||
// Now print uncovered lines column (manually, same as writeFormat does)
|
||||
try console_writer.writeAll(Output.prettyFmt("<r><d> | <r>", enable_ansi_colors));
|
||||
|
||||
var executable_lines_that_havent_been_executed = bun.handleOom(report.lines_which_have_executed.clone(bun.default_allocator));
|
||||
defer executable_lines_that_havent_been_executed.deinit(bun.default_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 console_writer.print(Output.prettyFmt("<r><d>,<r>", enable_ansi_colors), .{});
|
||||
}
|
||||
|
||||
if (start_of_line_range == prev_line) {
|
||||
try console_writer.print(Output.prettyFmt("<red>{d}", enable_ansi_colors), .{start_of_line_range + 1});
|
||||
} else {
|
||||
try console_writer.print(Output.prettyFmt("<red>{d}-{d}", enable_ansi_colors), .{ 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) {
|
||||
if (is_first) {
|
||||
is_first = false;
|
||||
} else {
|
||||
try console_writer.print(Output.prettyFmt("<r><d>,<r>", enable_ansi_colors), .{});
|
||||
}
|
||||
|
||||
if (start_of_line_range == prev_line) {
|
||||
try console_writer.print(Output.prettyFmt("<red>{d}", enable_ansi_colors), .{start_of_line_range + 1});
|
||||
} else {
|
||||
try console_writer.print(Output.prettyFmt("<red>{d}-{d}", enable_ansi_colors), .{ start_of_line_range + 1, prev_line + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!skip_table_for_ai) {
|
||||
// No git diff - use the standard writeFormat
|
||||
CodeCoverageReport.Text.writeFormat(&report, max_filepath_length, &fraction, relative_dir, console_writer, enable_ansi_colors) catch continue;
|
||||
}
|
||||
|
||||
avg.functions += fraction.functions;
|
||||
avg.lines += fraction.lines;
|
||||
avg.stmts += fraction.stmts;
|
||||
@@ -1170,7 +1362,9 @@ pub const CommandLineReporter = struct {
|
||||
failing = true;
|
||||
}
|
||||
|
||||
console_writer.writeAll("\n") catch continue;
|
||||
if (!skip_table_for_ai) {
|
||||
console_writer.writeAll("\n") catch continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (comptime reporters.lcov) {
|
||||
@@ -1183,7 +1377,7 @@ pub const CommandLineReporter = struct {
|
||||
}
|
||||
|
||||
if (comptime reporters.text) {
|
||||
{
|
||||
if (!skip_table_for_ai) {
|
||||
if (avg_count == 0) {
|
||||
avg.functions = 0;
|
||||
avg.lines = 0;
|
||||
@@ -1211,16 +1405,90 @@ pub const CommandLineReporter = struct {
|
||||
enable_ansi_colors,
|
||||
);
|
||||
|
||||
try console.writeAll(Output.prettyFmt("<r><d> |<r>\n", enable_ansi_colors));
|
||||
// Calculate total changed coverage percentage once for reuse
|
||||
const total_changed_pct: f64 = if (total_changed_executable > 0)
|
||||
@as(f64, @floatFromInt(total_covered_changed)) / @as(f64, @floatFromInt(total_changed_executable))
|
||||
else
|
||||
1.0;
|
||||
|
||||
// Add total changed coverage
|
||||
if (has_git_diff) {
|
||||
try console.writeAll(Output.prettyFmt("<r><d> | <r>", enable_ansi_colors));
|
||||
if (total_changed_pct < base_fraction.lines and total_changed_executable > 0) {
|
||||
try console.writeAll(Output.prettyFmt("<b><red>", enable_ansi_colors));
|
||||
} else {
|
||||
try console.writeAll(Output.prettyFmt("<b><green>", enable_ansi_colors));
|
||||
}
|
||||
try console.print("{d: >7.2}", .{total_changed_pct * 100.0});
|
||||
try console.writeAll(Output.prettyFmt("<r><d> |<r>\n", enable_ansi_colors));
|
||||
} else {
|
||||
try console.writeAll(Output.prettyFmt("<r><d> |<r>\n", enable_ansi_colors));
|
||||
}
|
||||
|
||||
console_writer.flush() catch return;
|
||||
try console.writeAll(console_buffer.written());
|
||||
try console.writeAll(Output.prettyFmt("<r><d>", enable_ansi_colors));
|
||||
console.splatByteAll('-', max_filepath_length + 2) catch return;
|
||||
if (has_git_diff) {
|
||||
console.writeAll(Output.prettyFmt("|---------|---------|---------|-------------------<r>\n", enable_ansi_colors)) catch return;
|
||||
} else {
|
||||
console.writeAll(Output.prettyFmt("|---------|---------|-------------------<r>\n", enable_ansi_colors)) catch return;
|
||||
}
|
||||
|
||||
// Print failure message for changed lines coverage
|
||||
if (has_git_diff and changes_failing) {
|
||||
try console.print(Output.prettyFmt("\n<red><b>Coverage for changed lines ({d:.2}%) is below threshold ({d:.2}%)<r>\n", enable_ansi_colors), .{ total_changed_pct * 100.0, base_fraction.lines * 100.0 });
|
||||
}
|
||||
}
|
||||
|
||||
console_writer.flush() catch return;
|
||||
try console.writeAll(console_buffer.written());
|
||||
try console.writeAll(Output.prettyFmt("<r><d>", enable_ansi_colors));
|
||||
console.splatByteAll('-', max_filepath_length + 2) catch return;
|
||||
console.writeAll(Output.prettyFmt("|---------|---------|-------------------<r>\n", enable_ansi_colors)) catch return;
|
||||
// Output AI agent prompts if applicable
|
||||
if (Output.isAIAgent() and ai_prompt_files.items.len > 0) {
|
||||
try console.writeAll("\n<errors>\n");
|
||||
for (ai_prompt_files.items) |file_info| {
|
||||
// Escape file path for XML to handle special characters like &, <, >, "
|
||||
var escaped_path_buf = std.array_list.Managed(u8).init(bun.default_allocator);
|
||||
defer escaped_path_buf.deinit();
|
||||
try escapeXml(file_info.path, escaped_path_buf.writer());
|
||||
const escaped_path = escaped_path_buf.items;
|
||||
|
||||
// Output uncovered line ranges as <file> tags
|
||||
if (file_info.uncovered_ranges.len > 0) {
|
||||
try console.print(" <file path=\"{s}\">\n", .{escaped_path});
|
||||
try console.print(" In {s}, lines ", .{escaped_path});
|
||||
for (file_info.uncovered_ranges, 0..) |range, i| {
|
||||
if (i > 0) try console.writeAll(", ");
|
||||
if (range.start == range.end) {
|
||||
try console.print("{d}", .{range.start});
|
||||
} else {
|
||||
try console.print("{d}-{d}", .{ range.start, range.end });
|
||||
}
|
||||
}
|
||||
try console.writeAll(" do not have test coverage. Write tests to cover these lines.\n");
|
||||
try console.writeAll(" </file>\n");
|
||||
}
|
||||
|
||||
// Output uncovered functions as <function> tags
|
||||
// func.start_line and func.end_line are already Ordinals, use oneBased() for display
|
||||
for (file_info.uncovered_functions) |func| {
|
||||
const start = func.start_line.oneBased();
|
||||
const end = func.end_line.oneBased();
|
||||
try console.print(" <function path=\"{s}\" startLine=\"{d}\" endLine=\"{d}\">\n", .{ escaped_path, start, end });
|
||||
try console.print(" In {s}, the function at lines {d}-{d} is never called. Write a test that calls this function, or delete it if it is dead code.\n", .{ escaped_path, start, end });
|
||||
try console.writeAll(" </function>\n");
|
||||
}
|
||||
}
|
||||
try console.writeAll("</errors>\n");
|
||||
|
||||
// Free the allocated memory for AI prompt files
|
||||
for (ai_prompt_files.items) |file_info| {
|
||||
bun.default_allocator.free(file_info.path);
|
||||
bun.default_allocator.free(file_info.uncovered_ranges);
|
||||
bun.default_allocator.free(file_info.uncovered_functions);
|
||||
}
|
||||
}
|
||||
|
||||
opts.fractions.failing = failing;
|
||||
opts.changes_failing = changes_failing;
|
||||
Output.flush();
|
||||
}
|
||||
|
||||
@@ -1294,6 +1562,8 @@ pub const TestCommand = struct {
|
||||
enabled: bool = false,
|
||||
fail_on_low_coverage: bool = false,
|
||||
ignore_patterns: []const string = &.{},
|
||||
changes_base_branch: ?string = null,
|
||||
changes_failing: bool = false,
|
||||
};
|
||||
pub const Reporter = enum {
|
||||
text,
|
||||
@@ -1782,7 +2052,8 @@ pub const TestCommand = struct {
|
||||
const summary = reporter.summary();
|
||||
|
||||
const should_fail_on_no_tests = !ctx.test_options.pass_with_no_tests and (failed_to_find_any_tests or summary.didLabelFilterOutAllTests());
|
||||
if (should_fail_on_no_tests or summary.fail > 0 or (coverage_options.enabled and coverage_options.fractions.failing and coverage_options.fail_on_low_coverage) or !write_snapshots_success) {
|
||||
const coverage_failed = coverage_options.enabled and coverage_options.fail_on_low_coverage and (coverage_options.fractions.failing or coverage_options.changes_failing);
|
||||
if (should_fail_on_no_tests or summary.fail > 0 or coverage_failed or !write_snapshots_success) {
|
||||
vm.exit_handler.exit_code = 1;
|
||||
} else if (reporter.jest.unhandled_errors_between_tests > 0) {
|
||||
vm.exit_handler.exit_code = 1;
|
||||
|
||||
@@ -721,6 +721,267 @@ pub const Block = struct {
|
||||
end_line: u32 = 0,
|
||||
};
|
||||
|
||||
/// Result from computing coverage changes report
|
||||
pub const ChangesResult = struct {
|
||||
/// Total changed lines that are executable
|
||||
total_changed_executable_lines: u32 = 0,
|
||||
/// Changed lines that were executed/covered
|
||||
covered_changed_lines: u32 = 0,
|
||||
/// List of uncovered changed line ranges per file
|
||||
uncovered_files: std.ArrayListUnmanaged(UncoveredFile) = .{},
|
||||
|
||||
pub const UncoveredFile = struct {
|
||||
filename: []const u8,
|
||||
uncovered_ranges: std.ArrayListUnmanaged(LineRange),
|
||||
uncovered_functions: std.ArrayListUnmanaged(UncoveredFunction),
|
||||
};
|
||||
|
||||
pub const LineRange = struct {
|
||||
start: u32,
|
||||
end: u32,
|
||||
};
|
||||
|
||||
/// An uncovered function (entire function body is in changed lines and not executed)
|
||||
/// Line numbers are stored as 1-indexed Ordinals for unambiguous display
|
||||
pub const UncoveredFunction = struct {
|
||||
start_line: bun.Ordinal,
|
||||
end_line: bun.Ordinal,
|
||||
};
|
||||
|
||||
pub fn coverageFraction(self: *const ChangesResult) f64 {
|
||||
if (self.total_changed_executable_lines == 0) return 1.0;
|
||||
return @as(f64, @floatFromInt(self.covered_changed_lines)) / @as(f64, @floatFromInt(self.total_changed_executable_lines));
|
||||
}
|
||||
|
||||
pub fn deinit(self: *ChangesResult, allocator: std.mem.Allocator) void {
|
||||
for (self.uncovered_files.items) |*file| {
|
||||
file.uncovered_ranges.deinit(allocator);
|
||||
file.uncovered_functions.deinit(allocator);
|
||||
}
|
||||
self.uncovered_files.deinit(allocator);
|
||||
}
|
||||
};
|
||||
|
||||
pub const ChangesReport = struct {
|
||||
/// Compute coverage for changed lines only
|
||||
/// Returns statistics about coverage for lines that were changed in the diff
|
||||
pub fn computeForReport(
|
||||
report: *const Report,
|
||||
changed_file: *const GitDiff.ChangedFile,
|
||||
allocator: std.mem.Allocator,
|
||||
) !struct {
|
||||
total_changed_executable: u32,
|
||||
covered_changed: u32,
|
||||
uncovered_ranges: std.ArrayListUnmanaged(ChangesResult.LineRange),
|
||||
uncovered_functions: std.ArrayListUnmanaged(ChangesResult.UncoveredFunction),
|
||||
} {
|
||||
var total_changed_executable: u32 = 0;
|
||||
var covered_changed: u32 = 0;
|
||||
var uncovered_ranges = std.ArrayListUnmanaged(ChangesResult.LineRange){};
|
||||
errdefer uncovered_ranges.deinit(allocator);
|
||||
var uncovered_functions = std.ArrayListUnmanaged(ChangesResult.UncoveredFunction){};
|
||||
errdefer uncovered_functions.deinit(allocator);
|
||||
|
||||
// Iterate through executable lines and check if they're changed
|
||||
var iter = report.executable_lines.iterator(.{});
|
||||
var range_start: ?u32 = null;
|
||||
var range_end: u32 = 0;
|
||||
|
||||
while (iter.next()) |line_idx| {
|
||||
const line: u32 = @intCast(line_idx + 1); // Convert to 1-indexed
|
||||
|
||||
// Check if this line is in the changed set
|
||||
if (!changed_file.isLineChanged(line)) continue;
|
||||
|
||||
total_changed_executable += 1;
|
||||
|
||||
const is_covered = report.lines_which_have_executed.isSet(line_idx);
|
||||
|
||||
if (is_covered) {
|
||||
covered_changed += 1;
|
||||
// Close any open uncovered range
|
||||
if (range_start) |start| {
|
||||
try uncovered_ranges.append(allocator, .{ .start = start, .end = range_end });
|
||||
range_start = null;
|
||||
}
|
||||
} else {
|
||||
// Uncovered line - start or extend range only if consecutive
|
||||
if (range_start == null) {
|
||||
// Start new range
|
||||
range_start = line;
|
||||
range_end = line;
|
||||
} else if (line == range_end + 1) {
|
||||
// Extend consecutive range
|
||||
range_end = line;
|
||||
} else {
|
||||
// Gap detected - close current range and start new one
|
||||
try uncovered_ranges.append(allocator, .{ .start = range_start.?, .end = range_end });
|
||||
range_start = line;
|
||||
range_end = line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close final uncovered range
|
||||
if (range_start) |start| {
|
||||
try uncovered_ranges.append(allocator, .{ .start = start, .end = range_end });
|
||||
}
|
||||
|
||||
// Find uncovered functions that are entirely within changed lines
|
||||
// Note: func.start_line and func.end_line from coverage report are 0-indexed
|
||||
// isLineChanged expects 1-indexed line numbers
|
||||
for (report.functions.items, 0..) |func, func_idx| {
|
||||
// Check if this function was NOT executed
|
||||
if (report.functions_which_have_executed.isSet(func_idx)) continue;
|
||||
|
||||
// Convert from 0-indexed to 1-indexed using Ordinal for clarity
|
||||
const start_ordinal = bun.Ordinal.fromZeroBased(@intCast(func.start_line));
|
||||
const end_ordinal = bun.Ordinal.fromZeroBased(@intCast(func.end_line));
|
||||
|
||||
// Check if the function's lines are all in changed lines
|
||||
var all_lines_changed = true;
|
||||
var line: u32 = @intCast(start_ordinal.oneBased());
|
||||
while (line <= end_ordinal.oneBased()) : (line += 1) {
|
||||
if (!changed_file.isLineChanged(line)) {
|
||||
all_lines_changed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (all_lines_changed and start_ordinal.isValid() and end_ordinal.isValid()) {
|
||||
try uncovered_functions.append(allocator, .{
|
||||
// Store as Ordinals (already converted from 0-indexed)
|
||||
.start_line = start_ordinal,
|
||||
.end_line = end_ordinal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return .{
|
||||
.total_changed_executable = total_changed_executable,
|
||||
.covered_changed = covered_changed,
|
||||
.uncovered_ranges = uncovered_ranges,
|
||||
.uncovered_functions = uncovered_functions,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn writeHeader(
|
||||
writer: *std.Io.Writer,
|
||||
max_filename_length: usize,
|
||||
comptime enable_colors: bool,
|
||||
) !void {
|
||||
try writer.writeAll(prettyFmt("<r>\n<b>Coverage for Changed Lines:<r>\n", enable_colors));
|
||||
try writer.writeAll(prettyFmt("<d>", enable_colors));
|
||||
try writer.splatByteAll('-', max_filename_length + 2);
|
||||
try writer.writeAll(prettyFmt("|---------|-------------------<r>\n", enable_colors));
|
||||
try writer.writeAll("File");
|
||||
try writer.splatByteAll(' ', max_filename_length - "File".len + 1);
|
||||
try writer.writeAll(prettyFmt(" <d>|<r> % Lines <d>|<r> Uncovered Line #s\n", enable_colors));
|
||||
try writer.writeAll(prettyFmt("<d>", enable_colors));
|
||||
try writer.splatByteAll('-', max_filename_length + 2);
|
||||
try writer.writeAll(prettyFmt("|---------|-------------------<r>\n", enable_colors));
|
||||
}
|
||||
|
||||
pub fn writeFileRow(
|
||||
filename: []const u8,
|
||||
max_filename_length: usize,
|
||||
coverage_pct: f64,
|
||||
threshold: f64,
|
||||
uncovered_ranges: []const ChangesResult.LineRange,
|
||||
writer: *std.Io.Writer,
|
||||
comptime enable_colors: bool,
|
||||
) !void {
|
||||
const failed = coverage_pct < threshold;
|
||||
|
||||
if (comptime enable_colors) {
|
||||
if (failed) {
|
||||
try writer.writeAll(prettyFmt("<r><b><red>", true));
|
||||
} else {
|
||||
try writer.writeAll(prettyFmt("<r><b><green>", true));
|
||||
}
|
||||
}
|
||||
|
||||
try writer.writeAll(" ");
|
||||
try writer.writeAll(filename);
|
||||
try writer.splatByteAll(' ', max_filename_length - filename.len);
|
||||
try writer.writeAll(prettyFmt("<r><d> | <r>", enable_colors));
|
||||
|
||||
if (comptime enable_colors) {
|
||||
if (failed) {
|
||||
try writer.writeAll(prettyFmt("<b><red>", true));
|
||||
} else {
|
||||
try writer.writeAll(prettyFmt("<b><green>", true));
|
||||
}
|
||||
}
|
||||
|
||||
try writer.print("{d: >7.2}", .{coverage_pct * 100.0});
|
||||
try writer.writeAll(prettyFmt("<r><d> | <r>", enable_colors));
|
||||
|
||||
// Write uncovered line ranges
|
||||
var is_first = true;
|
||||
for (uncovered_ranges) |range| {
|
||||
if (!is_first) {
|
||||
try writer.writeAll(prettyFmt("<r><d>,<r>", enable_colors));
|
||||
}
|
||||
is_first = false;
|
||||
|
||||
if (range.start == range.end) {
|
||||
try writer.print(prettyFmt("<red>{d}", enable_colors), .{range.start});
|
||||
} else {
|
||||
try writer.print(prettyFmt("<red>{d}-{d}", enable_colors), .{ range.start, range.end });
|
||||
}
|
||||
}
|
||||
|
||||
try writer.writeAll("\n");
|
||||
}
|
||||
|
||||
pub fn writeSummary(
|
||||
writer: *std.Io.Writer,
|
||||
max_filename_length: usize,
|
||||
total_pct: f64,
|
||||
threshold: f64,
|
||||
comptime enable_colors: bool,
|
||||
) !void {
|
||||
const failed = total_pct < threshold;
|
||||
|
||||
try writer.writeAll(prettyFmt("<d>", enable_colors));
|
||||
try writer.splatByteAll('-', max_filename_length + 2);
|
||||
try writer.writeAll(prettyFmt("|---------|-------------------<r>\n", enable_colors));
|
||||
|
||||
if (comptime enable_colors) {
|
||||
if (failed) {
|
||||
try writer.writeAll(prettyFmt("<r><b><red>", true));
|
||||
} else {
|
||||
try writer.writeAll(prettyFmt("<r><b><green>", true));
|
||||
}
|
||||
}
|
||||
|
||||
try writer.writeAll("All changed");
|
||||
try writer.splatByteAll(' ', max_filename_length - "All changed".len + 1);
|
||||
try writer.writeAll(prettyFmt("<r><d> | <r>", enable_colors));
|
||||
|
||||
if (comptime enable_colors) {
|
||||
if (failed) {
|
||||
try writer.writeAll(prettyFmt("<b><red>", true));
|
||||
} else {
|
||||
try writer.writeAll(prettyFmt("<b><green>", true));
|
||||
}
|
||||
}
|
||||
|
||||
try writer.print("{d: >7.2}", .{total_pct * 100.0});
|
||||
try writer.writeAll(prettyFmt("<r><d> |<r>\n", enable_colors));
|
||||
|
||||
try writer.writeAll(prettyFmt("<d>", enable_colors));
|
||||
try writer.splatByteAll('-', max_filename_length + 2);
|
||||
try writer.writeAll(prettyFmt("|---------|-------------------<r>\n", enable_colors));
|
||||
|
||||
if (failed) {
|
||||
try writer.print(prettyFmt("\n<red><b>Coverage for changed lines ({d:.2}%) is below threshold ({d:.2}%)<r>\n", enable_colors), .{ total_pct * 100.0, threshold * 100.0 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const GitDiff = @import("./GitDiff.zig");
|
||||
const std = @import("std");
|
||||
|
||||
const bun = @import("bun");
|
||||
|
||||
377
src/sourcemap/GitDiff.zig
Normal file
377
src/sourcemap/GitDiff.zig
Normal file
@@ -0,0 +1,377 @@
|
||||
/// Represents a range of changed lines
|
||||
pub const LineRange = struct {
|
||||
start: u32,
|
||||
count: u32,
|
||||
};
|
||||
|
||||
/// Represents changed line ranges in a single file
|
||||
pub const ChangedFile = struct {
|
||||
path: []const u8,
|
||||
/// Bit set where each bit represents whether that line was changed (added/modified)
|
||||
/// Line numbers are 1-indexed in the bitset (bit 0 is unused, bit 1 = line 1)
|
||||
changed_lines: Bitset,
|
||||
total_lines: u32,
|
||||
|
||||
/// Check if a line number (1-indexed) was changed
|
||||
pub fn isLineChanged(self: *const ChangedFile, line: u32) bool {
|
||||
if (line == 0 or line > self.total_lines) return false;
|
||||
return self.changed_lines.isSet(line);
|
||||
}
|
||||
};
|
||||
|
||||
/// Map of file paths to their changed lines
|
||||
pub const ChangedFilesMap = std.StringHashMapUnmanaged(ChangedFile);
|
||||
|
||||
/// Result of parsing git diff output
|
||||
pub const GitDiffResult = struct {
|
||||
files: ChangedFilesMap,
|
||||
allocator: std.mem.Allocator,
|
||||
error_message: ?[]const u8 = null,
|
||||
|
||||
pub fn deinit(self: *GitDiffResult) void {
|
||||
// Iterate over both keys and values to properly free all memory
|
||||
var iter = self.files.iterator();
|
||||
while (iter.next()) |entry| {
|
||||
// Free the bitset in ChangedFile
|
||||
entry.value_ptr.changed_lines.deinit(self.allocator);
|
||||
// Free the path string (key and value.path point to same allocation)
|
||||
self.allocator.free(entry.key_ptr.*);
|
||||
}
|
||||
self.files.deinit(self.allocator);
|
||||
if (self.error_message) |msg| {
|
||||
self.allocator.free(msg);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getChangedFile(self: *const GitDiffResult, path: []const u8) ?*const ChangedFile {
|
||||
return self.files.getPtr(path);
|
||||
}
|
||||
};
|
||||
|
||||
/// Parses git diff output to extract changed files and line numbers
|
||||
/// The diff should be in unified format (git diff -U0)
|
||||
pub fn parseGitDiff(allocator: std.mem.Allocator, diff_output: []const u8) !GitDiffResult {
|
||||
var result = GitDiffResult{
|
||||
.files = ChangedFilesMap{},
|
||||
.allocator = allocator,
|
||||
};
|
||||
errdefer result.deinit();
|
||||
|
||||
var lines_iter = std.mem.splitScalar(u8, diff_output, '\n');
|
||||
var current_file: ?[]const u8 = null;
|
||||
var changed_lines_list = std.array_list.Managed(LineRange).init(allocator);
|
||||
defer changed_lines_list.deinit();
|
||||
|
||||
while (lines_iter.next()) |line| {
|
||||
// Look for diff headers: +++ b/path/to/file
|
||||
if (std.mem.startsWith(u8, line, "+++ b/")) {
|
||||
// Save previous file's changes if any
|
||||
if (current_file) |file_path| {
|
||||
try saveChangedFile(&result.files, allocator, file_path, changed_lines_list.items);
|
||||
changed_lines_list.clearRetainingCapacity();
|
||||
}
|
||||
current_file = line[6..];
|
||||
}
|
||||
// Also handle +++ /dev/null for deleted files (skip them)
|
||||
else if (std.mem.startsWith(u8, line, "+++ /dev/null")) {
|
||||
current_file = null;
|
||||
changed_lines_list.clearRetainingCapacity();
|
||||
}
|
||||
// Look for hunk headers: @@ -old_start,old_count +new_start,new_count @@
|
||||
else if (std.mem.startsWith(u8, line, "@@") and current_file != null) {
|
||||
// Parse the +new_start,new_count part
|
||||
if (parseHunkHeader(line)) |hunk| {
|
||||
if (hunk.new_count > 0) {
|
||||
try changed_lines_list.append(.{
|
||||
.start = hunk.new_start,
|
||||
.count = hunk.new_count,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save last file's changes
|
||||
if (current_file) |file_path| {
|
||||
try saveChangedFile(&result.files, allocator, file_path, changed_lines_list.items);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const HunkInfo = struct {
|
||||
new_start: u32,
|
||||
new_count: u32,
|
||||
};
|
||||
|
||||
fn parseHunkHeader(line: []const u8) ?HunkInfo {
|
||||
// Format: @@ -old_start,old_count +new_start,new_count @@
|
||||
// or: @@ -old_start +new_start,new_count @@
|
||||
// or: @@ -old_start,old_count +new_start @@
|
||||
// or: @@ -old_start +new_start @@
|
||||
|
||||
// Find the + part
|
||||
const plus_idx = std.mem.indexOf(u8, line, " +") orelse return null;
|
||||
const after_plus = line[plus_idx + 2 ..];
|
||||
|
||||
// Find the end of the new section (next space or @@)
|
||||
var end_idx: usize = 0;
|
||||
for (after_plus, 0..) |c, i| {
|
||||
if (c == ' ' or c == '@') {
|
||||
end_idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (end_idx == 0) end_idx = after_plus.len;
|
||||
|
||||
const new_section = after_plus[0..end_idx];
|
||||
|
||||
// Parse new_start,new_count
|
||||
if (std.mem.indexOf(u8, new_section, ",")) |comma_idx| {
|
||||
const start_str = new_section[0..comma_idx];
|
||||
const count_str = new_section[comma_idx + 1 ..];
|
||||
|
||||
const new_start = std.fmt.parseInt(u32, start_str, 10) catch return null;
|
||||
const new_count = std.fmt.parseInt(u32, count_str, 10) catch return null;
|
||||
|
||||
return HunkInfo{
|
||||
.new_start = new_start,
|
||||
.new_count = new_count,
|
||||
};
|
||||
} else {
|
||||
// No comma means count is 1
|
||||
const new_start = std.fmt.parseInt(u32, new_section, 10) catch return null;
|
||||
return HunkInfo{
|
||||
.new_start = new_start,
|
||||
.new_count = 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn saveChangedFile(
|
||||
files: *ChangedFilesMap,
|
||||
allocator: std.mem.Allocator,
|
||||
file_path: []const u8,
|
||||
ranges: []const LineRange,
|
||||
) !void {
|
||||
if (ranges.len == 0) return;
|
||||
|
||||
// Find max line number to size the bitset
|
||||
var max_line: u32 = 0;
|
||||
for (ranges) |range| {
|
||||
const end_line = range.start + range.count - 1;
|
||||
if (end_line > max_line) max_line = end_line;
|
||||
}
|
||||
|
||||
// Create bitset for changed lines (1-indexed, so need max_line + 1 bits)
|
||||
var changed_lines = try Bitset.initEmpty(allocator, max_line + 1);
|
||||
errdefer changed_lines.deinit(allocator);
|
||||
|
||||
// Set bits for all changed lines
|
||||
for (ranges) |range| {
|
||||
var line = range.start;
|
||||
const end_line = range.start + range.count;
|
||||
while (line < end_line) : (line += 1) {
|
||||
changed_lines.set(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicate the path since it points into the diff buffer
|
||||
const owned_path = try allocator.dupe(u8, file_path);
|
||||
errdefer allocator.free(owned_path);
|
||||
|
||||
try files.put(allocator, owned_path, ChangedFile{
|
||||
.path = owned_path,
|
||||
.changed_lines = changed_lines,
|
||||
.total_lines = max_line,
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs git diff and returns the changed files map
|
||||
/// base_branch: the branch to diff against (e.g., "main", "origin/main", or "HEAD" for uncommitted changes)
|
||||
/// cwd: working directory for git command (null for current directory)
|
||||
pub fn getChangedFiles(allocator: std.mem.Allocator, base_branch: []const u8, cwd: ?[]const u8) !GitDiffResult {
|
||||
var path_buf: bun.PathBuffer = undefined;
|
||||
const git_path = bun.which(&path_buf, bun.env_var.PATH.get() orelse "", cwd orelse "", "git") orelse {
|
||||
return GitDiffResult{
|
||||
.files = ChangedFilesMap{},
|
||||
.allocator = allocator,
|
||||
.error_message = try allocator.dupe(u8, "git is not installed or not in PATH"),
|
||||
};
|
||||
};
|
||||
|
||||
// When base_branch is "HEAD", show uncommitted changes (staged + unstaged)
|
||||
// Otherwise, use the ... syntax to show changes since branches diverged
|
||||
const is_head = std.mem.eql(u8, base_branch, "HEAD");
|
||||
const diff_ref = if (is_head)
|
||||
try allocator.dupe(u8, "HEAD")
|
||||
else
|
||||
try std.fmt.allocPrint(allocator, "{s}...HEAD", .{base_branch});
|
||||
defer allocator.free(diff_ref);
|
||||
|
||||
const proc = bun.spawnSync(&.{
|
||||
.argv = &.{ git_path, "diff", diff_ref, "-U0", "--no-color" },
|
||||
.stdout = .buffer,
|
||||
.stderr = .buffer,
|
||||
.stdin = .ignore,
|
||||
.cwd = cwd orelse "",
|
||||
.envp = null,
|
||||
.windows = if (Environment.isWindows) .{
|
||||
.loop = bun.jsc.EventLoopHandle.init(bun.jsc.MiniEventLoop.initGlobal(null, null)),
|
||||
} else {},
|
||||
}) catch |err| {
|
||||
return GitDiffResult{
|
||||
.files = ChangedFilesMap{},
|
||||
.allocator = allocator,
|
||||
.error_message = try std.fmt.allocPrint(allocator, "Failed to run git diff: {s}", .{@errorName(err)}),
|
||||
};
|
||||
};
|
||||
|
||||
switch (proc) {
|
||||
.err => |err| {
|
||||
return GitDiffResult{
|
||||
.files = ChangedFilesMap{},
|
||||
.allocator = allocator,
|
||||
.error_message = try std.fmt.allocPrint(allocator, "Failed to spawn git process: {any}", .{err}),
|
||||
};
|
||||
},
|
||||
.result => |result| {
|
||||
defer result.deinit();
|
||||
|
||||
if (!result.isOK()) {
|
||||
// Try the simpler diff if the three-dot syntax fails
|
||||
// (happens when base_branch doesn't exist or is the same as current)
|
||||
const proc2 = bun.spawnSync(&.{
|
||||
.argv = &.{ git_path, "diff", base_branch, "-U0", "--no-color" },
|
||||
.stdout = .buffer,
|
||||
.stderr = .buffer,
|
||||
.stdin = .ignore,
|
||||
.cwd = cwd orelse "",
|
||||
.envp = null,
|
||||
.windows = if (Environment.isWindows) .{
|
||||
.loop = bun.jsc.EventLoopHandle.init(bun.jsc.MiniEventLoop.initGlobal(null, null)),
|
||||
} else {},
|
||||
}) catch |err| {
|
||||
return GitDiffResult{
|
||||
.files = ChangedFilesMap{},
|
||||
.allocator = allocator,
|
||||
.error_message = try std.fmt.allocPrint(allocator, "Failed to run git diff: {s}", .{@errorName(err)}),
|
||||
};
|
||||
};
|
||||
|
||||
switch (proc2) {
|
||||
.err => |err2| {
|
||||
return GitDiffResult{
|
||||
.files = ChangedFilesMap{},
|
||||
.allocator = allocator,
|
||||
.error_message = try std.fmt.allocPrint(allocator, "Failed to spawn git process: {any}", .{err2}),
|
||||
};
|
||||
},
|
||||
.result => |result2| {
|
||||
defer result2.deinit();
|
||||
|
||||
if (!result2.isOK()) {
|
||||
return GitDiffResult{
|
||||
.files = ChangedFilesMap{},
|
||||
.allocator = allocator,
|
||||
.error_message = try std.fmt.allocPrint(allocator, "git diff failed: {s}", .{result2.stderr.items}),
|
||||
};
|
||||
}
|
||||
|
||||
return parseGitDiff(allocator, result2.stdout.items);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return parseGitDiff(allocator, result.stdout.items);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test "parseHunkHeader" {
|
||||
// Standard format with counts
|
||||
{
|
||||
const res = parseHunkHeader("@@ -10,5 +20,3 @@ function foo()");
|
||||
try std.testing.expect(res != null);
|
||||
try std.testing.expectEqual(@as(u32, 20), res.?.new_start);
|
||||
try std.testing.expectEqual(@as(u32, 3), res.?.new_count);
|
||||
}
|
||||
|
||||
// No old count
|
||||
{
|
||||
const res = parseHunkHeader("@@ -10 +20,3 @@");
|
||||
try std.testing.expect(res != null);
|
||||
try std.testing.expectEqual(@as(u32, 20), res.?.new_start);
|
||||
try std.testing.expectEqual(@as(u32, 3), res.?.new_count);
|
||||
}
|
||||
|
||||
// No new count (single line change)
|
||||
{
|
||||
const res = parseHunkHeader("@@ -10,5 +20 @@");
|
||||
try std.testing.expect(res != null);
|
||||
try std.testing.expectEqual(@as(u32, 20), res.?.new_start);
|
||||
try std.testing.expectEqual(@as(u32, 1), res.?.new_count);
|
||||
}
|
||||
|
||||
// Both single line
|
||||
{
|
||||
const res = parseHunkHeader("@@ -10 +20 @@");
|
||||
try std.testing.expect(res != null);
|
||||
try std.testing.expectEqual(@as(u32, 20), res.?.new_start);
|
||||
try std.testing.expectEqual(@as(u32, 1), res.?.new_count);
|
||||
}
|
||||
}
|
||||
|
||||
test "parseGitDiff" {
|
||||
const diff =
|
||||
\\diff --git a/src/foo.ts b/src/foo.ts
|
||||
\\index 1234567..abcdefg 100644
|
||||
\\--- a/src/foo.ts
|
||||
\\+++ b/src/foo.ts
|
||||
\\@@ -10,3 +10,5 @@ function existing() {
|
||||
\\+ // new line
|
||||
\\+ console.log("hello");
|
||||
\\@@ -20,0 +22,2 @@
|
||||
\\+function newFunction() {
|
||||
\\+}
|
||||
\\diff --git a/src/bar.ts b/src/bar.ts
|
||||
\\new file mode 100644
|
||||
\\index 0000000..1234567
|
||||
\\--- /dev/null
|
||||
\\+++ b/src/bar.ts
|
||||
\\@@ -0,0 +1,10 @@
|
||||
\\+// new file content
|
||||
;
|
||||
|
||||
var res = try parseGitDiff(std.testing.allocator, diff);
|
||||
defer res.deinit();
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), res.files.count());
|
||||
|
||||
// Check foo.ts changes
|
||||
const foo = res.files.get("src/foo.ts");
|
||||
try std.testing.expect(foo != null);
|
||||
try std.testing.expect(foo.?.isLineChanged(10));
|
||||
try std.testing.expect(foo.?.isLineChanged(11));
|
||||
try std.testing.expect(foo.?.isLineChanged(12));
|
||||
try std.testing.expect(foo.?.isLineChanged(13));
|
||||
try std.testing.expect(foo.?.isLineChanged(14));
|
||||
try std.testing.expect(foo.?.isLineChanged(22));
|
||||
try std.testing.expect(foo.?.isLineChanged(23));
|
||||
try std.testing.expect(!foo.?.isLineChanged(9));
|
||||
try std.testing.expect(!foo.?.isLineChanged(15));
|
||||
|
||||
// Check bar.ts - new file with lines 1-10
|
||||
const bar = res.files.get("src/bar.ts");
|
||||
try std.testing.expect(bar != null);
|
||||
try std.testing.expect(bar.?.isLineChanged(1));
|
||||
try std.testing.expect(bar.?.isLineChanged(10));
|
||||
try std.testing.expect(!bar.?.isLineChanged(0));
|
||||
try std.testing.expect(!bar.?.isLineChanged(11));
|
||||
}
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const bun = @import("bun");
|
||||
const Environment = bun.Environment;
|
||||
const Bitset = bun.bit_set.DynamicBitSetUnmanaged;
|
||||
@@ -956,6 +956,7 @@ pub const coverage = @import("./CodeCoverage.zig");
|
||||
pub const VLQ = @import("./VLQ.zig");
|
||||
pub const LineOffsetTable = @import("./LineOffsetTable.zig");
|
||||
pub const JSSourceMap = @import("./JSSourceMap.zig");
|
||||
pub const GitDiff = @import("./GitDiff.zig");
|
||||
|
||||
const decodeVLQAssumeValid = VLQ.decodeAssumeValid;
|
||||
const decodeVLQ = VLQ.decode;
|
||||
|
||||
498
test/cli/test/coverage-changes.test.ts
Normal file
498
test/cli/test/coverage-changes.test.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import { spawnSync } from "bun";
|
||||
import { expect, test } from "bun:test";
|
||||
import { bunEnv, bunExe, normalizeBunSnapshot, tempDirWithFiles } from "harness";
|
||||
|
||||
// Helper to create a git repo, commit, make changes, and run coverage
|
||||
function setupGitRepo(files: Record<string, string>) {
|
||||
const dir = tempDirWithFiles("cov-changes", files);
|
||||
|
||||
// Initialize git repo
|
||||
let result = spawnSync(["git", "init"], {
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
if (result.exitCode !== 0) throw new Error("git init failed");
|
||||
|
||||
// Configure git user
|
||||
result = spawnSync(["git", "config", "user.email", "test@test.com"], {
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
if (result.exitCode !== 0) throw new Error("git config email failed");
|
||||
|
||||
result = spawnSync(["git", "config", "user.name", "Test User"], {
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
if (result.exitCode !== 0) throw new Error("git config name failed");
|
||||
|
||||
// Explicitly set branch name to "main" for consistent behavior across environments
|
||||
result = spawnSync(["git", "branch", "-M", "main"], {
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
if (result.exitCode !== 0) throw new Error("git branch -M main failed");
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
function gitAddCommit(dir: string, message: string) {
|
||||
let result = spawnSync(["git", "add", "-A"], {
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
if (result.exitCode !== 0) throw new Error("git add failed");
|
||||
|
||||
result = spawnSync(["git", "commit", "-m", message], {
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
if (result.exitCode !== 0) throw new Error("git commit failed");
|
||||
}
|
||||
|
||||
function gitCheckoutBranch(dir: string, branchName: string) {
|
||||
const result = spawnSync(["git", "checkout", "-b", branchName], {
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
if (result.exitCode !== 0) throw new Error("git checkout -b failed");
|
||||
}
|
||||
|
||||
test("--coverage-changes reports coverage for changed lines only", async () => {
|
||||
// Create initial files
|
||||
const dir = await setupGitRepo({
|
||||
"lib.ts": `
|
||||
export function existingFunction() {
|
||||
return "existing";
|
||||
}
|
||||
|
||||
export function anotherExisting() {
|
||||
return "another";
|
||||
}
|
||||
`,
|
||||
"test.test.ts": `
|
||||
import { test, expect } from "bun:test";
|
||||
import { existingFunction, anotherExisting } from "./lib";
|
||||
|
||||
test("should call existing functions", () => {
|
||||
expect(existingFunction()).toBe("existing");
|
||||
expect(anotherExisting()).toBe("another");
|
||||
});
|
||||
`,
|
||||
});
|
||||
|
||||
// Initial commit on main
|
||||
await gitAddCommit(dir, "Initial commit");
|
||||
|
||||
// Create feature branch
|
||||
await gitCheckoutBranch(dir, "feature");
|
||||
|
||||
// Add new function that won't be tested
|
||||
await Bun.write(
|
||||
`${dir}/lib.ts`,
|
||||
`
|
||||
export function existingFunction() {
|
||||
return "existing";
|
||||
}
|
||||
|
||||
export function anotherExisting() {
|
||||
return "another";
|
||||
}
|
||||
|
||||
export function newUncoveredFunction() {
|
||||
// This function is new but not tested
|
||||
return "uncovered";
|
||||
}
|
||||
|
||||
export function newCoveredFunction() {
|
||||
return "covered";
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
// Update test to call one of the new functions
|
||||
await Bun.write(
|
||||
`${dir}/test.test.ts`,
|
||||
`
|
||||
import { test, expect } from "bun:test";
|
||||
import { existingFunction, anotherExisting, newCoveredFunction } from "./lib";
|
||||
|
||||
test("should call existing and new covered functions", () => {
|
||||
expect(existingFunction()).toBe("existing");
|
||||
expect(anotherExisting()).toBe("another");
|
||||
expect(newCoveredFunction()).toBe("covered");
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
// Commit changes
|
||||
await gitAddCommit(dir, "Add new functions");
|
||||
|
||||
// Run coverage with --coverage-changes (using = syntax for optional param)
|
||||
const result = spawnSync([bunExe(), "test", "--coverage", "--coverage-changes=main"], {
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
stdio: [null, null, "pipe"],
|
||||
});
|
||||
|
||||
const stderr = normalizeBunSnapshot(result.stderr.toString("utf-8"), dir);
|
||||
|
||||
// Should show % Chang column in coverage table
|
||||
expect(stderr).toContain("% Chang");
|
||||
expect(stderr).toContain("lib.ts");
|
||||
// Should fail due to uncovered changed lines
|
||||
expect(stderr).toContain("Coverage for changed lines");
|
||||
expect(stderr).toContain("is below threshold");
|
||||
expect(result.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test("--coverage-changes passes when all changed lines are covered", async () => {
|
||||
// Create initial files
|
||||
const dir = await setupGitRepo({
|
||||
"lib.ts": `
|
||||
export function existingFunction() {
|
||||
return "existing";
|
||||
}
|
||||
`,
|
||||
"test.test.ts": `
|
||||
import { test, expect } from "bun:test";
|
||||
import { existingFunction } from "./lib";
|
||||
|
||||
test("should call existing function", () => {
|
||||
expect(existingFunction()).toBe("existing");
|
||||
});
|
||||
`,
|
||||
});
|
||||
|
||||
// Initial commit on main
|
||||
await gitAddCommit(dir, "Initial commit");
|
||||
|
||||
// Create feature branch
|
||||
await gitCheckoutBranch(dir, "feature");
|
||||
|
||||
// Add new function that WILL be tested
|
||||
await Bun.write(
|
||||
`${dir}/lib.ts`,
|
||||
`
|
||||
export function existingFunction() {
|
||||
return "existing";
|
||||
}
|
||||
|
||||
export function newFunction() {
|
||||
return "new";
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
// Update test to call the new function
|
||||
await Bun.write(
|
||||
`${dir}/test.test.ts`,
|
||||
`
|
||||
import { test, expect } from "bun:test";
|
||||
import { existingFunction, newFunction } from "./lib";
|
||||
|
||||
test("should call both functions", () => {
|
||||
expect(existingFunction()).toBe("existing");
|
||||
expect(newFunction()).toBe("new");
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
// Commit changes
|
||||
await gitAddCommit(dir, "Add new function with test");
|
||||
|
||||
// Run coverage with --coverage-changes (using = syntax for optional param)
|
||||
const result = spawnSync([bunExe(), "test", "--coverage", "--coverage-changes=main"], {
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
stdio: [null, null, "pipe"],
|
||||
});
|
||||
|
||||
const stderr = normalizeBunSnapshot(result.stderr.toString("utf-8"), dir);
|
||||
|
||||
// Should show % Chang column with 100% coverage
|
||||
expect(stderr).toContain("% Chang");
|
||||
expect(stderr).toContain("100.00");
|
||||
// Should NOT have the "below threshold" warning
|
||||
expect(stderr).not.toContain("is below threshold");
|
||||
expect(result.exitCode).toBe(0); // Should pass
|
||||
});
|
||||
|
||||
test("--coverage-changes defaults to HEAD (uncommitted changes)", async () => {
|
||||
// Create initial files
|
||||
const dir = await setupGitRepo({
|
||||
"lib.ts": `
|
||||
export function foo() {
|
||||
return "foo";
|
||||
}
|
||||
`,
|
||||
"test.test.ts": `
|
||||
import { test, expect } from "bun:test";
|
||||
import { foo } from "./lib";
|
||||
|
||||
test("should call foo", () => {
|
||||
expect(foo()).toBe("foo");
|
||||
});
|
||||
`,
|
||||
});
|
||||
|
||||
// Initial commit on main
|
||||
await gitAddCommit(dir, "Initial commit");
|
||||
|
||||
// Add new covered function WITHOUT committing (uncommitted changes)
|
||||
await Bun.write(
|
||||
`${dir}/lib.ts`,
|
||||
`
|
||||
export function foo() {
|
||||
return "foo";
|
||||
}
|
||||
|
||||
export function bar() {
|
||||
return "bar";
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
await Bun.write(
|
||||
`${dir}/test.test.ts`,
|
||||
`
|
||||
import { test, expect } from "bun:test";
|
||||
import { foo, bar } from "./lib";
|
||||
|
||||
test("should call both", () => {
|
||||
expect(foo()).toBe("foo");
|
||||
expect(bar()).toBe("bar");
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
// Run coverage with --coverage-changes without specifying branch (defaults to HEAD for uncommitted changes)
|
||||
const result = spawnSync([bunExe(), "test", "--coverage", "--coverage-changes"], {
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
stdio: [null, null, "pipe"],
|
||||
});
|
||||
|
||||
const stderr = normalizeBunSnapshot(result.stderr.toString("utf-8"), dir);
|
||||
|
||||
// Should show % Chang column (defaults to HEAD, showing uncommitted changes)
|
||||
expect(stderr).toContain("% Chang");
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("--coverage-changes shows no changes when branch is same as base", async () => {
|
||||
const dir = await setupGitRepo({
|
||||
"lib.ts": `
|
||||
export function foo() {
|
||||
return "foo";
|
||||
}
|
||||
`,
|
||||
"test.test.ts": `
|
||||
import { test, expect } from "bun:test";
|
||||
import { foo } from "./lib";
|
||||
|
||||
test("should call foo", () => {
|
||||
expect(foo()).toBe("foo");
|
||||
});
|
||||
`,
|
||||
});
|
||||
|
||||
await gitAddCommit(dir, "Initial commit");
|
||||
|
||||
// Run on main against main (no changes)
|
||||
const result = spawnSync([bunExe(), "test", "--coverage", "--coverage-changes=main"], {
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
stdio: [null, null, "pipe"],
|
||||
});
|
||||
|
||||
const stderr = normalizeBunSnapshot(result.stderr.toString("utf-8"), dir);
|
||||
|
||||
// When on main comparing to main (no changes), the % Chang column should not appear
|
||||
// because git diff returns empty
|
||||
expect(stderr).not.toContain("% Chang");
|
||||
// But regular coverage table should still be shown
|
||||
expect(stderr).toContain("% Funcs");
|
||||
expect(stderr).toContain("% Lines");
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("--coverage-changes with inline snapshot", async () => {
|
||||
// Create a simple scenario where we can verify exact output
|
||||
const dir = await setupGitRepo({
|
||||
"math.ts": `
|
||||
export function add(a: number, b: number) {
|
||||
return a + b;
|
||||
}
|
||||
`,
|
||||
"math.test.ts": `
|
||||
import { test, expect } from "bun:test";
|
||||
import { add } from "./math";
|
||||
|
||||
test("add works", () => {
|
||||
expect(add(1, 2)).toBe(3);
|
||||
});
|
||||
`,
|
||||
});
|
||||
|
||||
await gitAddCommit(dir, "Initial commit");
|
||||
await gitCheckoutBranch(dir, "feature");
|
||||
|
||||
// Add multiply function without test
|
||||
await Bun.write(
|
||||
`${dir}/math.ts`,
|
||||
`
|
||||
export function add(a: number, b: number) {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
export function multiply(a: number, b: number) {
|
||||
return a * b;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
await gitAddCommit(dir, "Add multiply");
|
||||
|
||||
const result = spawnSync([bunExe(), "test", "--coverage", "--coverage-changes=main"], {
|
||||
cwd: dir,
|
||||
env: {
|
||||
...bunEnv,
|
||||
NO_COLOR: "1",
|
||||
},
|
||||
stdio: [null, null, "pipe"],
|
||||
});
|
||||
|
||||
let stderr = result.stderr.toString("utf-8");
|
||||
// Remove timing and version info for snapshot stability
|
||||
stderr = normalizeBunSnapshot(stderr, dir);
|
||||
|
||||
// The output should show merged table with % Chang column
|
||||
expect(stderr).toContain("% Chang");
|
||||
expect(stderr).toContain("math.ts");
|
||||
// Should show "below threshold" warning
|
||||
expect(stderr).toContain("Coverage for changed lines");
|
||||
expect(stderr).toContain("is below threshold");
|
||||
// Exit code should be 1 because multiply is not covered
|
||||
expect(result.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test("--coverage-changes shows AI agent prompts when AGENT=1", async () => {
|
||||
const dir = await setupGitRepo({
|
||||
"lib.ts": `
|
||||
export function add(a: number, b: number) {
|
||||
return a + b;
|
||||
}
|
||||
`,
|
||||
"lib.test.ts": `
|
||||
import { test, expect } from "bun:test";
|
||||
import { add } from "./lib";
|
||||
|
||||
test("add works", () => {
|
||||
expect(add(1, 2)).toBe(3);
|
||||
});
|
||||
`,
|
||||
});
|
||||
|
||||
await gitAddCommit(dir, "Initial commit");
|
||||
await gitCheckoutBranch(dir, "feature");
|
||||
|
||||
// Add uncovered function
|
||||
await Bun.write(
|
||||
`${dir}/lib.ts`,
|
||||
`
|
||||
export function add(a: number, b: number) {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
export function uncovered() {
|
||||
return "not tested";
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
await gitAddCommit(dir, "Add uncovered function");
|
||||
|
||||
// Run with AGENT=1 to trigger AI prompts
|
||||
const result = spawnSync([bunExe(), "test", "--coverage", "--coverage-changes=main"], {
|
||||
cwd: dir,
|
||||
env: {
|
||||
...bunEnv,
|
||||
NO_COLOR: "1",
|
||||
AGENT: "1",
|
||||
},
|
||||
stdio: [null, null, "pipe"],
|
||||
});
|
||||
|
||||
const stderr = normalizeBunSnapshot(result.stderr.toString("utf-8"), dir);
|
||||
|
||||
// Should show AI agent XML prompts with <errors> tag
|
||||
expect(stderr).toContain("<errors>");
|
||||
expect(stderr).toContain("</errors>");
|
||||
expect(stderr).toContain("<file path=");
|
||||
expect(stderr).toContain("do not have test coverage");
|
||||
expect(result.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test("--coverage-changes shows <function> tags for entirely uncovered new functions", async () => {
|
||||
const dir = await setupGitRepo({
|
||||
"lib.ts": `
|
||||
export function existingFunc() {
|
||||
return "existing";
|
||||
}
|
||||
`,
|
||||
"lib.test.ts": `
|
||||
import { test, expect } from "bun:test";
|
||||
import { existingFunc } from "./lib";
|
||||
|
||||
test("existing works", () => {
|
||||
expect(existingFunc()).toBe("existing");
|
||||
});
|
||||
`,
|
||||
});
|
||||
|
||||
await gitAddCommit(dir, "Initial commit");
|
||||
await gitCheckoutBranch(dir, "feature");
|
||||
|
||||
// Add a completely new function that is never called
|
||||
await Bun.write(
|
||||
`${dir}/lib.ts`,
|
||||
`
|
||||
export function existingFunc() {
|
||||
return "existing";
|
||||
}
|
||||
|
||||
export function newUncalledFunc() {
|
||||
return "never called";
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
await gitAddCommit(dir, "Add uncalled function");
|
||||
|
||||
const result = spawnSync([bunExe(), "test", "--coverage", "--coverage-changes=main"], {
|
||||
cwd: dir,
|
||||
env: {
|
||||
...bunEnv,
|
||||
NO_COLOR: "1",
|
||||
AGENT: "1",
|
||||
},
|
||||
stdio: [null, null, "pipe"],
|
||||
});
|
||||
|
||||
const stderr = normalizeBunSnapshot(result.stderr.toString("utf-8"), dir);
|
||||
|
||||
// Should show <function> tag for the uncalled function
|
||||
expect(stderr).toContain("<errors>");
|
||||
expect(stderr).toContain("<function path=");
|
||||
expect(stderr).toContain("is never called");
|
||||
expect(stderr).toContain("</function>");
|
||||
expect(result.exitCode).toBe(1);
|
||||
});
|
||||
Reference in New Issue
Block a user