Files
bun.sh/src/bun.js/test/jest.zig
RiskyMH 38a7238588 Add test line filtering feature
Implements the ability to run specific tests by line number using
file.ts:lineNumber syntax. This allows targeting individual tests
or describe blocks within a test file.

Features:
- Parse file:line and file:line:column syntax (column is ignored)
- Filter tests based on line numbers
- Support filtering describe blocks by line number
- Line numbers of parent describe blocks are considered for matching
2025-09-20 19:41:25 +10:00

569 lines
24 KiB
Zig

pub const TestLineFilter = struct {
path: []const u8,
line: u32,
pub fn hash(self: TestLineFilter) u32 {
var hash_value: u32 = 0;
hash_value = @as(u32, @truncate(bun.hash(self.path)));
hash_value ^= @as(u32, self.line) *% 31;
return hash_value;
}
pub fn eql(a: TestLineFilter, b: TestLineFilter) bool {
return a.line == b.line and bun.strings.eql(a.path, b.path);
}
pub const HashContext = struct {
pub fn hash(_: HashContext, key: TestLineFilter) u32 {
return key.hash();
}
pub fn eql(_: HashContext, a: TestLineFilter, b: TestLineFilter) bool {
return a.eql(b);
}
};
pub const Map = std.HashMapUnmanaged(TestLineFilter, void, HashContext, 80);
};
const CurrentFile = struct {
title: string = "",
prefix: string = "",
repeat_info: struct {
count: u32 = 0,
index: u32 = 0,
} = .{},
has_printed_filename: bool = false,
pub fn set(this: *CurrentFile, title: string, prefix: string, repeat_count: u32, repeat_index: u32) void {
if (Output.isAIAgent()) {
this.freeAndClear();
this.title = bun.handleOom(bun.default_allocator.dupe(u8, title));
this.prefix = bun.handleOom(bun.default_allocator.dupe(u8, prefix));
this.repeat_info.count = repeat_count;
this.repeat_info.index = repeat_index;
this.has_printed_filename = false;
return;
}
this.has_printed_filename = true;
print(title, prefix, repeat_count, repeat_index);
}
fn freeAndClear(this: *CurrentFile) void {
bun.default_allocator.free(this.title);
bun.default_allocator.free(this.prefix);
}
fn print(title: string, prefix: string, repeat_count: u32, repeat_index: u32) void {
if (repeat_count > 0) {
if (repeat_count > 1) {
Output.prettyErrorln("<r>\n{s}{s}: <d>(run #{d})<r>\n", .{ prefix, title, repeat_index + 1 });
} else {
Output.prettyErrorln("<r>\n{s}{s}:\n", .{ prefix, title });
}
} else {
Output.prettyErrorln("<r>\n{s}{s}:\n", .{ prefix, title });
}
Output.flush();
}
pub fn printIfNeeded(this: *CurrentFile) void {
if (this.has_printed_filename) return;
this.has_printed_filename = true;
print(this.title, this.prefix, this.repeat_info.count, this.repeat_info.index);
}
};
pub const TestRunner = struct {
current_file: CurrentFile = CurrentFile{},
files: File.List = .{},
index: File.Map = File.Map{},
only: bool = false,
run_todo: bool = false,
concurrent: bool = false,
last_file: u64 = 0,
bail: u32 = 0,
allocator: std.mem.Allocator,
drainer: jsc.AnyTask = undefined,
has_pending_tests: bool = false,
snapshots: Snapshots,
default_timeout_ms: u32,
// from `setDefaultTimeout() or jest.setTimeout()`. maxInt(u32) means override not set.
default_timeout_override: u32 = std.math.maxInt(u32),
test_options: *const bun.cli.Command.TestOptions = undefined,
// Used for --test-name-pattern to reduce allocations
filter_regex: ?*RegularExpression,
// Used for file:line test filtering
line_filters: TestLineFilter.Map = .{},
unhandled_errors_between_tests: u32 = 0,
summary: Summary = Summary{},
bun_test_root: bun_test.BunTestRoot,
pub const Summary = struct {
pass: u32 = 0,
expectations: u32 = 0,
skip: u32 = 0,
todo: u32 = 0,
fail: u32 = 0,
files: u32 = 0,
skipped_because_label: u32 = 0,
pub fn didLabelFilterOutAllTests(this: *const Summary) bool {
return this.skipped_because_label > 0 and (this.pass + this.skip + this.todo + this.fail + this.expectations) == 0;
}
};
pub fn hasTestFilter(this: *const TestRunner) bool {
return this.filter_regex != null;
}
pub fn hasTestLineFilter(this: *const TestRunner) bool {
return this.line_filters.count() > 0;
}
pub fn addLineFilter(this: *TestRunner, path: []const u8, line: u32) !void {
const filter = TestLineFilter{
.path = try this.allocator.dupe(u8, path),
.line = line,
};
try this.line_filters.put(this.allocator, filter, {});
}
pub fn shouldSkipBasedOnLineFilter(this: *const TestRunner, file_path: []const u8, line_number: u32) bool {
if (!this.hasTestLineFilter()) return false;
// Check if this specific test matches any filter
const test_filter = TestLineFilter{
.path = file_path,
.line = line_number,
};
if (this.line_filters.contains(test_filter)) {
return false; // Don't skip, this test is selected
}
// Check if any filter matches this file
var iter = this.line_filters.iterator();
while (iter.next()) |entry| {
if (bun.strings.eql(entry.key_ptr.path, file_path)) {
return true; // Skip, there's a filter for this file but not this line
}
}
return false; // Don't skip, no filters for this file
}
pub fn matchesLineFilter(this: *const TestRunner, file_path: []const u8, test_line: u32, parent_lines: []const u32) bool {
if (!this.hasTestLineFilter()) return true;
// Check if test line matches
const test_filter = TestLineFilter{
.path = file_path,
.line = test_line,
};
if (this.line_filters.contains(test_filter)) {
return true;
}
// Check if any parent describe block matches
for (parent_lines) |parent_line| {
const parent_filter = TestLineFilter{
.path = file_path,
.line = parent_line,
};
if (this.line_filters.contains(parent_filter)) {
return true;
}
}
// Check if there are any filters for this file
var has_file_filter = false;
var iter = this.line_filters.iterator();
while (iter.next()) |entry| {
if (bun.strings.eql(entry.key_ptr.path, file_path)) {
has_file_filter = true;
break;
}
}
// If there are no filters for this file, run the test
// If there are filters for this file but none match, skip the test
return !has_file_filter;
}
pub fn getOrPutFile(this: *TestRunner, file_path: string) struct { file_id: File.ID } {
const entry = this.index.getOrPut(this.allocator, @as(u32, @truncate(bun.hash(file_path)))) catch unreachable; // TODO: this is wrong. you can't put a hash as the key in a hashmap.
if (entry.found_existing) {
return .{ .file_id = entry.value_ptr.* };
}
const file_id = @as(File.ID, @truncate(this.files.len));
this.files.append(this.allocator, .{ .source = logger.Source.initEmptyFile(file_path) }) catch unreachable;
entry.value_ptr.* = file_id;
return .{ .file_id = file_id };
}
pub const File = struct {
source: logger.Source = logger.Source.initEmptyFile(""),
log: logger.Log = logger.Log.initComptime(default_allocator),
pub const List = std.MultiArrayList(File);
pub const ID = u32;
pub const Map = std.ArrayHashMapUnmanaged(u32, u32, ArrayIdentityContext, false);
};
};
pub const Jest = struct {
pub var runner: ?*TestRunner = null;
pub fn Bun__Jest__createTestModuleObject(globalObject: *JSGlobalObject) callconv(.C) JSValue {
return createTestModule(globalObject) catch return .zero;
}
pub fn createTestModule(globalObject: *JSGlobalObject) bun.JSError!JSValue {
const module = JSValue.createEmptyObject(globalObject, 19);
const test_scope_functions = try bun_test.ScopeFunctions.createBound(globalObject, .@"test", .zero, .{}, bun_test.ScopeFunctions.strings.@"test");
module.put(globalObject, ZigString.static("test"), test_scope_functions);
module.put(globalObject, ZigString.static("it"), test_scope_functions);
const xtest_scope_functions = try bun_test.ScopeFunctions.createBound(globalObject, .@"test", .zero, .{ .self_mode = .skip }, bun_test.ScopeFunctions.strings.xtest);
module.put(globalObject, ZigString.static("xtest"), xtest_scope_functions);
module.put(globalObject, ZigString.static("xit"), xtest_scope_functions);
const describe_scope_functions = try bun_test.ScopeFunctions.createBound(globalObject, .describe, .zero, .{}, bun_test.ScopeFunctions.strings.describe);
module.put(globalObject, ZigString.static("describe"), describe_scope_functions);
const xdescribe_scope_functions = bun_test.ScopeFunctions.createBound(globalObject, .describe, .zero, .{ .self_mode = .skip }, bun_test.ScopeFunctions.strings.xdescribe) catch return .zero;
module.put(globalObject, ZigString.static("xdescribe"), xdescribe_scope_functions);
module.put(globalObject, ZigString.static("beforeEach"), jsc.host_fn.NewFunction(globalObject, ZigString.static("beforeEach"), 1, bun_test.js_fns.genericHook(.beforeEach).hookFn, false));
module.put(globalObject, ZigString.static("beforeAll"), jsc.host_fn.NewFunction(globalObject, ZigString.static("beforeAll"), 1, bun_test.js_fns.genericHook(.beforeAll).hookFn, false));
module.put(globalObject, ZigString.static("afterAll"), jsc.host_fn.NewFunction(globalObject, ZigString.static("afterAll"), 1, bun_test.js_fns.genericHook(.afterAll).hookFn, false));
module.put(globalObject, ZigString.static("afterEach"), jsc.host_fn.NewFunction(globalObject, ZigString.static("afterEach"), 1, bun_test.js_fns.genericHook(.afterEach).hookFn, false));
module.put(globalObject, ZigString.static("setDefaultTimeout"), jsc.host_fn.NewFunction(globalObject, ZigString.static("setDefaultTimeout"), 1, jsSetDefaultTimeout, false));
module.put(globalObject, ZigString.static("expect"), Expect.js.getConstructor(globalObject));
module.put(globalObject, ZigString.static("expectTypeOf"), ExpectTypeOf.js.getConstructor(globalObject));
createMockObjects(globalObject, module);
return module;
}
fn createMockObjects(globalObject: *JSGlobalObject, module: JSValue) void {
const setSystemTime = jsc.host_fn.NewFunction(globalObject, ZigString.static("setSystemTime"), 0, JSMock__jsSetSystemTime, false);
module.put(
globalObject,
ZigString.static("setSystemTime"),
setSystemTime,
);
const useFakeTimers = jsc.host_fn.NewFunction(globalObject, ZigString.static("useFakeTimers"), 0, JSMock__jsUseFakeTimers, false);
const useRealTimers = jsc.host_fn.NewFunction(globalObject, ZigString.static("useRealTimers"), 0, JSMock__jsUseRealTimers, false);
const mockFn = jsc.host_fn.NewFunction(globalObject, ZigString.static("fn"), 1, JSMock__jsMockFn, false);
const spyOn = jsc.host_fn.NewFunction(globalObject, ZigString.static("spyOn"), 2, JSMock__jsSpyOn, false);
const restoreAllMocks = jsc.host_fn.NewFunction(globalObject, ZigString.static("restoreAllMocks"), 2, JSMock__jsRestoreAllMocks, false);
const clearAllMocks = jsc.host_fn.NewFunction(globalObject, ZigString.static("clearAllMocks"), 2, JSMock__jsClearAllMocks, false);
const mockModuleFn = jsc.host_fn.NewFunction(globalObject, ZigString.static("module"), 2, JSMock__jsModuleMock, false);
module.put(globalObject, ZigString.static("mock"), mockFn);
mockFn.put(globalObject, ZigString.static("module"), mockModuleFn);
mockFn.put(globalObject, ZigString.static("restore"), restoreAllMocks);
mockFn.put(globalObject, ZigString.static("clearAllMocks"), clearAllMocks);
const jest = JSValue.createEmptyObject(globalObject, 10);
jest.put(globalObject, ZigString.static("fn"), mockFn);
jest.put(globalObject, ZigString.static("mock"), mockModuleFn);
jest.put(globalObject, ZigString.static("spyOn"), spyOn);
jest.put(globalObject, ZigString.static("restoreAllMocks"), restoreAllMocks);
jest.put(globalObject, ZigString.static("clearAllMocks"), clearAllMocks);
jest.put(globalObject, ZigString.static("resetAllMocks"), clearAllMocks);
jest.put(
globalObject,
ZigString.static("setSystemTime"),
setSystemTime,
);
jest.put(
globalObject,
ZigString.static("useFakeTimers"),
useFakeTimers,
);
jest.put(
globalObject,
ZigString.static("useRealTimers"),
useRealTimers,
);
jest.put(globalObject, ZigString.static("now"), jsc.host_fn.NewFunction(globalObject, ZigString.static("now"), 0, JSMock__jsNow, false));
jest.put(globalObject, ZigString.static("setTimeout"), jsc.host_fn.NewFunction(globalObject, ZigString.static("setTimeout"), 1, jsSetDefaultTimeout, false));
module.put(globalObject, ZigString.static("jest"), jest);
module.put(globalObject, ZigString.static("spyOn"), spyOn);
module.put(
globalObject,
ZigString.static("expect"),
Expect.js.getConstructor(globalObject),
);
const vi = JSValue.createEmptyObject(globalObject, 5);
vi.put(globalObject, ZigString.static("fn"), mockFn);
vi.put(globalObject, ZigString.static("mock"), mockModuleFn);
vi.put(globalObject, ZigString.static("spyOn"), spyOn);
vi.put(globalObject, ZigString.static("restoreAllMocks"), restoreAllMocks);
vi.put(globalObject, ZigString.static("clearAllMocks"), clearAllMocks);
module.put(globalObject, ZigString.static("vi"), vi);
}
extern fn Bun__Jest__testModuleObject(*JSGlobalObject) JSValue;
extern fn JSMock__jsMockFn(*JSGlobalObject, *CallFrame) callconv(jsc.conv) JSValue;
extern fn JSMock__jsModuleMock(*JSGlobalObject, *CallFrame) callconv(jsc.conv) JSValue;
extern fn JSMock__jsNow(*JSGlobalObject, *CallFrame) callconv(jsc.conv) JSValue;
extern fn JSMock__jsSetSystemTime(*JSGlobalObject, *CallFrame) callconv(jsc.conv) JSValue;
extern fn JSMock__jsRestoreAllMocks(*JSGlobalObject, *CallFrame) callconv(jsc.conv) JSValue;
extern fn JSMock__jsClearAllMocks(*JSGlobalObject, *CallFrame) callconv(jsc.conv) JSValue;
extern fn JSMock__jsSpyOn(*JSGlobalObject, *CallFrame) callconv(jsc.conv) JSValue;
extern fn JSMock__jsUseFakeTimers(*JSGlobalObject, *CallFrame) callconv(jsc.conv) JSValue;
extern fn JSMock__jsUseRealTimers(*JSGlobalObject, *CallFrame) callconv(jsc.conv) JSValue;
pub fn call(
globalObject: *JSGlobalObject,
callframe: *CallFrame,
) bun.JSError!JSValue {
const vm = globalObject.bunVM();
if (vm.is_in_preload or runner == null) {
// in preload, no arguments needed
} else {
const arguments = callframe.arguments_old(2).slice();
if (arguments.len < 1 or !arguments[0].isString()) {
return globalObject.throw("Bun.jest() expects a string filename", .{});
}
var str = try arguments[0].toSlice(globalObject, bun.default_allocator);
defer str.deinit();
const slice = str.slice();
if (!std.fs.path.isAbsolute(slice)) {
return globalObject.throw("Bun.jest() expects an absolute file path, got '{s}'", .{slice});
}
}
return Bun__Jest__testModuleObject(globalObject);
}
fn jsSetDefaultTimeout(globalObject: *JSGlobalObject, callframe: *CallFrame) bun.JSError!JSValue {
const arguments = callframe.arguments_old(1).slice();
if (arguments.len < 1 or !arguments[0].isNumber()) {
return globalObject.throw("setTimeout() expects a number (milliseconds)", .{});
}
const timeout_ms: u32 = @intCast(@max(try arguments[0].coerce(i32, globalObject), 0));
if (Jest.runner) |test_runner| {
test_runner.default_timeout_override = timeout_ms;
}
return .js_undefined;
}
comptime {
@export(&Bun__Jest__createTestModuleObject, .{ .name = "Bun__Jest__createTestModuleObject" });
}
};
pub const on_unhandled_rejection = struct {
pub fn onUnhandledRejection(jsc_vm: *VirtualMachine, globalObject: *JSGlobalObject, rejection: JSValue) void {
if (bun.jsc.Jest.bun_test.cloneActiveStrong()) |buntest_strong_| {
var buntest_strong = buntest_strong_;
defer buntest_strong.deinit();
const buntest = buntest_strong.get();
var current_state_data = buntest.getCurrentStateData(); // mark unhandled errors as belonging to the currently active test. note that this can be misleading.
if (current_state_data.entry(buntest)) |entry| {
if (current_state_data.sequence(buntest)) |sequence| {
if (entry != sequence.test_entry) {
current_state_data = .start; // mark errors in hooks as 'unhandled error between tests'
}
}
}
buntest.onUncaughtException(globalObject, rejection, true, current_state_data);
buntest.addResult(current_state_data);
bun_test.BunTest.run(buntest_strong, globalObject) catch |e| {
globalObject.reportUncaughtExceptionFromError(e);
};
return;
}
jsc_vm.runErrorHandler(rejection, jsc_vm.onUnhandledRejectionExceptionList);
}
};
fn consumeArg(
globalThis: *JSGlobalObject,
should_write: bool,
str_idx: *usize,
args_idx: *usize,
array_list: *std.ArrayList(u8),
arg: *const JSValue,
fallback: []const u8,
) !void {
if (should_write) {
const owned_slice = try arg.toSliceOrNull(globalThis);
defer owned_slice.deinit();
bun.handleOom(array_list.appendSlice(owned_slice.slice()));
} else {
bun.handleOom(array_list.appendSlice(fallback));
}
str_idx.* += 1;
args_idx.* += 1;
}
// Generate test label by positionally injecting parameters with printf formatting
pub fn formatLabel(globalThis: *JSGlobalObject, label: string, function_args: []const jsc.JSValue, test_idx: usize, allocator: std.mem.Allocator) !string {
var idx: usize = 0;
var args_idx: usize = 0;
var list = bun.handleOom(std.ArrayList(u8).initCapacity(allocator, label.len));
defer list.deinit();
while (idx < label.len) {
const char = label[idx];
if (char == '$' and idx + 1 < label.len and function_args.len > 0 and function_args[0].isObject()) {
const var_start = idx + 1;
var var_end = var_start;
if (bun.js_lexer.isIdentifierStart(label[var_end])) {
var_end += 1;
while (var_end < label.len) {
const c = label[var_end];
if (c == '.') {
if (var_end + 1 < label.len and bun.js_lexer.isIdentifierContinue(label[var_end + 1])) {
var_end += 1;
} else {
break;
}
} else if (bun.js_lexer.isIdentifierContinue(c)) {
var_end += 1;
} else {
break;
}
}
const var_path = label[var_start..var_end];
const value = try function_args[0].getIfPropertyExistsFromPath(globalThis, bun.String.init(var_path).toJS(globalThis));
if (!value.isEmptyOrUndefinedOrNull()) {
var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true };
defer formatter.deinit();
bun.handleOom(list.writer().print("{}", .{value.toFmt(&formatter)}));
idx = var_end;
continue;
}
} else {
while (var_end < label.len and (bun.js_lexer.isIdentifierContinue(label[var_end]) and label[var_end] != '$')) {
var_end += 1;
}
}
bun.handleOom(list.append('$'));
bun.handleOom(list.appendSlice(label[var_start..var_end]));
idx = var_end;
} else if (char == '%' and (idx + 1 < label.len) and !(args_idx >= function_args.len)) {
const current_arg = function_args[args_idx];
switch (label[idx + 1]) {
's' => {
try consumeArg(globalThis, current_arg != .zero and current_arg.jsType().isString(), &idx, &args_idx, &list, &current_arg, "%s");
},
'i' => {
try consumeArg(globalThis, current_arg.isAnyInt(), &idx, &args_idx, &list, &current_arg, "%i");
},
'd' => {
try consumeArg(globalThis, current_arg.isNumber(), &idx, &args_idx, &list, &current_arg, "%d");
},
'f' => {
try consumeArg(globalThis, current_arg.isNumber(), &idx, &args_idx, &list, &current_arg, "%f");
},
'j', 'o' => {
var str = bun.String.empty;
defer str.deref();
try current_arg.jsonStringify(globalThis, 0, &str);
const owned_slice = bun.handleOom(str.toOwnedSlice(allocator));
defer allocator.free(owned_slice);
bun.handleOom(list.appendSlice(owned_slice));
idx += 1;
args_idx += 1;
},
'p' => {
var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true };
defer formatter.deinit();
const value_fmt = current_arg.toFmt(&formatter);
bun.handleOom(list.writer().print("{}", .{value_fmt}));
idx += 1;
args_idx += 1;
},
'#' => {
const test_index_str = bun.handleOom(std.fmt.allocPrint(allocator, "{d}", .{test_idx}));
defer allocator.free(test_index_str);
bun.handleOom(list.appendSlice(test_index_str));
idx += 1;
},
'%' => {
bun.handleOom(list.append('%'));
idx += 1;
},
else => {
// ignore unrecognized fmt
},
}
} else bun.handleOom(list.append(char));
idx += 1;
}
return list.toOwnedSlice();
}
pub fn captureTestLineNumber(callframe: *jsc.CallFrame, globalThis: *JSGlobalObject) u32 {
if (Jest.runner) |runner| {
if (runner.test_options.file_reporter == .junit) {
return bun.cpp.Bun__CallFrame__getLineNumber(callframe, globalThis);
}
}
return 0;
}
const string = []const u8;
pub const bun_test = @import("./bun_test.zig");
const std = @import("std");
const Snapshots = @import("./snapshot.zig").Snapshots;
const expect = @import("./expect.zig");
const Expect = expect.Expect;
const ExpectTypeOf = expect.ExpectTypeOf;
const bun = @import("bun");
const ArrayIdentityContext = bun.ArrayIdentityContext;
const Output = bun.Output;
const RegularExpression = bun.RegularExpression;
const default_allocator = bun.default_allocator;
const logger = bun.logger;
const jsc = bun.jsc;
const CallFrame = jsc.CallFrame;
const JSGlobalObject = jsc.JSGlobalObject;
const JSValue = jsc.JSValue;
const VirtualMachine = jsc.VirtualMachine;
const ZigString = jsc.ZigString;