From a717679fb32c91c386dcc09194fec30d4368a7de Mon Sep 17 00:00:00 2001 From: Michael H Date: Wed, 16 Jul 2025 18:42:19 +1000 Subject: [PATCH] support `$variables` in `test.each` (#21061) --- src/bun.js/test/jest.zig | 69 ++++++++- test/cli/test/bun-test.test.ts | 256 +++++++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+), 1 deletion(-) diff --git a/src/bun.js/test/jest.zig b/src/bun.js/test/jest.zig index 53e341ed71..4c2649a48d 100644 --- a/src/bun.js/test/jest.zig +++ b/src/bun.js/test/jest.zig @@ -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]) { diff --git a/test/cli/test/bun-test.test.ts b/test/cli/test/bun-test.test.ts index d32b5d320d..b6a78ca41a 100644 --- a/test/cli/test/bun-test.test.ts +++ b/test/cli/test/bun-test.test.ts @@ -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", () => {