Compare commits

...

6 Commits

Author SHA1 Message Date
Claude Bot
cf43feebd7 refactor: deduplicate total_changed_pct calculation
Compute total_changed_pct once before it's used in both the summary
table display and the failure message, eliminating duplicate logic.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 08:51:06 +00:00
Claude Bot
7387522215 fix: escape XML special characters in file paths for AI prompts
File paths in <file> and <function> XML tags are now escaped to handle
special characters like &, <, >, and " that could produce malformed XML.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 08:39:02 +00:00
Claude Bot
6434022e77 feat: default --coverage-changes to HEAD for uncommitted changes
- Change default from 'main' to 'HEAD' so users can test coverage
  of uncommitted changes without committing first
- When base_branch is "HEAD", run `git diff HEAD` instead of
  `git diff HEAD...HEAD` to show staged + unstaged changes
- Update tests to verify uncommitted changes detection

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 08:22:53 +00:00
Claude Bot
3c33410666 fix: address PR review comments and improve coverage output
- Add end_ordinal.isValid() check when detecting uncovered functions
- Remove async keyword from synchronous helper functions in tests
- Fix line range collapsing to show separate ranges (e.g., "5, 10, 15" not "5-15")
- Skip coverage table output when CLAUDECODE=1 with --coverage-changes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 08:15:48 +00:00
autofix-ci[bot]
993d44ccf4 [autofix.ci] apply automated fixes 2025-12-17 07:35:37 +00:00
Claude Bot
d329278996 feat(test): add --coverage-changes flag for PR coverage reporting
Adds a new `--coverage-changes` CLI flag for `bun test` that reports code coverage
specifically for lines that changed compared to a base branch (default: main).

Features:
- Merged coverage table with "% Chang" column showing coverage for changed lines only
- AI agent detection (AGENT=1 or CLAUDECODE env) outputs XML prompts for uncovered code:
  - `<file>` tags for uncovered line ranges
  - `<function>` tags for entirely uncovered new functions with correct line numbers
- Exits with code 1 if changed lines coverage is below threshold (default 90%)
- Skips test files from changed lines tracking when coverageSkipTestFiles is enabled
- Uses bun.Ordinal for unambiguous line number handling (0-indexed internal, 1-indexed display)

Usage:
  bun test --coverage --coverage-changes        # Compare against main
  bun test --coverage --coverage-changes=develop  # Compare against develop

Example output with AI agent prompts (AGENT=1):
```
File       | % Funcs | % Lines | % Chang | Uncovered Line #s
lib.ts     |   50.00 |   66.67 |    0.00 |

Coverage for changed lines (0.00%) is below threshold (90.00%)

<errors>
  <file path="lib.ts">
    In lib.ts, lines 5 do not have test coverage. Write tests to cover these lines.
  </file>
  <function path="lib.ts" startLine="5" endLine="6">
    In lib.ts, the function at lines 5-6 is never called. Write a test that calls this function, or delete it if it is dead code.
  </function>
</errors>
```

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 07:33:47 +00:00
6 changed files with 1440 additions and 25 deletions

View File

@@ -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| {

View File

@@ -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;

View File

@@ -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
View 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;

View File

@@ -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;

View 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);
});