Compare commits

...

33 Commits

Author SHA1 Message Date
pfg
85b556b9af Merge remote-tracking branch 'origin/main' into pfg/add-snapshot-serializer 2025-12-01 12:23:58 -08:00
pfg
d78d473e0f addForNewLine str len & comment fix 2025-11-24 16:14:51 -08:00
pfg
0c14fc2ba2 simplify serializers impl 2 2025-11-24 15:44:24 -08:00
pfg
58e4488a58 simplify serializers impl 1 2025-11-24 15:43:39 -08:00
pfg
bbc263d488 only initialize snapshotSerializersSerializeFunction once 2025-11-21 15:28:02 -08:00
pfg
20fd605fbb Merge remote-tracking branch 'origin/main' into pfg/add-snapshot-serializer 2025-11-21 14:40:57 -08:00
pfg
63d16cee82 update types for inference with snapshot serializer 2025-11-21 14:40:50 -08:00
pfg
11ccf7e545 serialize implemented in js 2025-11-21 13:51:55 -08:00
pfg
dc3a410963 Merge remote-tracking branch 'origin/main' into pfg/add-snapshot-serializer 2025-11-19 16:44:58 -08:00
pfg
efdb2e8769 fixes bindv2 usage 2025-11-19 12:51:06 -08:00
pfg
3bfdc4b077 return fixes 2025-11-18 21:00:27 -08:00
pfg
6e02340883 types wip 2025-11-18 20:15:57 -08:00
pfg
8b583c8b5f test simplification? 2025-11-18 20:09:23 -08:00
pfg
5ec3734d14 snapshot-serializers test 2025-11-18 20:07:26 -08:00
pfg
e55ebfdbd0 flatten 2025-11-18 18:44:21 -08:00
pfg
52fbeee121 exception check validation fix 2025-11-18 18:39:11 -08:00
pfg
4ee10fd1b5 comment fix 2025-11-18 17:33:00 -08:00
pfg
f127a74aa5 fix 2025-11-18 17:18:53 -08:00
pfg
b9fc1261f8 error handling fixes 2025-11-18 17:16:04 -08:00
autofix-ci[bot]
a6e55ccd55 [autofix.ci] apply automated fixes 2025-11-19 01:01:41 +00:00
pfg
f381f94ca5 wip.10 2025-11-18 14:31:07 -08:00
pfg
62c78b46f2 wip.9 2025-11-18 14:24:11 -08:00
pfg
2a8b5739ff remove unused header entries 2025-11-17 20:19:05 -08:00
pfg
b4e7d5ee69 fix throwing in matchAndFmtSnapshot 2025-11-17 20:17:06 -08:00
pfg
0cfd29fcf3 wip.8 2025-11-17 20:06:12 -08:00
pfg
2b81198922 error cases 2025-11-17 20:02:19 -08:00
pfg
198f21aab6 wip.7 2025-11-17 19:59:44 -08:00
pfg
86436f51de wip.6 2025-11-17 19:49:32 -08:00
pfg
ed877e38e1 wip.5 2025-11-17 19:49:20 -08:00
pfg
b980e26fe7 wip.4 2025-11-17 19:44:58 -08:00
pfg
5556d2781e wip.3 2025-11-17 19:36:33 -08:00
pfg
67bdd10889 wip.2 2025-11-17 19:31:04 -08:00
pfg
ba2c6ca29d wip.1 2025-11-17 18:59:56 -08:00
14 changed files with 691 additions and 12 deletions

View File

@@ -697,8 +697,56 @@ declare module "bun:test" {
* Ensures that a specific number of assertions are made
*/
assertions(neededAssertions: number): void;
/**
* Add a custom snapshot serializer to customize how values are formatted in snapshots.
*
* @example
* class Point {
* constructor(public x: number, public y: number) {}
* }
*
* expect.addSnapshotSerializer({
* test: (val) => val instanceof Point,
* serialize: (val) => `Point(${val.x}, ${val.y})`,
* });
*
* expect(new Point(1, 2)).toMatchInlineSnapshot(`Point(1, 2)`);
*
* @param serializer The snapshot serializer configuration
*/
addSnapshotSerializer<T>(serializer: SnapshotSerializer<T>): void;
}
export type SnapshotSerializer<T> =
| {
/**
* Test function to determine if this serializer should be used for a value
*/
test(val: unknown): val is T;
/**
* Serialize function to convert the value to a string.
*/
serialize: (val: T) => string;
}
| {
/**
* Test function to determine if this serializer should be used for a value
*/
test: (val: unknown) => boolean;
/**
* Serialize function to convert the value to a string.
*/
serialize: (val: T) => string;
}
| {
test: (val: unknown) => boolean;
/**
* @deprecated Pass `serialize` instead of `print`.
*/
print: (val: T) => string;
};
/**
* You can extend this interface with declaration merging, in order to add type support for custom matchers.
* @template T Type of the actual value

View File

@@ -0,0 +1,226 @@
#include "root.h"
#include "SnapshotSerializers.h"
#include <JavaScriptCore/JSArray.h>
#include <JavaScriptCore/JSCJSValueInlines.h>
#include <JavaScriptCore/ObjectConstructor.h>
#include <JavaScriptCore/JSCInlines.h>
#include <JavaScriptCore/Exception.h>
#include <JavaScriptCore/JSFunction.h>
#include "ErrorCode.h"
#include "WebCoreJSBuiltins.h"
namespace Bun {
using namespace JSC;
using namespace WebCore;
const ClassInfo SnapshotSerializers::s_info = { "SnapshotSerializers"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(SnapshotSerializers) };
SnapshotSerializers::SnapshotSerializers(VM& vm, Structure* structure)
: Base(vm, structure)
{
}
void SnapshotSerializers::finishCreation(VM& vm)
{
Base::finishCreation(vm);
}
JSArray* SnapshotSerializers::getTestCallbacks(JSGlobalObject* globalObject) const
{
auto& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
JSArray* val = m_testCallbacks.get();
if (!val) {
val = JSC::constructEmptyArray(globalObject, nullptr, 0);
RETURN_IF_EXCEPTION(scope, {});
m_testCallbacks.set(vm, this, val);
}
return val;
}
JSArray* SnapshotSerializers::getSerializeCallbacks(JSGlobalObject* globalObject) const
{
auto& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
JSArray* val = m_serializeCallbacks.get();
if (!val) {
val = JSC::constructEmptyArray(globalObject, nullptr, 0);
RETURN_IF_EXCEPTION(scope, {});
m_serializeCallbacks.set(vm, this, val);
}
return val;
}
template<typename Visitor>
void SnapshotSerializers::visitChildrenImpl(JSCell* cell, Visitor& visitor)
{
SnapshotSerializers* thisObject = jsCast<SnapshotSerializers*>(cell);
ASSERT_GC_OBJECT_INHERITS(thisObject, info());
Base::visitChildren(thisObject, visitor);
visitor.append(thisObject->m_testCallbacks);
visitor.append(thisObject->m_serializeCallbacks);
}
DEFINE_VISIT_CHILDREN(SnapshotSerializers);
SnapshotSerializers* SnapshotSerializers::create(VM& vm, Structure* structure)
{
SnapshotSerializers* serializers = new (NotNull, allocateCell<SnapshotSerializers>(vm)) SnapshotSerializers(vm, structure);
serializers->finishCreation(vm);
return serializers;
}
void SnapshotSerializers::addSerializer(JSGlobalObject* globalObject, JSValue testCallback, JSValue serializeCallback)
{
auto& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
// Check for re-entrancy
if (m_isExecuting) {
throwTypeError(globalObject, scope, "Cannot add snapshot serializer from within a test or serialize callback"_s);
RELEASE_AND_RETURN(scope, );
}
// Validate that both callbacks are callable
if (!testCallback.isCallable()) {
throwTypeError(globalObject, scope, "Snapshot serializer test callback must be a function"_s);
RELEASE_AND_RETURN(scope, );
}
if (!serializeCallback.isCallable()) {
throwTypeError(globalObject, scope, "Snapshot serializer serialize callback must be a function"_s);
RELEASE_AND_RETURN(scope, );
}
// Get the arrays (lazily initialized)
JSArray* testCallbacks = getTestCallbacks(globalObject);
RETURN_IF_EXCEPTION(scope, );
JSArray* serializeCallbacks = getSerializeCallbacks(globalObject);
RETURN_IF_EXCEPTION(scope, );
// Add to the end of the arrays (most recent last, we'll iterate in reverse)
testCallbacks->push(globalObject, testCallback);
RETURN_IF_EXCEPTION(scope, );
serializeCallbacks->push(globalObject, serializeCallback);
RETURN_IF_EXCEPTION(scope, );
}
JSValue SnapshotSerializers::serialize(JSGlobalObject* globalObject, JSValue value)
{
auto& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
// If no serializers are registered, return undefined
if (!m_testCallbacks.get() || m_testCallbacks.get()->length() == 0) {
return jsUndefined();
}
// Check for re-entrancy
if (m_isExecuting) {
throwTypeError(globalObject, scope, "Cannot serialize from within a test or serialize callback"_s);
RELEASE_AND_RETURN(scope, {});
}
// RAII guard to manage m_isExecuting flag
class ExecutionGuard {
public:
ExecutionGuard(bool& flag)
: m_flag(flag)
{
m_flag = true;
}
~ExecutionGuard() { m_flag = false; }
private:
bool& m_flag;
};
ExecutionGuard guard(m_isExecuting);
JSArray* testCallbacks = getTestCallbacks(globalObject);
RETURN_IF_EXCEPTION(scope, {});
JSArray* serializeCallbacks = getSerializeCallbacks(globalObject);
RETURN_IF_EXCEPTION(scope, {});
// Use JavaScript builtin for iteration to avoid deoptimization at boundaries
// Get the cached function from the global object
JSFunction* serializeBuiltin = jsCast<Zig::GlobalObject*>(globalObject)->snapshotSerializersSerializeFunction();
MarkedArgumentBuffer args;
args.append(testCallbacks);
args.append(serializeCallbacks);
args.append(value);
ASSERT(!args.hasOverflowed());
JSValue result = call(globalObject, serializeBuiltin, args, "snapshotSerializersSerialize"_s);
RETURN_IF_EXCEPTION(scope, {});
return result;
}
} // namespace Bun
using namespace Bun;
using namespace JSC;
// Zig-exported functions
extern "C" [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue SnapshotSerializers__create(Zig::GlobalObject* globalObject)
{
auto& vm = globalObject->vm();
auto* structure = globalObject->SnapshotSerializersStructure();
auto* serializers = SnapshotSerializers::create(vm, structure);
return JSValue::encode(serializers);
}
extern "C" [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue SnapshotSerializers__add(
Zig::GlobalObject* globalObject,
JSC::EncodedJSValue encodedSerializers,
JSC::EncodedJSValue encodedTestCallback,
JSC::EncodedJSValue encodedSerializeCallback)
{
auto& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
JSValue serializersValue = JSValue::decode(encodedSerializers);
SnapshotSerializers* serializers = jsDynamicCast<SnapshotSerializers*>(serializersValue);
if (!serializers) {
throwTypeError(globalObject, scope, "Invalid SnapshotSerializers object"_s);
RELEASE_AND_RETURN(scope, {});
}
JSValue testCallback = JSValue::decode(encodedTestCallback);
JSValue serializeCallback = JSValue::decode(encodedSerializeCallback);
serializers->addSerializer(globalObject, testCallback, serializeCallback);
RETURN_IF_EXCEPTION(scope, {});
return JSValue::encode(jsUndefined());
}
extern "C" [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue SnapshotSerializers__serialize(
Zig::GlobalObject* globalObject,
JSC::EncodedJSValue encodedSerializers,
JSC::EncodedJSValue encodedValue)
{
auto& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
JSValue serializersValue = JSValue::decode(encodedSerializers);
SnapshotSerializers* serializers = jsDynamicCast<SnapshotSerializers*>(serializersValue);
if (!serializers) {
throwTypeError(globalObject, scope, "Invalid SnapshotSerializers object"_s);
RELEASE_AND_RETURN(scope, {});
}
JSValue value = JSValue::decode(encodedValue);
JSValue result = serializers->serialize(globalObject, value);
RETURN_IF_EXCEPTION(scope, {});
return JSValue::encode(result);
}

View File

@@ -0,0 +1,64 @@
#pragma once
#include "root.h"
#include <JavaScriptCore/JSDestructibleObject.h>
#include <JavaScriptCore/JSArray.h>
#include <JavaScriptCore/WriteBarrier.h>
#include "ZigGlobalObject.h"
namespace Bun {
class SnapshotSerializers final : public JSC::JSDestructibleObject {
public:
using Base = JSC::JSDestructibleObject;
static constexpr unsigned StructureFlags = Base::StructureFlags;
static SnapshotSerializers* create(JSC::VM& vm, JSC::Structure* structure);
static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype)
{
return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info());
}
template<typename MyClassT, JSC::SubspaceAccess mode>
static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm)
{
if constexpr (mode == JSC::SubspaceAccess::Concurrently)
return nullptr;
return WebCore::subspaceForImpl<MyClassT, WebCore::UseCustomHeapCellType::No>(
vm,
[](auto& spaces) { return spaces.m_clientSubspaceForSnapshotSerializers.get(); },
[](auto& spaces, auto&& space) { spaces.m_clientSubspaceForSnapshotSerializers = std::forward<decltype(space)>(space); },
[](auto& spaces) { return spaces.m_subspaceForSnapshotSerializers.get(); },
[](auto& spaces, auto&& space) { spaces.m_subspaceForSnapshotSerializers = std::forward<decltype(space)>(space); });
}
DECLARE_INFO;
DECLARE_VISIT_CHILDREN;
// Add a new snapshot serializer
// Throws TypeError if callbacks are not callable or if called re-entrantly
void addSerializer(JSC::JSGlobalObject* globalObject, JSC::JSValue testCallback, JSC::JSValue serializeCallback);
// Test a value and serialize if a matching serializer is found
// Returns the serialized string or null
JSC::JSValue serialize(JSC::JSGlobalObject* globalObject, JSC::JSValue value);
private:
SnapshotSerializers(JSC::VM& vm, JSC::Structure* structure);
void finishCreation(JSC::VM& vm);
// Lazy getters for arrays (following JSMockFunction pattern)
JSC::JSArray* getTestCallbacks(JSC::JSGlobalObject* globalObject) const;
JSC::JSArray* getSerializeCallbacks(JSC::JSGlobalObject* globalObject) const;
// Arrays store serializers with most recent last; iterated in reverse order
mutable JSC::WriteBarrier<JSC::JSArray> m_testCallbacks;
mutable JSC::WriteBarrier<JSC::JSArray> m_serializeCallbacks;
// Re-entrancy guard
bool m_isExecuting { false };
};
} // namespace Bun

View File

@@ -124,6 +124,7 @@
#include "JSSink.h"
#include "JSSocketAddressDTO.h"
#include "JSSQLStatement.h"
#include "SnapshotSerializers.h"
#include "JSStringDecoder.h"
#include "JSTextEncoder.h"
#include "JSTextEncoderStream.h"
@@ -2288,6 +2289,10 @@ void GlobalObject::finishCreation(VM& vm)
init.set(JSC::JSFunction::create(init.vm, init.owner, WebCore::ipcSerializeCodeGenerator(init.vm), init.owner));
});
m_snapshotSerializersSerializeFunction.initLater([](const LazyProperty<JSC::JSGlobalObject, JSC::JSFunction>::Initializer& init) {
init.set(JSC::JSFunction::create(init.vm, init.owner, WebCore::snapshotSerializersSerializeCodeGenerator(init.vm), init.owner));
});
m_JSFileSinkClassStructure.initLater(
[](LazyClassStructure::Initializer& init) {
auto* prototype = createJSSinkPrototype(init.vm, init.global, WebCore::SinkID::FileSink);
@@ -2366,6 +2371,12 @@ void GlobalObject::finishCreation(VM& vm)
init.setConstructor(constructor);
});
m_SnapshotSerializersStructure.initLater(
[](LazyClassStructure::Initializer& init) {
auto* structure = Bun::SnapshotSerializers::createStructure(init.vm, init.global, jsNull());
init.setStructure(structure);
});
m_JSBufferListClassStructure.initLater(
[](LazyClassStructure::Initializer& init) {
auto* prototype = JSBufferListPrototype::create(

View File

@@ -201,6 +201,7 @@ public:
WebCore::JSBuiltinInternalFunctions& builtinInternalFunctions() { return m_builtinInternalFunctions; }
JSC::Structure* FFIFunctionStructure() const { return m_JSFFIFunctionStructure.getInitializedOnMainThread(this); }
JSC::Structure* NapiClassStructure() const { return m_NapiClassStructure.getInitializedOnMainThread(this); }
JSC::Structure* SnapshotSerializersStructure() const { return m_SnapshotSerializersStructure.getInitializedOnMainThread(this); }
JSC::Structure* FileSinkStructure() const { return m_JSFileSinkClassStructure.getInitializedOnMainThread(this); }
JSC::JSObject* FileSink() const { return m_JSFileSinkClassStructure.constructorInitializedOnMainThread(this); }
@@ -277,6 +278,8 @@ public:
JSC::JSFunction* wasmStreamingConsumeStreamFunction() const { return m_wasmStreamingConsumeStreamFunction.getInitializedOnMainThread(this); }
JSC::JSFunction* snapshotSerializersSerializeFunction() const { return m_snapshotSerializersSerializeFunction.getInitializedOnMainThread(this); }
JSObject* requireFunctionUnbound() const { return m_requireFunctionUnbound.getInitializedOnMainThread(this); }
JSObject* requireResolveFunctionUnbound() const { return m_requireResolveFunctionUnbound.getInitializedOnMainThread(this); }
Bun::InternalModuleRegistry* internalModuleRegistry() const { return m_internalModuleRegistry.getInitializedOnMainThread(this); }
@@ -528,6 +531,7 @@ public:
V(private, LazyClassStructure, m_JSBufferListClassStructure) \
V(private, LazyClassStructure, m_JSFFIFunctionStructure) \
V(private, LazyClassStructure, m_JSFileSinkClassStructure) \
V(private, LazyClassStructure, m_SnapshotSerializersStructure) \
V(private, LazyClassStructure, m_JSHTTPResponseSinkClassStructure) \
V(private, LazyClassStructure, m_JSHTTPSResponseSinkClassStructure) \
V(private, LazyClassStructure, m_JSNetworkSinkClassStructure) \
@@ -631,7 +635,8 @@ public:
V(public, LazyPropertyOfGlobalObject<Symbol>, m_nodeVMDontContextify) \
V(public, LazyPropertyOfGlobalObject<Symbol>, m_nodeVMUseMainContextDefaultLoader) \
V(public, LazyPropertyOfGlobalObject<JSFunction>, m_ipcSerializeFunction) \
V(public, LazyPropertyOfGlobalObject<JSFunction>, m_ipcParseHandleFunction)
V(public, LazyPropertyOfGlobalObject<JSFunction>, m_ipcParseHandleFunction) \
V(private, LazyPropertyOfGlobalObject<JSFunction>, m_snapshotSerializersSerializeFunction)
#define DECLARE_GLOBALOBJECT_GC_MEMBER(visibility, T, name) \
visibility: \

View File

@@ -47,6 +47,7 @@ public:
std::unique_ptr<GCClient::IsoSubspace> m_clientSubspaceForJSMockFunction;
std::unique_ptr<GCClient::IsoSubspace> m_clientSubspaceForAsyncContextFrame;
std::unique_ptr<GCClient::IsoSubspace> m_clientSubspaceForMockWithImplementationCleanupData;
std::unique_ptr<GCClient::IsoSubspace> m_clientSubspaceForSnapshotSerializers;
std::unique_ptr<GCClient::IsoSubspace> m_clientSubspaceForProcessObject;
std::unique_ptr<GCClient::IsoSubspace> m_clientSubspaceForInternalModuleRegistry;
std::unique_ptr<GCClient::IsoSubspace> m_clientSubspaceForErrorCodeCache;

View File

@@ -47,6 +47,7 @@ public:
std::unique_ptr<IsoSubspace> m_subspaceForJSMockFunction;
std::unique_ptr<IsoSubspace> m_subspaceForAsyncContextFrame;
std::unique_ptr<IsoSubspace> m_subspaceForMockWithImplementationCleanupData;
std::unique_ptr<IsoSubspace> m_subspaceForSnapshotSerializers;
std::unique_ptr<IsoSubspace> m_subspaceForProcessObject;
std::unique_ptr<IsoSubspace> m_subspaceForInternalModuleRegistry;
std::unique_ptr<IsoSubspace> m_subspaceForErrorCodeCache;

View File

@@ -0,0 +1,22 @@
import * as b from "bindgenv2";
export const SnapshotSerializerOptions = b.dictionary(
{
name: "SnapshotSerializerOptions",
generateConversionFunction: true,
},
{
test: {
type: b.RawAny,
internalName: "test_fn",
},
serialize: {
type: b.RawAny,
internalName: "serialize_fn",
},
print: {
type: b.RawAny,
internalName: "print_fn",
},
},
);

View File

@@ -710,7 +710,10 @@ pub const Expect = struct {
var pretty_value = std.Io.Writer.Allocating.init(default_allocator);
defer pretty_value.deinit();
try this.matchAndFmtSnapshot(globalThis, value, property_matchers, &pretty_value.writer, fn_name);
this.matchAndFmtSnapshot(globalThis, value, property_matchers, &pretty_value.writer, fn_name) catch |err| return switch (err) {
error.WriteFailed => error.OutOfMemory,
else => |e| e,
};
var start_indent: ?[]const u8 = null;
var end_indent: ?[]const u8 = null;
@@ -795,7 +798,7 @@ pub const Expect = struct {
return .js_undefined;
}
pub fn matchAndFmtSnapshot(this: *Expect, globalThis: *JSGlobalObject, value: JSValue, property_matchers: ?JSValue, pretty_value: *std.Io.Writer, comptime fn_name: []const u8) bun.JSError!void {
pub fn matchAndFmtSnapshot(this: *Expect, globalThis: *JSGlobalObject, value: JSValue, property_matchers: ?JSValue, pretty_value: *std.Io.Writer, comptime fn_name: []const u8) (bun.JSError || std.Io.Writer.Error)!void {
if (property_matchers) |_prop_matchers| {
if (!value.isObject()) {
const signature = comptime getSignature(fn_name, "<green>properties<r><d>, <r>hint", false);
@@ -816,16 +819,15 @@ pub const Expect = struct {
}
}
value.jestSnapshotPrettyFormat(pretty_value, globalThis) catch {
var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis };
defer formatter.deinit();
return globalThis.throw("Failed to pretty format value: {f}", .{value.toFmt(&formatter)});
};
try value.jestSnapshotPrettyFormat(pretty_value, globalThis);
}
pub fn snapshot(this: *Expect, globalThis: *JSGlobalObject, value: JSValue, property_matchers: ?JSValue, hint: []const u8, comptime fn_name: []const u8) bun.JSError!JSValue {
var pretty_value = std.Io.Writer.Allocating.init(default_allocator);
defer pretty_value.deinit();
try this.matchAndFmtSnapshot(globalThis, value, property_matchers, &pretty_value.writer, fn_name);
this.matchAndFmtSnapshot(globalThis, value, property_matchers, &pretty_value.writer, fn_name) catch |err| return switch (err) {
error.WriteFailed => error.OutOfMemory,
else => |e| e,
};
const existing_value = Jest.runner.?.snapshots.getOrPut(this, pretty_value.written(), hint) catch |err| {
var buntest_strong = this.bunTest() orelse return globalThis.throw("Snapshot matchers cannot be used outside of a test", .{});
@@ -968,6 +970,50 @@ pub const Expect = struct {
return .js_undefined;
}
/// Implements `expect.addSnapshotSerializer({ test, serialize })`
pub fn addSnapshotSerializer(globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue {
const arg = callFrame.argumentsAsArray(1)[0];
const runner = Jest.runner orelse {
return globalThis.throwPretty("<d>expect.<r>addSnapshotSerializer<d>(<r>serializer<d>)<r>\n\nSnapshot serializers can only be added during test execution\n", .{});
};
// Parse options using bindv2
const options = try SnapshotSerializerOptions.fromJS(globalThis, arg);
// Validate test function
if (!options.test_fn.jsType().isFunction()) {
return globalThis.throwPretty("<d>expect.<r>addSnapshotSerializer<d>(<r>serializer<d>)<r>\n\nExpected 'test' to be a function\n", .{});
}
// Get serialize or print function
var serialize_fn_value = options.serialize_fn;
if (serialize_fn_value.isUndefinedOrNull()) {
serialize_fn_value = options.print_fn;
}
if (serialize_fn_value.isUndefinedOrNull()) {
return globalThis.throwPretty("<d>expect.<r>addSnapshotSerializer<d>(<r>serializer<d>)<r>\n\nExpected serializer object to have a 'serialize' or 'print' function\n", .{});
}
if (!serialize_fn_value.jsType().isFunction()) {
return globalThis.throwPretty("<d>expect.<r>addSnapshotSerializer<d>(<r>serializer<d>)<r>\n\nExpected 'serialize' or 'print' to be a function\n", .{});
}
// Get or create the SnapshotSerializers object
const serializers = runner.snapshots.serializers.get() orelse blk: {
// Create a new SnapshotSerializers object
const new_serializers = try bun.cpp.SnapshotSerializers__create(globalThis);
runner.snapshots.serializers.set(globalThis, new_serializers);
break :blk new_serializers;
};
// Add the serializer
_ = try bun.cpp.SnapshotSerializers__add(globalThis, serializers, options.test_fn, serialize_fn_value);
return .js_undefined;
}
const CustomMatcherParamsFormatter = struct {
colors: bool,
globalThis: *JSGlobalObject,
@@ -1172,8 +1218,6 @@ pub const Expect = struct {
return thisValue;
}
pub const addSnapshotSerializer = notImplementedStaticFn;
pub fn hasAssertions(globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue {
_ = callFrame;
defer globalThis.bunVM().autoGarbageCollect();
@@ -2249,8 +2293,12 @@ test "fuzz Expect.trimLeadingWhitespaceForInlineSnapshot" {
const string = []const u8;
pub const Jest = jest.Jest;
const bindgen_generated = @import("bindgen_generated");
const std = @import("std");
const DiffFormatter = @import("./diff_format.zig").DiffFormatter;
const SnapshotSerializerOptions = bindgen_generated.snapshot_serializer_options.SnapshotSerializerOptions;
const bun = @import("bun");
const Environment = bun.Environment;
@@ -2269,5 +2317,4 @@ const ZigString = jsc.ZigString;
const jest = bun.jsc.Jest;
const DescribeScope = jest.DescribeScope;
const Jest = jest.Jest;
const TestRunner = jest.TestRunner;

View File

@@ -869,12 +869,32 @@ pub const JestPrettyFormat = struct {
) bun.JSError!void {
if (this.failed)
return;
var writer = WrappedWriter(Writer){ .ctx = writer_, .estimated_line_length = &this.estimated_line_length };
defer {
if (writer.failed) {
this.failed = true;
}
}
// Try user-defined snapshot serializers first
if (expect.Jest.runner) |runner| {
if (runner.snapshots.serializers.get()) |serializers| {
const result = try bun.cpp.SnapshotSerializers__serialize(this.globalThis, serializers, value);
if (!result.isUndefinedOrNull()) {
if (bun.Environment.ci_assert) bun.assert(result.isString()); // should have thrown in SnapshotSerializers__serialize()
var str = ZigString.Empty;
try result.toZigString(&str, this.globalThis);
this.addForNewLine(str.len);
writer.writeString(str);
return;
}
}
}
if (comptime Format.canHaveCircularReferences()) {
if (this.map_node == null) {
this.map_node = Visited.Pool.get(default_allocator);

View File

@@ -17,6 +17,7 @@ pub const Snapshots = struct {
snapshot_dir_path: ?string = null,
inline_snapshots_to_write: *std.AutoArrayHashMap(TestRunner.File.ID, std.array_list.Managed(InlineSnapshotToWrite)),
last_error_snapshot_name: ?[]const u8 = null,
serializers: jsc.Strong.Optional = .empty,
pub const InlineSnapshotToWrite = struct {
line: c_ulong,

View File

@@ -0,0 +1,31 @@
// Iterates through snapshot serializers and returns the serialized value or null
// This is implemented in JavaScript to avoid deoptimization around JS/C++ boundaries
export function serialize(
testCallbacks: Function[],
serializeCallbacks: Function[],
value: unknown,
): string | undefined {
// Iterate through serializers in reverse order (most recent to least recent)
for (let i = testCallbacks.length - 1; i >= 0; i--) {
const testCallback = testCallbacks[i];
// Call the test function with the value
if (!testCallback(value)) {
continue;
}
// Use this serializer
const serializeCallback = serializeCallbacks[i];
const result = serializeCallback(value);
// Error if the result is not a string
if (typeof result !== "string") {
throw new TypeError("Snapshot serializer serialize callback must return a string");
}
return result;
}
// No matching serializer found
return undefined;
}

View File

@@ -400,3 +400,64 @@ declare const setOfStrings: Set<string>;
/** 1. **/ expect(setOfStrings).toBe(new Set()); // this is inferrable to Set<string> so this should pass
/** 2. **/ expect(setOfStrings).toBe(new Set<string>()); // exact, so we are happy!
/** 3. **/ expect(setOfStrings).toBe<Set<string>>(new Set()); // happy! We opted out of type safety for this expectation
class Point {
constructor(
public x: number,
public y: number,
) {}
}
expect.addSnapshotSerializer({
test: val => val instanceof Point,
serialize: val => {
expectType<Point>(val);
return `Point(${val.x}, ${val.y})`;
},
});
expect.addSnapshotSerializer({
test: val => typeof val === "object" && val !== null && "qwerty" in val,
serialize: val => {
expectType<object & Record<"qwerty", unknown>>(val);
return `{qwerty}`;
},
});
function returnsBoolean(): boolean {
return false;
}
expect.addSnapshotSerializer({
test: () => returnsBoolean(),
serialize: val => {
expectType<unknown>(val);
return `boolean`;
},
});
expect.addSnapshotSerializer({
test: () => false,
print: val => {
expectType<unknown>(val);
return `false`;
},
});
try {
expect.addSnapshotSerializer({
test: () => true,
// @ts-expect-error
serialize: val => {
return;
},
});
expect.addSnapshotSerializer({
// @ts-expect-error
test: () => 25,
});
// @ts-expect-error
expect.addSnapshotSerializer({
test: () => false,
});
} catch (error) {}

View File

@@ -0,0 +1,141 @@
import { expect, test } from "bun:test";
class Point {
constructor(
public x: number,
public y: number,
) {}
}
class Color {
constructor(public name: string) {}
}
class Size {
constructor(
public width: number,
public height: number,
) {}
}
class CustomSerializer {
constructor(public opts: { test: (val: unknown) => boolean; serialize: (val: unknown) => string }) {}
}
// Add serializers at the top level
expect.addSnapshotSerializer({
test: val => val instanceof Point,
serialize: val => `Point(${val.x}, ${val.y})`,
});
expect.addSnapshotSerializer({
test: val => val instanceof Color,
serialize: val => `Color[${val.name}]`,
});
// Add a second Point serializer to test that most recent wins
expect.addSnapshotSerializer({
test: val => val instanceof Point,
serialize: val => `OVERRIDE: Point(${val.x}, ${val.y})`,
});
expect.addSnapshotSerializer({
test: val => val instanceof Size,
print: val => `Size{${val.width}x${val.height}}`,
});
expect.addSnapshotSerializer({
test: val => (val instanceof CustomSerializer ? val.opts.test(val) : false),
serialize: val => val.opts.serialize(val),
});
test("snapshot serializers work for custom formatting", () => {
const color = new Color("red");
expect(color).toMatchInlineSnapshot(`Color[red]`);
});
test("most recently added serializer is used when multiple match", () => {
// The second Point serializer should be used (most recent wins)
const point = new Point(10, 20);
expect(point).toMatchInlineSnapshot(`OVERRIDE: Point(10, 20)`);
});
test("snapshot serializer with 'print' instead of 'serialize'", () => {
const size = new Size(100, 200);
expect(size).toMatchInlineSnapshot(`Size{100x200}`);
});
test("snapshot serializers apply to object fields", () => {
const obj = {
color: new Color("blue"),
size: new Size(640, 480),
};
expect(obj).toMatchInlineSnapshot(`
{
"color": Color[blue],
"size": Size{640x480},
}
`);
});
test("test function throwing error propagates to expect()", () => {
const obj = new CustomSerializer({
test: () => {
throw new Error("Test function error");
},
serialize: () => "test",
});
expect(() => {
expect(obj).toMatchInlineSnapshot();
}).toThrow("Test function error");
});
test("serialize function throwing error propagates to expect()", () => {
const obj = new CustomSerializer({
test: () => true,
serialize: () => {
throw new Error("Serialize function error");
},
});
expect(() => {
expect(obj).toMatchInlineSnapshot();
}).toThrow("Serialize function error");
});
test("serialize function returning non-string throws error", () => {
const obj = new CustomSerializer({
test: () => true,
serialize: () => 123 as unknown as string,
});
expect(() => {
expect(obj).toMatchInlineSnapshot();
}).toThrow("Snapshot serializer serialize callback must return a string");
});
test("cannot add snapshot serializer from within a test callback", () => {
expect(() => {
expect(
new CustomSerializer({
test: () => {
expect.addSnapshotSerializer({ test: () => true, serialize: () => "test" });
return true;
},
serialize: () => "test",
}),
).toMatchInlineSnapshot();
}).toThrow("Cannot add snapshot serializer from within a test or serialize callback");
});
test("cannot add snapshot serializer from within a serialize callback", () => {
expect(() => {
expect(
new CustomSerializer({
test: () => true,
serialize: () => {
expect.addSnapshotSerializer({ test: () => true, serialize: () => "test" });
return "test";
},
}),
).toMatchInlineSnapshot();
}).toThrow("Cannot add snapshot serializer from within a test or serialize callback");
});