support $variables in test.each (#21061)

This commit is contained in:
Michael H
2025-07-16 18:42:19 +10:00
committed by GitHub
parent 36ce7b0203
commit a717679fb3
2 changed files with 324 additions and 1 deletions

View File

@@ -2007,6 +2007,30 @@ fn consumeArg(
args_idx.* += 1;
}
fn resolvePropertyPath(globalThis: *JSGlobalObject, obj: JSValue, path: []const u8) !?JSValue {
var current = obj;
var parts = std.mem.tokenizeScalar(u8, path, '.');
while (parts.next()) |part| {
if (current.isEmptyOrUndefinedOrNull()) return null;
if (std.fmt.parseInt(u32, part, 10)) |index| {
if (current.jsType().isArray()) {
if (index >= try current.getLength(globalThis)) return null;
current = try current.getIndex(globalThis, index);
} else {
return null;
}
} else |_| if (current.isObject()) {
current = try current.get(globalThis, part) orelse return null;
} else {
return null;
}
}
return if (current.isEmptyOrUndefinedOrNull()) null else current;
}
// Generate test label by positionally injecting parameters with printf formatting
fn formatLabel(globalThis: *JSGlobalObject, label: string, function_args: []JSValue, test_idx: usize) !string {
const allocator = bun.default_allocator;
@@ -2016,7 +2040,50 @@ fn formatLabel(globalThis: *JSGlobalObject, label: string, function_args: []JSVa
while (idx < label.len) {
const char = label[idx];
if (char == '%' and (idx + 1 < label.len) and !(args_idx >= function_args.len)) {
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 (std.ascii.isAlphabetic(label[var_end]) or label[var_end] == '_' or label[var_end] == '$') {
var_end += 1;
while (var_end < label.len) {
const c = label[var_end];
if (c == '.') {
const next_char = label[var_end + 1];
if (var_end + 1 < label.len and (std.ascii.isAlphanumeric(next_char) or next_char == '_' or next_char == '$')) {
var_end += 1;
} else {
break;
}
} else if (std.ascii.isAlphanumeric(c) or c == '_' or c == '$') {
var_end += 1;
} else {
break;
}
}
const var_path = label[var_start..var_end];
if (try resolvePropertyPath(globalThis, function_args[0], var_path)) |value| {
var formatter = JSC.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true };
defer formatter.deinit();
const value_str = std.fmt.allocPrint(allocator, "{}", .{value.toFmt(&formatter)}) catch bun.outOfMemory();
defer allocator.free(value_str);
list.appendSlice(allocator, value_str) catch bun.outOfMemory();
idx = var_end;
continue;
}
} else {
while (var_end < label.len and (std.ascii.isAlphanumeric(label[var_end]) or label[var_end] == '_')) {
var_end += 1;
}
}
list.append(allocator, '$') catch bun.outOfMemory();
list.appendSlice(allocator, label[var_start..var_end]) catch bun.outOfMemory();
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]) {

View File

@@ -881,6 +881,262 @@ describe("bun test", () => {
expect(stderr).toContain(`%`);
});
test.todo("check formatting for %p", () => {});
describe("$variable syntax", () => {
test("should replace $variables with object properties in test names", () => {
const cases = [
{ a: 1, b: 2, expected: 3 },
{ a: 5, b: 5, expected: 10 },
{ a: -1, b: 1, expected: 0 },
];
const stderr = runTest({
args: [],
input: `
import { test, expect } from "bun:test";
const cases = ${JSON.stringify(cases)};
test.each(cases)('$a + $b = $expected', ({ a, b, expected }) => {
expect(a + b).toBe(expected);
});
`,
});
expect(stderr).toContain("(pass) 1 + 2 = 3");
expect(stderr).toContain("(pass) 5 + 5 = 10");
expect(stderr).toContain("(pass) -1 + 1 = 0");
expect(stderr).toContain("3 pass");
});
test("should show $variable literal when property doesn't exist", () => {
const cases = [{ a: 1 }, { a: 2 }];
const stderr = runTest({
args: [],
input: `
import { test, expect } from "bun:test";
const cases = ${JSON.stringify(cases)};
test.each(cases)('value $a with missing $nonexistent', ({ a }) => {
expect(a).toBeDefined();
});
`,
});
expect(stderr).toContain("(pass) value 1 with missing $nonexistent");
expect(stderr).toContain("(pass) value 2 with missing $nonexistent");
expect(stderr).toContain("2 pass");
});
test("should work with describe.each", () => {
const cases = [
{ module: "fs", method: "readFile" },
{ module: "path", method: "join" },
];
const stderr = runTest({
args: [],
input: `
import { test, expect, describe } from "bun:test";
const cases = ${JSON.stringify(cases)};
describe.each(cases)('$module module', ({ module, method }) => {
test('has $method', () => {
const mod = require(module);
expect(mod).toHaveProperty(method);
});
});
`,
});
expect(stderr).toContain('"fs" module > has $method');
expect(stderr).toContain('"path" module > has $method');
expect(stderr).toContain("2 pass");
});
test("should work with complex property names", () => {
const cases = [
{ user_name: "john_doe", age: 30, is_active: true },
{ user_name: "jane_smith", age: 25, is_active: false },
];
const stderr = runTest({
args: [],
input: `
import { test, expect } from "bun:test";
const cases = ${JSON.stringify(cases)};
test.each(cases)('user $user_name age $age active $is_active', ({ user_name, age, is_active }) => {
expect(user_name).toBeDefined();
expect(age).toBeGreaterThan(0);
expect(typeof is_active).toBe('boolean');
});
`,
});
expect(stderr).toContain('(pass) user "john_doe" age 30 active true');
expect(stderr).toContain('(pass) user "jane_smith" age 25 active false');
expect(stderr).toContain("2 pass");
});
test("should coexist with % formatting for arrays", () => {
const numbers = [
[1, 2, 3],
[5, 5, 10],
];
const stderr = runTest({
args: [],
input: `
import { test, expect } from "bun:test";
test.each(${JSON.stringify(numbers)})('%i + %i = %i', (a, b, expected) => {
expect(a + b).toBe(expected);
});
`,
});
expect(stderr).toContain("(pass) 1 + 2 = 3");
expect(stderr).toContain("(pass) 5 + 5 = 10");
expect(stderr).toContain("2 pass");
});
test("should support nested property access", () => {
const cases = [
{
user: { name: "Alice", profile: { city: "NYC" } },
expected: "Alice from NYC",
},
{
user: { name: "Bob", profile: { city: "LA" } },
expected: "Bob from LA",
},
];
const stderr = runTest({
args: [],
input: `
import { test, expect } from "bun:test";
const cases = ${JSON.stringify(cases)};
test.each(cases)('$user.name from $user.profile.city', ({ user, expected }) => {
expect(\`\${user.name} from \${user.profile.city}\`).toBe(expected);
});
`,
});
expect(stderr).toContain('(pass) "Alice" from "NYC"');
expect(stderr).toContain('(pass) "Bob" from "LA"');
expect(stderr).toContain("2 pass");
});
test("should support array indexing with dot notation", () => {
const cases = [
{
users: [{ name: "Alice" }, { name: "Bob" }],
first: "Alice",
},
{
users: [{ name: "Carol" }, { name: "Dave" }],
first: "Carol",
},
];
const stderr = runTest({
args: [],
input: `
import { test, expect } from "bun:test";
const cases = ${JSON.stringify(cases)};
test.each(cases)('first user is $users.0.name', ({ users, first }) => {
expect(users[0].name).toBe(first);
});
`,
});
expect(stderr).toContain('(pass) first user is "Alice"');
expect(stderr).toContain('(pass) first user is "Carol"');
expect(stderr).toContain("2 pass");
});
test("handles edge cases with underscores and invalid identifiers", () => {
const cases = [
{
_valid: "underscore",
$dollar: "dollar",
_123mix: "mix",
"123invalid": "invalid",
"has-dash": "dash",
"has space": "space",
},
];
const stderr = runTest({
args: [],
input: `
import { test, expect } from "bun:test";
const cases = ${JSON.stringify(cases)};
test.each(cases)('Edge: $_valid | $$dollar | $_123mix | $123invalid | $has-dash | $has space', (obj) => {
expect(obj).toBeDefined();
});
`,
});
expect(stderr).toContain('"underscore"');
expect(stderr).toContain('"dollar"');
expect(stderr).toContain('"mix"');
expect(stderr).toContain("$123invalid");
expect(stderr).toContain("$hasdash");
expect(stderr).toContain("$hasspace");
});
test("handles deeply nested properties with arrays", () => {
const cases = [
{
data: {
users: [
{ name: "Alice", tags: ["admin", "user"] },
{ name: "Bob", tags: ["user"] },
],
count: 2,
},
},
];
const stderr = runTest({
args: [],
input: `
import { test, expect } from "bun:test";
const cases = ${JSON.stringify(cases)};
test.each(cases)('First user: $data.users.0.name with tag: $data.users.0.tags.0', (obj) => {
expect(obj).toBeDefined();
});
`,
});
expect(stderr).toContain('First user: "Alice" with tag: "admin"');
});
test("handles missing properties gracefully", () => {
const cases = [{ a: 1 }];
const stderr = runTest({
args: [],
input: `
import { test, expect } from "bun:test";
const cases = ${JSON.stringify(cases)};
test.each(cases)('$a | $missing | $a.b.c | $a', ({ a }) => {
expect(a).toBe(1);
});
`,
});
expect(stderr).toContain("1 | $missing| $a.b.c| 1");
});
});
});
test("Prints error when no test matches", () => {