Implement expect.addSnapshotSerializer

Add support for custom snapshot serializers via expect.addSnapshotSerializer(),
matching Jest's API. This allows users to define custom formatting for
specific value types in snapshots.

Changes:
- Add serializers registry to Snapshots struct (snapshot.zig)
- Implement addSnapshotSerializer() function (expect.zig)
- Integrate custom serializers into snapshot formatting pipeline (JSValue.zig)
- Initialize serializers array in TestRunner (test_command.zig)
- Add comprehensive tests for snapshot serializer functionality

The implementation checks registered serializers (in LIFO order) before
falling back to default formatting. Serializers can use either the
modern 'print' function or legacy 'serialize' function.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2025-11-01 03:21:27 +00:00
parent a5f8b0e8dd
commit 27163b9acd
5 changed files with 458 additions and 1 deletions

View File

@@ -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;

View File

@@ -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("<d>expect.<r>addSnapshotSerializer<d>(<r>serializer<d>)<r>\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("<d>expect.<r>addSnapshotSerializer<d>(<r>serializer<d>)<r>\n\nExpected serializer to have a 'test' function\n", .{});
};
if (!test_fn.jsType().isFunction()) {
return globalThis.throwPretty("<d>expect.<r>addSnapshotSerializer<d>(<r>serializer<d>)<r>\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("<d>expect.<r>addSnapshotSerializer<d>(<r>serializer<d>)<r>\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;

View File

@@ -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;

View File

@@ -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),
},

View File

@@ -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 \`<Vector x=\${val.x} y=\${val.y} z=\${val.z}>\`;
}
});
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("<Vector x=1 y=2 z=3>");
});
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]");
});
});