Compare commits

...

2 Commits

Author SHA1 Message Date
autofix-ci[bot]
f5a00a647c [autofix.ci] apply automated fixes 2025-09-20 09:44:09 +00:00
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
4 changed files with 446 additions and 18 deletions

View File

@@ -202,6 +202,38 @@ fn enqueueDescribeOrTestCallback(this: *ScopeFunctions, bunTest: *bun_test.BunTe
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);
},
@@ -226,6 +258,29 @@ fn enqueueDescribeOrTestCallback(this: *ScopeFunctions, bunTest: *bun_test.BunTe
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;
}

View File

@@ -1,3 +1,31 @@
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 = "",
@@ -76,6 +104,9 @@ pub const TestRunner = struct {
// 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{},
@@ -99,6 +130,80 @@ pub const TestRunner = struct {
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) {

View File

@@ -571,6 +571,33 @@ pub const JunitReporter = struct {
}
};
fn parseFileLineArg(arg: []const u8) ?struct { file_pattern: []const u8, line_num: u32 } {
const colon_index = strings.lastIndexOfChar(arg, ':') orelse return null;
const after_colon = arg[colon_index + 1 ..];
const before_colon = arg[0..colon_index];
if (before_colon.len == 0) return null;
const line_num = std.fmt.parseInt(u32, after_colon, 10) catch return null;
if (line_num == 0) return null;
// Check for file.ts:5:10 syntax (column specified)
if (strings.lastIndexOfChar(before_colon, ':')) |second_last_colon_index| {
if (std.fmt.parseInt(u32, before_colon[second_last_colon_index + 1 ..], 10) catch null) |line_num_2| {
if (line_num_2 == 0) return null;
// Use the first line number (ignore column)
return .{
.file_pattern = arg[0..second_last_colon_index],
.line_num = line_num_2,
};
}
}
return .{
.file_pattern = before_colon,
.line_num = line_num,
};
}
pub const CommandLineReporter = struct {
jest: TestRunner,
last_dot: u32 = 0,
@@ -1395,31 +1422,58 @@ pub const TestCommand = struct {
var scanner = bun.handleOom(Scanner.init(ctx.allocator, &vm.transpiler, ctx.positionals.len));
defer scanner.deinit();
// Collect line filters
var line_filters = std.ArrayList(struct { path: []const u8, line: u32 }).init(ctx.allocator);
defer line_filters.deinit();
const has_relative_path = for (ctx.positionals) |arg| {
if (std.fs.path.isAbsolute(arg) or
strings.startsWith(arg, "./") or
strings.startsWith(arg, "../") or
(Environment.isWindows and (strings.startsWith(arg, ".\\") or
strings.startsWith(arg, "..\\")))) break true;
// Check if it's a file path or file:line path
const file_part = if (parseFileLineArg(arg)) |parsed| parsed.file_pattern else arg;
if (std.fs.path.isAbsolute(file_part) or
strings.startsWith(file_part, "./") or
strings.startsWith(file_part, "../") or
(Environment.isWindows and (strings.startsWith(file_part, ".\\") or
strings.startsWith(file_part, "..\\")))) break true;
} else false;
if (has_relative_path) {
// One of the files is a filepath. Instead of treating the
// arguments as filters, treat them as filepaths
const file_or_dirnames = ctx.positionals[1..];
for (file_or_dirnames) |arg| {
scanner.scan(arg) catch |err| switch (err) {
error.OutOfMemory => bun.outOfMemory(),
// don't error if multiple are passed; one might fail
// but the others may not
error.DoesNotExist => if (file_or_dirnames.len == 1) {
if (Output.isAIAgent()) {
Output.prettyErrorln("Test filter <b>{}<r> had no matches in --cwd={}", .{ bun.fmt.quote(arg), bun.fmt.quote(bun.fs.FileSystem.instance.top_level_dir) });
} else {
Output.prettyErrorln("Test filter <b>{}<r> had no matches", .{bun.fmt.quote(arg)});
}
Global.exit(1);
},
};
// Parse file:line syntax if present
if (parseFileLineArg(arg)) |parsed| {
scanner.scan(parsed.file_pattern) catch |err| switch (err) {
error.OutOfMemory => bun.outOfMemory(),
error.DoesNotExist => if (file_or_dirnames.len == 1) {
if (Output.isAIAgent()) {
Output.prettyErrorln("Test file <b>{}<r> had no matches in --cwd={}", .{ bun.fmt.quote(parsed.file_pattern), bun.fmt.quote(bun.fs.FileSystem.instance.top_level_dir) });
} else {
Output.prettyErrorln("Test file <b>{}<r> had no matches", .{bun.fmt.quote(parsed.file_pattern)});
}
Global.exit(1);
},
};
// Store the line filter to pass to the test runner
line_filters.append(.{
.path = parsed.file_pattern,
.line = parsed.line_num,
}) catch bun.outOfMemory();
} else {
scanner.scan(arg) catch |err| switch (err) {
error.OutOfMemory => bun.outOfMemory(),
// don't error if multiple are passed; one might fail
// but the others may not
error.DoesNotExist => if (file_or_dirnames.len == 1) {
if (Output.isAIAgent()) {
Output.prettyErrorln("Test filter <b>{}<r> had no matches in --cwd={}", .{ bun.fmt.quote(arg), bun.fmt.quote(bun.fs.FileSystem.instance.top_level_dir) });
} else {
Output.prettyErrorln("Test filter <b>{}<r> had no matches", .{bun.fmt.quote(arg)});
}
Global.exit(1);
},
};
}
}
} else {
// Treat arguments as filters and scan the codebase
@@ -1477,6 +1531,17 @@ pub const TestCommand = struct {
else => {},
}
// Add line filters to the test runner
for (line_filters.items) |filter| {
// Convert relative paths to absolute paths
const abs_path = if (std.fs.path.isAbsolute(filter.path))
filter.path
else
resolve_path.joinAbs(scanner.fs.top_level_dir, .auto, filter.path);
reporter.jest.addLineFilter(abs_path, filter.line) catch bun.outOfMemory();
}
runAllTests(reporter, vm, test_files, ctx.allocator);
}

View File

@@ -0,0 +1,203 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
describe("test line filtering", () => {
test("runs specific test by line number", async () => {
using dir = tempDir("test-line-filter", {
"test.test.ts": `
import { test, expect } from "bun:test";
test("test 1 - should NOT run", () => {
console.log("❌ Test 1 ran - this should not happen!");
expect.unreachable("Test 1 should not run");
});
test("target test - SHOULD run", () => {
console.log("✅ Target test ran on line 8");
expect(2).toBe(2);
});
test("test 3 - should NOT run", () => {
console.log("❌ Test 3 ran - this should not happen!");
expect.unreachable("Test 3 should not run");
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "test.test.ts:8"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
expect(stdout).toContain("✅ Target test ran on line 8");
expect(stdout).not.toContain("❌ Test 1 ran");
expect(stdout).not.toContain("❌ Test 3 ran");
expect(stdout).toMatch(/1 pass/);
});
test("runs tests in describe block by line number", async () => {
using dir = tempDir("test-line-filter-describe", {
"test.test.ts": `
import { test, expect, describe } from "bun:test";
describe("outer", () => {
test("outer test 1", () => {
console.log("❌ Outer 1 should not run");
expect.unreachable();
});
describe("nested group", () => {
test("nested test 1", () => {
console.log("❌ Nested 1 should not run");
expect.unreachable();
});
test("nested target", () => {
console.log("✅ Nested target on line 16");
expect(true).toBe(true);
});
test("nested test 3", () => {
console.log("❌ Nested 3 should not run");
expect.unreachable();
});
});
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "test.test.ts:9"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// When targeting describe block, all tests within should run
expect(stdout).not.toContain("❌ Outer 1");
expect(stdout).toContain("✅ Nested target");
expect(stdout).toMatch(/\d+ pass/);
});
test("handles multiple file:line arguments", async () => {
using dir = tempDir("test-line-filter-multi", {
"test.test.ts": `
import { test, expect } from "bun:test";
test("test 1", () => {
console.log("✅ Test 1 on line 3");
expect(1).toBe(1);
});
test("test 2", () => {
console.log("❌ Test 2 should not run");
expect.unreachable();
});
test("test 3", () => {
console.log("✅ Test 3 on line 13");
expect(3).toBe(3);
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "test.test.ts:3", "test.test.ts:13"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
expect(stdout).toContain("✅ Test 1 on line 3");
expect(stdout).not.toContain("❌ Test 2 should not run");
expect(stdout).toContain("✅ Test 3 on line 13");
expect(stdout).toMatch(/2 pass/);
});
test("handles file:line:column syntax (ignores column)", async () => {
using dir = tempDir("test-line-filter-column", {
"test.test.ts": `
import { test, expect } from "bun:test";
test("test 1", () => {
console.log("❌ Test 1 should not run");
expect.unreachable();
});
test("target test", () => {
console.log("✅ Target test on line 8");
expect(2).toBe(2);
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "test.test.ts:8:15"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
expect(stdout).toContain("✅ Target test on line 8");
expect(stdout).not.toContain("❌ Test 1 should not run");
expect(stdout).toMatch(/1 pass/);
});
test("works with test.each", async () => {
using dir = tempDir("test-line-filter-each", {
"test.test.ts": `
import { test, expect } from "bun:test";
test("normal test", () => {
console.log("❌ Normal test should not run");
expect.unreachable();
});
test.each([[1, 2, 3], [4, 5, 9]])("adds %i + %i = %i", (a, b, expected) => {
console.log(\`✅ Testing \${a} + \${b} = \${expected}\`);
expect(a + b).toBe(expected);
});
test("another test", () => {
console.log("❌ Another test should not run");
expect.unreachable();
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "test.test.ts:8"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
expect(stdout).toContain("✅ Testing");
expect(stdout).not.toContain("❌ Normal test");
expect(stdout).not.toContain("❌ Another test");
// Should run all iterations of test.each
expect(stdout).toMatch(/2 pass/);
});
});