Files
bun.sh/src/bun.js/test/ScopeFunctions.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

513 lines
24 KiB
Zig

const Mode = enum { describe, @"test" };
mode: Mode,
cfg: bun_test.BaseScopeCfg,
/// typically `.zero`. not Strong.Optional because codegen adds it to the visit function.
each: jsc.JSValue,
pub const strings = struct {
pub const describe = bun.String.static("describe");
pub const xdescribe = bun.String.static("xdescribe");
pub const @"test" = bun.String.static("test");
pub const xtest = bun.String.static("xtest");
pub const skip = bun.String.static("skip");
pub const todo = bun.String.static("todo");
pub const failing = bun.String.static("failing");
pub const concurrent = bun.String.static("concurrent");
pub const only = bun.String.static("only");
pub const @"if" = bun.String.static("if");
pub const skipIf = bun.String.static("skipIf");
pub const todoIf = bun.String.static("todoIf");
pub const failingIf = bun.String.static("failingIf");
pub const concurrentIf = bun.String.static("concurrentIf");
pub const each = bun.String.static("each");
};
pub fn getSkip(this: *ScopeFunctions, globalThis: *JSGlobalObject) bun.JSError!JSValue {
return genericExtend(this, globalThis, .{ .self_mode = .skip }, "get .skip", strings.skip);
}
pub fn getTodo(this: *ScopeFunctions, globalThis: *JSGlobalObject) bun.JSError!JSValue {
return genericExtend(this, globalThis, .{ .self_mode = .todo }, "get .todo", strings.todo);
}
pub fn getFailing(this: *ScopeFunctions, globalThis: *JSGlobalObject) bun.JSError!JSValue {
return genericExtend(this, globalThis, .{ .self_mode = .failing }, "get .failing", strings.failing);
}
pub fn getConcurrent(this: *ScopeFunctions, globalThis: *JSGlobalObject) bun.JSError!JSValue {
return genericExtend(this, globalThis, .{ .self_concurrent = true }, "get .concurrent", strings.concurrent);
}
pub fn getOnly(this: *ScopeFunctions, globalThis: *JSGlobalObject) bun.JSError!JSValue {
return genericExtend(this, globalThis, .{ .self_only = true }, "get .only", strings.only);
}
pub fn fnIf(this: *ScopeFunctions, globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue {
return genericIf(this, globalThis, callFrame, .{ .self_mode = .skip }, "call .if()", true, strings.@"if");
}
pub fn fnSkipIf(this: *ScopeFunctions, globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue {
return genericIf(this, globalThis, callFrame, .{ .self_mode = .skip }, "call .skipIf()", false, strings.skipIf);
}
pub fn fnTodoIf(this: *ScopeFunctions, globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue {
return genericIf(this, globalThis, callFrame, .{ .self_mode = .todo }, "call .todoIf()", false, strings.todoIf);
}
pub fn fnFailingIf(this: *ScopeFunctions, globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue {
return genericIf(this, globalThis, callFrame, .{ .self_mode = .failing }, "call .failingIf()", false, strings.failingIf);
}
pub fn fnConcurrentIf(this: *ScopeFunctions, globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue {
return genericIf(this, globalThis, callFrame, .{ .self_concurrent = true }, "call .concurrentIf()", false, strings.concurrentIf);
}
pub fn fnEach(this: *ScopeFunctions, globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue {
groupLog.begin(@src());
defer groupLog.end();
const array = callFrame.argumentsAsArray(1)[0];
if (array.isUndefinedOrNull() or !array.isArray()) {
var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis };
defer formatter.deinit();
return globalThis.throw("Expected array, got {}", .{array.toFmt(&formatter)});
}
if (this.each != .zero) return globalThis.throw("Cannot {s} on {f}", .{ "each", this });
return createBound(globalThis, this.mode, array, this.cfg, strings.each);
}
pub fn callAsFunction(globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue {
groupLog.begin(@src());
defer groupLog.end();
const this = ScopeFunctions.fromJS(callFrame.this()) orelse return globalThis.throw("Expected callee to be ScopeFunctions", .{});
const line_no = jsc.Jest.captureTestLineNumber(callFrame, globalThis);
var buntest_strong = try bun_test.js_fns.cloneActiveStrong(globalThis, .{ .signature = .{ .scope_functions = this }, .allow_in_preload = false });
defer buntest_strong.deinit();
const bunTest = buntest_strong.get();
const callback_mode: CallbackMode = switch (this.cfg.self_mode) {
.skip, .todo => .allow,
else => .require,
};
var args = try parseArguments(globalThis, callFrame, .{ .scope_functions = this }, bunTest.gpa, .{ .callback = callback_mode });
defer args.deinit(bunTest.gpa);
const callback_length = if (args.callback) |callback| try callback.getLength(globalThis) else 0;
if (this.each != .zero) {
if (this.each.isUndefinedOrNull() or !this.each.isArray()) {
var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis };
defer formatter.deinit();
return globalThis.throw("Expected array, got {}", .{this.each.toFmt(&formatter)});
}
var iter = try this.each.arrayIterator(globalThis);
var test_idx: usize = 0;
while (try iter.next()) |item| : (test_idx += 1) {
if (item == .zero) break;
var args_list: std.ArrayList(Strong) = .init(bunTest.gpa);
defer args_list.deinit();
defer for (args_list.items) |*arg| arg.deinit();
if (item.isArray()) {
// Spread array as args_list (matching Jest & Vitest)
bun.handleOom(args_list.ensureUnusedCapacity(try item.getLength(globalThis)));
var item_iter = try item.arrayIterator(globalThis);
var idx: usize = 0;
while (try item_iter.next()) |array_item| : (idx += 1) {
bun.handleOom(args_list.append(.init(bunTest.gpa, array_item)));
}
} else {
bun.handleOom(args_list.append(.init(bunTest.gpa, item)));
}
var args_list_raw = bun.handleOom(std.ArrayList(jsc.JSValue).initCapacity(bunTest.gpa, args_list.items.len)); // safe because the items are held strongly in args_list
defer args_list_raw.deinit();
for (args_list.items) |arg| bun.handleOom(args_list_raw.append(arg.get()));
const formatted_label: ?[]const u8 = if (args.description) |desc| try jsc.Jest.formatLabel(globalThis, desc, args_list_raw.items, test_idx, bunTest.gpa) else null;
defer if (formatted_label) |label| bunTest.gpa.free(label);
const bound = if (args.callback) |cb| try cb.bind(globalThis, item, &bun.String.static("cb"), 0, args_list_raw.items) else null;
try this.enqueueDescribeOrTestCallback(bunTest, globalThis, callFrame, bound, formatted_label, args.options.timeout, callback_length -| args_list.items.len, line_no);
}
} else {
try this.enqueueDescribeOrTestCallback(bunTest, globalThis, callFrame, args.callback, args.description, args.options.timeout, callback_length, line_no);
}
return .js_undefined;
}
const Measure = struct {
len: usize,
fn writeEnd(this: *Measure, write: []const u8) void {
this.len += write.len;
}
};
const Write = struct {
buf: []u8,
fn writeEnd(this: *Write, write: []const u8) void {
if (this.buf.len < write.len) {
bun.debugAssert(false);
return;
}
@memcpy(this.buf[this.buf.len - write.len ..], write);
this.buf = this.buf[0 .. this.buf.len - write.len];
}
};
fn filterNames(comptime Rem: type, rem: *Rem, description: ?[]const u8, parent_in: ?*bun_test.DescribeScope) void {
const sep = " ";
rem.writeEnd(description orelse "");
var parent = parent_in;
while (parent) |scope| : (parent = scope.base.parent) {
if (scope.base.name == null) continue;
rem.writeEnd(sep);
rem.writeEnd(scope.base.name orelse "");
}
}
fn enqueueDescribeOrTestCallback(this: *ScopeFunctions, bunTest: *bun_test.BunTest, globalThis: *jsc.JSGlobalObject, callFrame: *jsc.CallFrame, callback: ?jsc.JSValue, description: ?[]const u8, timeout: u32, callback_length: usize, line_no: u32) bun.JSError!void {
groupLog.begin(@src());
defer groupLog.end();
// only allow in collection phase
switch (bunTest.phase) {
.collection => {}, // ok
.execution => return globalThis.throw("Cannot call {}() inside a test. Call it inside describe() instead.", .{this}),
.done => return globalThis.throw("Cannot call {}() after the test run has completed", .{this}),
}
// handle test reporter agent for debugger
const vm = globalThis.bunVM();
var test_id_for_debugger: i32 = 0;
if (vm.debugger) |*debugger| {
if (debugger.test_reporter_agent.isEnabled()) {
const globals = struct {
var max_test_id_for_debugger: i32 = 0;
};
globals.max_test_id_for_debugger += 1;
var name = bun.String.init(description orelse "(unnamed)");
const parent = bunTest.collection.active_scope;
const parent_id = if (parent.base.test_id_for_debugger != 0) parent.base.test_id_for_debugger else -1;
debugger.test_reporter_agent.reportTestFound(callFrame, globals.max_test_id_for_debugger, &name, switch (this.mode) {
.describe => .describe,
.@"test" => .@"test",
}, parent_id);
test_id_for_debugger = globals.max_test_id_for_debugger;
}
}
const has_done_parameter = if (callback != null) callback_length >= 1 else false;
var base = this.cfg;
base.line_no = line_no;
base.test_id_for_debugger = test_id_for_debugger;
if (bun.jsc.Jest.Jest.runner) |runner| if (runner.concurrent) {
base.self_concurrent = true;
};
switch (this.mode) {
.describe => {
// Check line filter for describe blocks too
var matches_line_filter = true;
if (bunTest.reporter != null) {
const reporter = bunTest.reporter.?;
if (reporter.jest.hasTestLineFilter()) {
// Get the current file path
const file_path = if (vm.main.len > 0) vm.main else "";
// For describe blocks, we need to check if any line filter matches this file
// If there are no filters for this file, we proceed normally
// If there are filters for this file, we check if this describe block or its parents match
var parent_lines = std.ArrayList(u32).init(bunTest.gpa);
defer parent_lines.deinit();
var parent_scope = bunTest.collection.active_scope;
while (parent_scope.base.parent) |parent| {
if (parent.base.line_no > 0) {
bun.handleOom(parent_lines.append(parent.base.line_no));
}
parent_scope = parent;
}
matches_line_filter = reporter.jest.matchesLineFilter(file_path, line_no, parent_lines.items);
// If describe doesn't match but has a specific line filter for this file,
// still create it but mark as potentially filtered
if (!matches_line_filter) {
base.self_mode = .filtered_out;
}
}
}
const new_scope = try bunTest.collection.active_scope.appendDescribe(bunTest.gpa, description, base);
try bunTest.collection.enqueueDescribeCallback(new_scope, callback);
},
.@"test" => {
// check for filter match
var matches_filter = true;
if (bunTest.reporter) |reporter| if (reporter.jest.filter_regex) |filter_regex| {
groupLog.log("matches_filter begin", .{});
bun.assert(bunTest.collection.filter_buffer.items.len == 0);
defer bunTest.collection.filter_buffer.clearRetainingCapacity();
var len: Measure = .{ .len = 0 };
filterNames(Measure, &len, description, bunTest.collection.active_scope);
const slice = try bunTest.collection.filter_buffer.addManyAsSlice(len.len);
var rem: Write = .{ .buf = slice };
filterNames(Write, &rem, description, bunTest.collection.active_scope);
bun.debugAssert(rem.buf.len == 0);
const str = bun.String.fromBytes(bunTest.collection.filter_buffer.items);
groupLog.log("matches_filter \"{}\"", .{std.zig.fmtEscapes(bunTest.collection.filter_buffer.items)});
matches_filter = filter_regex.matches(str);
};
// Check line filter as well
if (matches_filter and bunTest.reporter != null) {
const reporter = bunTest.reporter.?;
if (reporter.jest.hasTestLineFilter()) {
// Get the current file path
const file_path = if (vm.main.len > 0) vm.main else "";
// Collect parent line numbers for describe blocks
var parent_lines = std.ArrayList(u32).init(bunTest.gpa);
defer parent_lines.deinit();
var parent_scope = bunTest.collection.active_scope;
while (parent_scope.base.parent) |parent| {
if (parent.base.line_no > 0) {
bun.handleOom(parent_lines.append(parent.base.line_no));
}
parent_scope = parent;
}
matches_filter = reporter.jest.matchesLineFilter(file_path, line_no, parent_lines.items);
}
}
if (!matches_filter) {
base.self_mode = .filtered_out;
}
bun.assert(!bunTest.collection.locked);
groupLog.log("enqueueTestCallback / {s} / in scope: {s}", .{ description orelse "(unnamed)", bunTest.collection.active_scope.base.name orelse "(unnamed)" });
_ = try bunTest.collection.active_scope.appendTest(bunTest.gpa, description, if (matches_filter) callback else null, .{
.has_done_parameter = has_done_parameter,
.timeout = timeout,
}, base);
},
}
}
fn genericIf(this: *ScopeFunctions, globalThis: *JSGlobalObject, callFrame: *CallFrame, conditional_cfg: bun_test.BaseScopeCfg, name: []const u8, invert: bool, fn_name: bun.String) bun.JSError!JSValue {
groupLog.begin(@src());
defer groupLog.end();
const condition = callFrame.argumentsAsArray(1)[0];
if (callFrame.arguments().len == 0) return globalThis.throw("Expected condition to be a boolean", .{});
const cond = condition.toBoolean();
if (cond != invert) {
return genericExtend(this, globalThis, conditional_cfg, name, fn_name);
} else {
return createBound(globalThis, this.mode, this.each, this.cfg, fn_name);
}
}
fn genericExtend(this: *ScopeFunctions, globalThis: *JSGlobalObject, cfg: bun_test.BaseScopeCfg, name: []const u8, fn_name: bun.String) bun.JSError!JSValue {
groupLog.begin(@src());
defer groupLog.end();
if (cfg.self_mode == .failing and this.mode == .describe) return globalThis.throw("Cannot {s} on {f}", .{ name, this });
if (cfg.self_only) try errorInCI(globalThis, ".only");
const extended = this.cfg.extend(cfg) orelse return globalThis.throw("Cannot {s} on {f}", .{ name, this });
return createBound(globalThis, this.mode, this.each, extended, fn_name);
}
fn errorInCI(globalThis: *jsc.JSGlobalObject, signature: []const u8) bun.JSError!void {
if (!bun.FeatureFlags.breaking_changes_1_3) return; // this is a breaking change for version 1.3
if (bun.detectCI()) |_| {
return globalThis.throwPretty("{s} is not allowed in CI environments.\nIf this is not a CI environment, set the environment variable CI=false to force allow.", .{signature});
}
}
const ParseArgumentsResult = struct {
description: ?[]const u8,
callback: ?jsc.JSValue,
options: struct {
timeout: u32 = 0,
retry: ?f64 = null,
repeats: ?f64 = null,
},
pub fn deinit(this: *ParseArgumentsResult, gpa: std.mem.Allocator) void {
if (this.description) |str| gpa.free(str);
}
};
pub const CallbackMode = enum { require, allow };
fn getDescription(gpa: std.mem.Allocator, globalThis: *jsc.JSGlobalObject, description: jsc.JSValue, signature: Signature) bun.JSError![]const u8 {
const is_valid_description =
description.isClass(globalThis) or
(description.isFunction() and !description.getName(globalThis).isEmpty()) or
description.isNumber() or
description.isString();
if (!is_valid_description) {
return globalThis.throwPretty("{s}() expects first argument to be a named class, named function, number, or string", .{signature});
}
if (description == .zero) {
return "";
}
if (description.isClass(globalThis)) {
const name_str = if ((try description.className(globalThis)).toSlice(gpa).length() == 0)
description.getName(globalThis).toSlice(gpa).slice()
else
(try description.className(globalThis)).toSlice(gpa).slice();
return try gpa.dupe(u8, name_str);
}
if (description.isFunction()) {
var slice = description.getName(globalThis).toSlice(gpa);
defer slice.deinit();
return try gpa.dupe(u8, slice.slice());
}
var slice = try description.toSlice(globalThis, gpa);
defer slice.deinit();
return try gpa.dupe(u8, slice.slice());
}
pub fn parseArguments(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame, signature: Signature, gpa: std.mem.Allocator, cfg: struct { callback: CallbackMode }) bun.JSError!ParseArgumentsResult {
var a1, var a2, var a3 = callframe.argumentsAsArray(3);
const len: enum { three, two, one, zero } = if (!a3.isUndefinedOrNull()) .three else if (!a2.isUndefinedOrNull()) .two else if (!a1.isUndefinedOrNull()) .one else .zero;
const DescriptionCallbackOptions = struct { description: JSValue = .js_undefined, callback: JSValue = .js_undefined, options: JSValue = .js_undefined };
const items: DescriptionCallbackOptions = switch (len) {
// description, callback(fn), options(!fn)
// description, options(!fn), callback(fn)
.three => if (a2.isFunction()) .{ .description = a1, .callback = a2, .options = a3 } else .{ .description = a1, .callback = a3, .options = a2 },
// description, callback(fn)
.two => .{ .description = a1, .callback = a2 },
// description
// callback(fn)
.one => if (a1.isFunction()) .{ .callback = a1 } else .{ .description = a1 },
.zero => .{},
};
const description, const callback, const options = .{ items.description, items.callback, items.options };
const result_callback: ?jsc.JSValue = if (cfg.callback != .require and callback.isUndefinedOrNull()) blk: {
break :blk null;
} else if (callback.isFunction()) blk: {
break :blk callback.withAsyncContextIfNeeded(globalThis);
} else {
return globalThis.throw("{s} expects a function as the second argument", .{signature});
};
var result: ParseArgumentsResult = .{
.description = null,
.callback = result_callback,
.options = .{},
};
errdefer result.deinit(gpa);
var timeout_option: ?f64 = null;
if (options.isNumber()) {
timeout_option = options.asNumber();
} else if (options.isFunction()) {
return globalThis.throw("{}() expects options to be a number or object, not a function", .{signature});
} else if (options.isObject()) {
if (try options.get(globalThis, "timeout")) |timeout| {
if (!timeout.isNumber()) {
return globalThis.throwPretty("{}() expects timeout to be a number", .{signature});
}
timeout_option = timeout.asNumber();
}
if (try options.get(globalThis, "retry")) |retries| {
if (!retries.isNumber()) {
return globalThis.throwPretty("{}() expects retry to be a number", .{signature});
}
result.options.retry = retries.asNumber();
}
if (try options.get(globalThis, "repeats")) |repeats| {
if (!repeats.isNumber()) {
return globalThis.throwPretty("{}() expects repeats to be a number", .{signature});
}
result.options.repeats = repeats.asNumber();
}
} else if (options.isUndefinedOrNull()) {
// no options
} else {
return globalThis.throw("{}() expects a number, object, or undefined as the third argument", .{signature});
}
result.description = if (description.isUndefinedOrNull()) null else try getDescription(gpa, globalThis, description, signature);
const default_timeout_ms: ?u32 = if (bun.jsc.Jest.Jest.runner) |runner| if (runner.default_timeout_ms != 0) runner.default_timeout_ms else null else null;
const override_timeout_ms: ?u32 = if (bun.jsc.Jest.Jest.runner) |runner| if (runner.default_timeout_override != std.math.maxInt(u32)) runner.default_timeout_override else null else null;
const timeout_option_ms: ?u32 = if (timeout_option) |timeout| std.math.lossyCast(u32, timeout) else null;
result.options.timeout = timeout_option_ms orelse override_timeout_ms orelse default_timeout_ms orelse 0;
return result;
}
pub const js = jsc.Codegen.JSScopeFunctions;
pub const toJS = js.toJS;
pub const fromJS = js.fromJS;
pub const fromJSDirect = js.fromJSDirect;
pub fn format(this: ScopeFunctions, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void {
try writer.print("{s}", .{@tagName(this.mode)});
if (this.cfg.self_concurrent) try writer.print(".concurrent", .{});
if (this.cfg.self_mode != .normal) try writer.print(".{s}", .{@tagName(this.cfg.self_mode)});
if (this.cfg.self_only) try writer.print(".only", .{});
if (this.each != .zero) try writer.print(".each()", .{});
}
pub fn finalize(
this: *ScopeFunctions,
) callconv(.C) void {
groupLog.begin(@src());
defer groupLog.end();
VirtualMachine.get().allocator.destroy(this);
}
pub fn createUnbound(globalThis: *JSGlobalObject, mode: Mode, each: jsc.JSValue, cfg: bun_test.BaseScopeCfg) JSValue {
groupLog.begin(@src());
defer groupLog.end();
var scope_functions = bun.handleOom(globalThis.bunVM().allocator.create(ScopeFunctions));
scope_functions.* = .{ .mode = mode, .cfg = cfg, .each = each };
const value = scope_functions.toJS(globalThis);
value.ensureStillAlive();
return value;
}
pub fn bind(value: JSValue, globalThis: *JSGlobalObject, name: *const bun.String) bun.JSError!JSValue {
const callFn = jsc.host_fn.NewFunction(globalThis, &name.toZigString(), 1, callAsFunction, false);
const bound = try callFn.bind(globalThis, value, name, 1, &.{});
try bound.setPrototypeDirect(value.getPrototype(globalThis), globalThis);
return bound;
}
pub fn createBound(globalThis: *JSGlobalObject, mode: Mode, each: jsc.JSValue, cfg: bun_test.BaseScopeCfg, name: bun.String) bun.JSError!JSValue {
groupLog.begin(@src());
defer groupLog.end();
const value = createUnbound(globalThis, mode, each, cfg);
return bind(value, globalThis, &name);
}
const bun = @import("bun");
const std = @import("std");
const jsc = bun.jsc;
const CallFrame = jsc.CallFrame;
const JSGlobalObject = jsc.JSGlobalObject;
const JSValue = jsc.JSValue;
const VirtualMachine = jsc.VirtualMachine;
const Strong = jsc.Strong.Deprecated;
const bun_test = jsc.Jest.bun_test;
const BunTest = bun_test.BunTest;
const ScopeFunctions = bun_test.ScopeFunctions;
const Signature = bun_test.js_fns.Signature;
const groupLog = bun_test.debug.group;