diff --git a/src/bun.js/bindings/JSValue.zig b/src/bun.js/bindings/JSValue.zig index 9a208b2d4b..9ae0f5274b 100644 --- a/src/bun.js/bindings/JSValue.zig +++ b/src/bun.js/bindings/JSValue.zig @@ -541,6 +541,51 @@ pub const JSValue = enum(i64) { } pub fn jestSnapshotPrettyFormat(this: JSValue, out: *MutableString, globalObject: *JSGlobalObject) !void { + // Check for custom snapshot serializers + if (Jest.runner) |runner| { + for (runner.snapshots.serializers.items) |serializer| { + // Call the test function to check if this serializer applies + const test_fn = (try serializer.getIfPropertyExists(globalObject, "test")) orelse continue; + + var test_result = test_fn.callWithGlobalThis(globalObject, &[_]JSValue{this}) catch continue; + + // If test returns truthy, use this serializer + if (test_result.toBoolean()) { + // Try print first (modern Jest API) + if (try serializer.getIfPropertyExists(globalObject, "print")) |print_fn| { + // Call print(val) with just the value + // Note: Jest's print() can optionally receive a second argument (printer function) + // but many serializers work fine with just the value + const result = print_fn.callWithGlobalThis(globalObject, &[_]JSValue{this}) catch { + // If print fails, fall through to default formatting + break; + }; + + if (result.isString()) { + const result_str = try result.toBunString(globalObject); + defer result_str.deref(); + try out.append(result_str.toUTF8(bun.default_allocator).slice()); + return; + } + } else if (try serializer.getIfPropertyExists(globalObject, "serialize")) |serialize_fn| { + // Try serialize (legacy Jest API) - call it with just the value + const result = serialize_fn.callWithGlobalThis(globalObject, &[_]JSValue{this}) catch { + // If serialize fails, fall through to default formatting + break; + }; + + if (result.isString()) { + const result_str = try result.toBunString(globalObject); + defer result_str.deref(); + try out.append(result_str.toUTF8(bun.default_allocator).slice()); + return; + } + } + } + } + } + + // Fall back to default formatting var buffered_writer = MutableString.BufferedWriter{ .context = out }; const writer = buffered_writer.writer(); const Writer = @TypeOf(writer); @@ -2413,6 +2458,7 @@ const string = []const u8; const FFI = @import("./FFI.zig"); const std = @import("std"); const JestPrettyFormat = @import("../test/pretty_format.zig").JestPrettyFormat; +const Jest = @import("../test/jest.zig").Jest; const bun = @import("bun"); const Environment = bun.Environment; diff --git a/src/bun.js/test/expect.zig b/src/bun.js/test/expect.zig index 8a1326a11c..e8dade9ede 100644 --- a/src/bun.js/test/expect.zig +++ b/src/bun.js/test/expect.zig @@ -1157,7 +1157,46 @@ pub const Expect = struct { return thisValue; } - pub const addSnapshotSerializer = notImplementedStaticFn; + /// Implements `expect.addSnapshotSerializer(serializer)` + /// https://jestjs.io/docs/expect#expectaddsnapshotserializerserializer + pub fn addSnapshotSerializer(globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue { + const args = callFrame.arguments_old(1).slice(); + + if (args.len == 0 or !args[0].isObject()) { + return globalThis.throwPretty("expect.addSnapshotSerializer(serializer)\n\nExpected an object with 'test' and 'serialize' or 'print' properties\n", .{}); + } + + const serializer = args[0]; + + // Validate that serializer has a 'test' function + const test_fn = try serializer.getIfPropertyExists(globalThis, "test") orelse { + return globalThis.throwPretty("expect.addSnapshotSerializer(serializer)\n\nExpected serializer to have a 'test' function\n", .{}); + }; + + if (!test_fn.jsType().isFunction()) { + return globalThis.throwPretty("expect.addSnapshotSerializer(serializer)\n\nExpected serializer.test to be a function\n", .{}); + } + + // Validate that serializer has either 'serialize' or 'print' function + const serialize_fn = try serializer.getIfPropertyExists(globalThis, "serialize"); + const print_fn = try serializer.getIfPropertyExists(globalThis, "print"); + + if ((serialize_fn == null and print_fn == null) or + (serialize_fn != null and !serialize_fn.?.jsType().isFunction()) or + (print_fn != null and !print_fn.?.jsType().isFunction())) + { + return globalThis.throwPretty("expect.addSnapshotSerializer(serializer)\n\nExpected serializer to have either 'serialize' or 'print' function\n", .{}); + } + + // Add serializer to the list (prepend so latest serializers are checked first, matching Jest behavior) + if (Jest.runner) |runner| { + try runner.snapshots.serializers.insert(0, serializer); + } + + globalThis.bunVM().autoGarbageCollect(); + + return .js_undefined; + } pub fn hasAssertions(globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue { _ = callFrame; diff --git a/src/bun.js/test/snapshot.zig b/src/bun.js/test/snapshot.zig index e3dac38abf..990c4d7d49 100644 --- a/src/bun.js/test/snapshot.zig +++ b/src/bun.js/test/snapshot.zig @@ -16,6 +16,7 @@ pub const Snapshots = struct { _current_file: ?File = null, snapshot_dir_path: ?string = null, inline_snapshots_to_write: *std.AutoArrayHashMap(TestRunner.File.ID, std.ArrayList(InlineSnapshotToWrite)), + serializers: std.ArrayList(JSValue), pub const InlineSnapshotToWrite = struct { line: c_ulong, @@ -565,3 +566,4 @@ const strings = bun.strings; const jsc = bun.jsc; const VirtualMachine = jsc.VirtualMachine; +const JSValue = jsc.JSValue; diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index 6466f14b88..731c63082e 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -1349,6 +1349,7 @@ pub const TestCommand = struct { .values = &snapshot_values, .counts = &snapshot_counts, .inline_snapshots_to_write = &inline_snapshots_to_write, + .serializers = std.ArrayList(jsc.JSValue).init(ctx.allocator), }, .bun_test_root = .init(ctx.allocator), }, diff --git a/test/js/bun/test/snapshot-serializer.test.ts b/test/js/bun/test/snapshot-serializer.test.ts new file mode 100644 index 0000000000..282a37bf5d --- /dev/null +++ b/test/js/bun/test/snapshot-serializer.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +describe("expect.addSnapshotSerializer", () => { + test("should serialize custom objects with print function", async () => { + using dir = tempDir("snapshot-serializer-print", { + "test.test.js": ` +import { test, expect } from "bun:test"; + +class Point { + constructor(x, y) { + this.x = x; + this.y = y; + } +} + +expect.addSnapshotSerializer({ + test(val) { + return val instanceof Point; + }, + print(val) { + return \`Point(\${val.x}, \${val.y})\`; + } +}); + +test("snapshot with custom serializer", () => { + const point = new Point(10, 20); + expect(point).toMatchSnapshot(); +}); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "test.test.js", "--update-snapshots"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("panic"); + expect(exitCode).toBe(0); + + // Check that snapshot file was created with custom serialization + const snapshotContent = await Bun.file(`${dir}/__snapshots__/test.test.js.snap`).text(); + expect(snapshotContent).toContain("Point(10, 20)"); + }); + + test("should serialize custom objects with serialize function", async () => { + using dir = tempDir("snapshot-serializer-serialize", { + "test.test.js": ` +import { test, expect } from "bun:test"; + +class Vector { + constructor(x, y, z) { + this.x = x; + this.y = y; + this.z = z; + } +} + +expect.addSnapshotSerializer({ + test(val) { + return val instanceof Vector; + }, + serialize(val) { + return \`\`; + } +}); + +test("snapshot with serialize function", () => { + const vec = new Vector(1, 2, 3); + expect(vec).toMatchSnapshot(); +}); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "test.test.js", "--update-snapshots"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("panic"); + expect(exitCode).toBe(0); + + const snapshotContent = await Bun.file(`${dir}/__snapshots__/test.test.js.snap`).text(); + expect(snapshotContent).toContain(""); + }); + + test("should use most recently added serializer first", async () => { + using dir = tempDir("snapshot-serializer-order", { + "test.test.js": ` +import { test, expect } from "bun:test"; + +class MyClass { + constructor(value) { + this.value = value; + } +} + +expect.addSnapshotSerializer({ + test(val) { + return val instanceof MyClass; + }, + print(val) { + return \`FirstSerializer(\${val.value})\`; + } +}); + +expect.addSnapshotSerializer({ + test(val) { + return val instanceof MyClass; + }, + print(val) { + return \`SecondSerializer(\${val.value})\`; + } +}); + +test("uses most recent serializer", () => { + const obj = new MyClass("test"); + expect(obj).toMatchSnapshot(); +}); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "test.test.js", "--update-snapshots"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("panic"); + expect(exitCode).toBe(0); + + const snapshotContent = await Bun.file(`${dir}/__snapshots__/test.test.js.snap`).text(); + expect(snapshotContent).toContain("SecondSerializer(test)"); + expect(snapshotContent).not.toContain("FirstSerializer"); + }); + + test("should fall back to default formatting if test returns false", async () => { + using dir = tempDir("snapshot-serializer-fallback", { + "test.test.js": ` +import { test, expect } from "bun:test"; + +expect.addSnapshotSerializer({ + test(val) { + return false; // Never matches + }, + print(val) { + return "SHOULD_NOT_APPEAR"; + } +}); + +test("uses default formatter", () => { + expect({ x: 1, y: 2 }).toMatchSnapshot(); +}); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "test.test.js", "--update-snapshots"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("panic"); + expect(exitCode).toBe(0); + + const snapshotContent = await Bun.file(`${dir}/__snapshots__/test.test.js.snap`).text(); + expect(snapshotContent).not.toContain("SHOULD_NOT_APPEAR"); + expect(snapshotContent).toContain("x"); + expect(snapshotContent).toContain("y"); + }); + + test("should throw error if serializer is not an object", async () => { + using dir = tempDir("snapshot-serializer-invalid-object", { + "test.test.js": ` +import { test, expect } from "bun:test"; + +try { + expect.addSnapshotSerializer("not an object"); + console.log("FAIL: Should have thrown"); +} catch (e) { + console.log("PASS: Threw error"); +} +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.test.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("PASS: Threw error"); + }); + + test("should throw error if serializer missing test function", async () => { + using dir = tempDir("snapshot-serializer-no-test", { + "test.test.js": ` +import { test, expect } from "bun:test"; + +try { + expect.addSnapshotSerializer({ + print(val) { + return String(val); + } + }); + console.log("FAIL: Should have thrown"); +} catch (e) { + console.log("PASS: Threw error"); +} +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.test.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("PASS: Threw error"); + }); + + test("should throw error if serializer missing print/serialize function", async () => { + using dir = tempDir("snapshot-serializer-no-print", { + "test.test.js": ` +import { test, expect } from "bun:test"; + +try { + expect.addSnapshotSerializer({ + test(val) { + return true; + } + }); + console.log("FAIL: Should have thrown"); +} catch (e) { + console.log("PASS: Threw error"); +} +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.test.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("PASS: Threw error"); + }); + + test("should work with inline snapshots", async () => { + using dir = tempDir("snapshot-serializer-inline", { + "test.test.js": ` +import { test, expect } from "bun:test"; + +class Color { + constructor(r, g, b) { + this.r = r; + this.g = g; + this.b = b; + } +} + +expect.addSnapshotSerializer({ + test(val) { + return val instanceof Color; + }, + print(val) { + return \`rgb(\${val.r}, \${val.g}, \${val.b})\`; + } +}); + +test("inline snapshot with serializer", () => { + const color = new Color(255, 128, 0); + expect(color).toMatchInlineSnapshot(); +}); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "test.test.js", "--update-snapshots"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("panic"); + expect(exitCode).toBe(0); + + // Check that the test file was updated with the inline snapshot + const testContent = await Bun.file(`${dir}/test.test.js`).text(); + expect(testContent).toContain("rgb(255, 128, 0)"); + }); + + test("should serialize top-level custom object", async () => { + using dir = tempDir("snapshot-serializer-toplevel", { + "test.test.js": ` +import { test, expect } from "bun:test"; + +class Container { + constructor(items) { + this.items = items; + } +} + +expect.addSnapshotSerializer({ + test(val) { + return val instanceof Container; + }, + print(val) { + return \`Container[\${val.items.length} items]\`; + } +}); + +test("top-level custom serializer", () => { + const container = new Container([1, 2, 3]); + expect(container).toMatchSnapshot(); +}); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "test.test.js", "--update-snapshots"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("panic"); + expect(exitCode).toBe(0); + + const snapshotContent = await Bun.file(`${dir}/__snapshots__/test.test.js.snap`).text(); + expect(snapshotContent).toContain("Container[3 items]"); + }); +});