mirror of
https://github.com/oven-sh/bun
synced 2026-02-17 14:22:01 +00:00
Compare commits
2 Commits
claude/fix
...
claude/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5a00a647c | ||
|
|
38a7238588 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
203
test/cli/test/test-line-filter.test.ts
Normal file
203
test/cli/test/test-line-filter.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user