From 1476e4c958b7f586f8a5d25e7344c5466f578498 Mon Sep 17 00:00:00 2001 From: pfg Date: Thu, 5 Dec 2024 19:07:18 -0800 Subject: [PATCH] implement toThrowErrorMatchingSnapshot, toThrowErrorMatchingInlineSnapshot (#15607) --- docs/test/writing.md | 4 +- packages/bun-types/test.d.ts | 34 +- .../bindings/InternalModuleRegistry.cpp | 2 +- src/bun.js/bindings/bindings.cpp | 8 +- src/bun.js/bindings/bindings.zig | 10 +- src/bun.js/test/expect.zig | 310 ++++++++++++------ src/bun.js/test/snapshot.zig | 38 ++- .../test/__snapshots__/test-interop.js.snap | 4 + test/js/bun/test/expect.test.js | 18 +- .../__snapshots__/snapshot.test.ts.snap | 20 ++ .../snapshot-tests/snapshots/snapshot.test.ts | 101 +++++- 11 files changed, 399 insertions(+), 150 deletions(-) diff --git a/docs/test/writing.md b/docs/test/writing.md index e56c234b10da1c..f432c9a9514b08 100644 --- a/docs/test/writing.md +++ b/docs/test/writing.md @@ -536,12 +536,12 @@ Bun implements the following matchers. Full Jest compatibility is on the roadmap --- -- ❌ +- ✅ - [`.toThrowErrorMatchingSnapshot()`](https://jestjs.io/docs/expect#tothrowerrormatchingsnapshothint) --- -- ❌ +- ✅ - [`.toThrowErrorMatchingInlineSnapshot()`](https://jestjs.io/docs/expect#tothrowerrormatchinginlinesnapshotinlinesnapshot) {% /table %} diff --git a/packages/bun-types/test.d.ts b/packages/bun-types/test.d.ts index da6fbd31f7d8c7..17270962a4002a 100644 --- a/packages/bun-types/test.d.ts +++ b/packages/bun-types/test.d.ts @@ -1299,15 +1299,17 @@ declare module "bun:test" { * Asserts that a value matches the most recent inline snapshot. * * @example + * expect("Hello").toMatchInlineSnapshot(); * expect("Hello").toMatchInlineSnapshot(`"Hello"`); - * @param value The latest snapshot value. + * + * @param value The latest automatically-updated snapshot value. */ toMatchInlineSnapshot(value?: string): void; /** * Asserts that a value matches the most recent inline snapshot. * * @example - * expect("Hello").toMatchInlineSnapshot(`"Hello"`); + * expect({ c: new Date() }).toMatchInlineSnapshot({ c: expect.any(Date) }); * expect({ c: new Date() }).toMatchInlineSnapshot({ c: expect.any(Date) }, ` * { * "v": Any, @@ -1315,9 +1317,35 @@ declare module "bun:test" { * `); * * @param propertyMatchers Object containing properties to match against the value. - * @param hint Hint used to identify the snapshot in the snapshot file. + * @param value The latest automatically-updated snapshot value. */ toMatchInlineSnapshot(propertyMatchers?: object, value?: string): void; + /** + * Asserts that a function throws an error matching the most recent snapshot. + * + * @example + * function fail() { + * throw new Error("Oops!"); + * } + * expect(fail).toThrowErrorMatchingSnapshot(); + * expect(fail).toThrowErrorMatchingSnapshot("This one should say Oops!"); + * + * @param value The latest automatically-updated snapshot value. + */ + toThrowErrorMatchingSnapshot(hint?: string): void; + /** + * Asserts that a function throws an error matching the most recent snapshot. + * + * @example + * function fail() { + * throw new Error("Oops!"); + * } + * expect(fail).toThrowErrorMatchingInlineSnapshot(); + * expect(fail).toThrowErrorMatchingInlineSnapshot(`"Oops!"`); + * + * @param value The latest automatically-updated snapshot value. + */ + toThrowErrorMatchingInlineSnapshot(value?: string): void; /** * Asserts that an object matches a subset of properties. * diff --git a/src/bun.js/bindings/InternalModuleRegistry.cpp b/src/bun.js/bindings/InternalModuleRegistry.cpp index 2f5d95c92f0308..eb158d45286c0a 100644 --- a/src/bun.js/bindings/InternalModuleRegistry.cpp +++ b/src/bun.js/bindings/InternalModuleRegistry.cpp @@ -74,7 +74,7 @@ JSC::JSValue generateModule(JSC::JSGlobalObject* globalObject, JSC::VM& vm, cons return result; } -#if BUN_DYNAMIC_JS_LOAD_PATH +#ifdef BUN_DYNAMIC_JS_LOAD_PATH JSValue initializeInternalModuleFromDisk( JSGlobalObject* globalObject, VM& vm, diff --git a/src/bun.js/bindings/bindings.cpp b/src/bun.js/bindings/bindings.cpp index 38e244d118cfaa..7ff4573359c600 100644 --- a/src/bun.js/bindings/bindings.cpp +++ b/src/bun.js/bindings/bindings.cpp @@ -6032,11 +6032,10 @@ CPP_DECL bool Bun__CallFrame__isFromBunMain(JSC::CallFrame* callFrame, JSC::VM* return source.string() == "builtin://bun/main"_s; } -CPP_DECL void Bun__CallFrame__getCallerSrcLoc(JSC::CallFrame* callFrame, JSC::JSGlobalObject* globalObject, unsigned int* outSourceID, unsigned int* outLine, unsigned int* outColumn) +CPP_DECL void Bun__CallFrame__getCallerSrcLoc(JSC::CallFrame* callFrame, JSC::JSGlobalObject* globalObject, BunString* outSourceURL, unsigned int* outLine, unsigned int* outColumn) { JSC::VM& vm = globalObject->vm(); JSC::LineColumn lineColumn; - JSC::SourceID sourceID = 0; String sourceURL; ZigStackFrame remappedFrame = {}; @@ -6046,6 +6045,7 @@ CPP_DECL void Bun__CallFrame__getCallerSrcLoc(JSC::CallFrame* callFrame, JSC::JS return WTF::IterationStatus::Continue; if (visitor->hasLineAndColumnInfo()) { + lineColumn = visitor->computeLineAndColumn(); String sourceURLForFrame = visitor->sourceURL(); @@ -6071,8 +6071,6 @@ CPP_DECL void Bun__CallFrame__getCallerSrcLoc(JSC::CallFrame* callFrame, JSC::JS sourceURLForFrame = origin.string(); } } - - sourceID = provider->asID(); } } @@ -6099,7 +6097,7 @@ CPP_DECL void Bun__CallFrame__getCallerSrcLoc(JSC::CallFrame* callFrame, JSC::JS lineColumn.column = OrdinalNumber::fromZeroBasedInt(remappedFrame.position.column_zero_based).oneBasedInt(); } - *outSourceID = sourceID; + *outSourceURL = Bun::toStringRef(sourceURL); *outLine = lineColumn.line; *outColumn = lineColumn.column; } diff --git a/src/bun.js/bindings/bindings.zig b/src/bun.js/bindings/bindings.zig index 5034ae9806993c..87170e0040a9a0 100644 --- a/src/bun.js/bindings/bindings.zig +++ b/src/bun.js/bindings/bindings.zig @@ -6666,19 +6666,19 @@ pub const CallFrame = opaque { return value; } - extern fn Bun__CallFrame__getCallerSrcLoc(*const CallFrame, *JSGlobalObject, *c_uint, *c_uint, *c_uint) void; + extern fn Bun__CallFrame__getCallerSrcLoc(*const CallFrame, *JSGlobalObject, *bun.String, *c_uint, *c_uint) void; pub const CallerSrcLoc = struct { - source_file_id: c_uint, + str: bun.String, line: c_uint, column: c_uint, }; pub fn getCallerSrcLoc(call_frame: *const CallFrame, globalThis: *JSGlobalObject) CallerSrcLoc { - var source_id: c_uint = undefined; + var str: bun.String = undefined; var line: c_uint = undefined; var column: c_uint = undefined; - Bun__CallFrame__getCallerSrcLoc(call_frame, globalThis, &source_id, &line, &column); + Bun__CallFrame__getCallerSrcLoc(call_frame, globalThis, &str, &line, &column); return .{ - .source_file_id = source_id, + .str = str, .line = line, .column = column, }; diff --git a/src/bun.js/test/expect.zig b/src/bun.js/test/expect.zig index a384c42aecf0dd..e09000c7b05736 100644 --- a/src/bun.js/test/expect.zig +++ b/src/bun.js/test/expect.zig @@ -2152,7 +2152,6 @@ pub const Expect = struct { pub fn toThrow(this: *Expect, globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue { defer this.postMatch(globalThis); - const vm = globalThis.bunVM(); const thisValue = callFrame.this(); const arguments = callFrame.argumentsAsArray(1); @@ -2178,63 +2177,9 @@ pub const Expect = struct { }; expected_value.ensureStillAlive(); - const value: JSValue = try this.getValue(globalThis, thisValue, "toThrow", "expected"); - const not = this.flags.not; - var return_value_from_function: JSValue = .zero; - const result_: ?JSValue = brk: { - if (!value.jsType().isFunction()) { - if (this.flags.promise != .none) { - break :brk value; - } - - return globalThis.throw("Expected value must be a function", .{}); - } - - var return_value: JSValue = .zero; - - // Drain existing unhandled rejections - vm.global.handleRejectedPromises(); - - var scope = vm.unhandledRejectionScope(); - const prev_unhandled_pending_rejection_to_capture = vm.unhandled_pending_rejection_to_capture; - vm.unhandled_pending_rejection_to_capture = &return_value; - vm.onUnhandledRejection = &VirtualMachine.onQuietUnhandledRejectionHandlerCaptureValue; - return_value_from_function = value.call(globalThis, .undefined, &.{}) catch |err| globalThis.takeException(err); - vm.unhandled_pending_rejection_to_capture = prev_unhandled_pending_rejection_to_capture; - - vm.global.handleRejectedPromises(); - - if (return_value == .zero) { - return_value = return_value_from_function; - } - - if (return_value.asAnyPromise()) |promise| { - vm.waitForPromise(promise); - scope.apply(vm); - switch (promise.unwrap(globalThis.vm(), .mark_handled)) { - .fulfilled => { - break :brk null; - }, - .rejected => |rejected| { - // since we know for sure it rejected, we should always return the error - break :brk rejected.toError() orelse rejected; - }, - .pending => unreachable, - } - } - - if (return_value != return_value_from_function) { - if (return_value_from_function.asAnyPromise()) |existing| { - existing.setHandled(globalThis.vm()); - } - } - - scope.apply(vm); - - break :brk return_value.toError() orelse return_value_from_function.toError(); - }; + const result_, const return_value_from_function = try this.getValueAsToThrow(globalThis, try this.getValue(globalThis, thisValue, "toThrow", "expected")); const did_throw = result_ != null; @@ -2506,10 +2451,152 @@ pub const Expect = struct { expected_value.getClassName(globalThis, &expected_class); return this.throw(globalThis, signature, expected_fmt, .{ expected_class, result.toFmt(&formatter) }); } - pub fn toMatchInlineSnapshot(this: *Expect, globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue { - // in jest, a failing inline snapshot does not block the rest from running - // not sure why - empty snapshots will autofill and with the `-u` flag none will fail + fn getValueAsToThrow(this: *Expect, globalThis: *JSGlobalObject, value: JSValue) bun.JSError!struct { ?JSValue, JSValue } { + const vm = globalThis.bunVM(); + + var return_value_from_function: JSValue = .zero; + + if (!value.jsType().isFunction()) { + if (this.flags.promise != .none) { + return .{ value, return_value_from_function }; + } + + return globalThis.throw("Expected value must be a function", .{}); + } + + var return_value: JSValue = .zero; + + // Drain existing unhandled rejections + vm.global.handleRejectedPromises(); + + var scope = vm.unhandledRejectionScope(); + const prev_unhandled_pending_rejection_to_capture = vm.unhandled_pending_rejection_to_capture; + vm.unhandled_pending_rejection_to_capture = &return_value; + vm.onUnhandledRejection = &VirtualMachine.onQuietUnhandledRejectionHandlerCaptureValue; + return_value_from_function = value.call(globalThis, .undefined, &.{}) catch |err| globalThis.takeException(err); + vm.unhandled_pending_rejection_to_capture = prev_unhandled_pending_rejection_to_capture; + + vm.global.handleRejectedPromises(); + + if (return_value == .zero) { + return_value = return_value_from_function; + } + if (return_value.asAnyPromise()) |promise| { + vm.waitForPromise(promise); + scope.apply(vm); + switch (promise.unwrap(globalThis.vm(), .mark_handled)) { + .fulfilled => { + return .{ null, return_value_from_function }; + }, + .rejected => |rejected| { + // since we know for sure it rejected, we should always return the error + return .{ rejected.toError() orelse rejected, return_value_from_function }; + }, + .pending => unreachable, + } + } + + if (return_value != return_value_from_function) { + if (return_value_from_function.asAnyPromise()) |existing| { + existing.setHandled(globalThis.vm()); + } + } + + scope.apply(vm); + + return .{ return_value.toError() orelse return_value_from_function.toError(), return_value_from_function }; + } + pub fn toThrowErrorMatchingSnapshot(this: *Expect, globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue { + defer this.postMatch(globalThis); + const thisValue = callFrame.this(); + const _arguments = callFrame.arguments_old(2); + const arguments: []const JSValue = _arguments.ptr[0.._arguments.len]; + + incrementExpectCallCounter(); + + const not = this.flags.not; + if (not) { + const signature = comptime getSignature("toThrowErrorMatchingSnapshot", "", true); + return this.throw(globalThis, signature, "\n\nMatcher error: Snapshot matchers cannot be used with not\n", .{}); + } + + if (this.testScope() == null) { + const signature = comptime getSignature("toThrowErrorMatchingSnapshot", "", true); + return this.throw(globalThis, signature, "\n\nMatcher error: Snapshot matchers cannot be used outside of a test\n", .{}); + } + + var hint_string: ZigString = ZigString.Empty; + switch (arguments.len) { + 0 => {}, + 1 => { + if (arguments[0].isString()) { + arguments[0].toZigString(&hint_string, globalThis); + } else { + return this.throw(globalThis, "", "\n\nMatcher error: Expected first argument to be a string\n", .{}); + } + }, + else => return this.throw(globalThis, "", "\n\nMatcher error: Expected zero or one arguments\n", .{}), + } + + var hint = hint_string.toSlice(default_allocator); + defer hint.deinit(); + + const value: JSValue = try this.fnToErrStringOrUndefined(globalThis, try this.getValue(globalThis, thisValue, "toThrowErrorMatchingSnapshot", "properties, hint")); + + return this.snapshot(globalThis, value, null, hint.slice(), "toThrowErrorMatchingSnapshot"); + } + fn fnToErrStringOrUndefined(this: *Expect, globalThis: *JSGlobalObject, value: JSValue) !JSValue { + const err_value, _ = try this.getValueAsToThrow(globalThis, value); + + var err_value_res = err_value orelse JSValue.undefined; + if (err_value_res.isAnyError()) { + const message = try err_value_res.getTruthyComptime(globalThis, "message") orelse JSValue.undefined; + err_value_res = message; + } else { + err_value_res = JSValue.undefined; + } + return err_value_res; + } + pub fn toThrowErrorMatchingInlineSnapshot(this: *Expect, globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue { + defer this.postMatch(globalThis); + const thisValue = callFrame.this(); + const _arguments = callFrame.arguments_old(2); + const arguments: []const JSValue = _arguments.ptr[0.._arguments.len]; + + incrementExpectCallCounter(); + + const not = this.flags.not; + if (not) { + const signature = comptime getSignature("toThrowErrorMatchingInlineSnapshot", "", true); + return this.throw(globalThis, signature, "\n\nMatcher error: Snapshot matchers cannot be used with not\n", .{}); + } + + var has_expected = false; + var expected_string: ZigString = ZigString.Empty; + switch (arguments.len) { + 0 => {}, + 1 => { + if (arguments[0].isString()) { + has_expected = true; + arguments[0].toZigString(&expected_string, globalThis); + } else { + return this.throw(globalThis, "", "\n\nMatcher error: Expected first argument to be a string\n", .{}); + } + }, + else => return this.throw(globalThis, "", "\n\nMatcher error: Expected zero or one arguments\n", .{}), + } + + var expected = expected_string.toSlice(default_allocator); + defer expected.deinit(); + + const expected_slice: ?[]const u8 = if (has_expected) expected.slice() else null; + + const value: JSValue = try this.fnToErrStringOrUndefined(globalThis, try this.getValue(globalThis, thisValue, "toThrowErrorMatchingInlineSnapshot", "properties, hint")); + + return this.inlineSnapshot(globalThis, callFrame, value, null, expected_slice, "toThrowErrorMatchingInlineSnapshot"); + } + pub fn toMatchInlineSnapshot(this: *Expect, globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue { defer this.postMatch(globalThis); const thisValue = callFrame.this(); const _arguments = callFrame.arguments_old(2); @@ -2556,48 +2643,43 @@ pub const Expect = struct { var expected = expected_string.toSlice(default_allocator); defer expected.deinit(); - const value: JSValue = try this.getValue(globalThis, thisValue, "toMatchInlineSnapshot", "properties, hint"); - - if (!value.isObject() and property_matchers != null) { - const signature = comptime getSignature("toMatchInlineSnapshot", "properties, hint", false); - return this.throw(globalThis, signature, "\n\nMatcher error: received values must be an object when the matcher has properties\n", .{}); - } - - if (property_matchers) |_prop_matchers| { - const prop_matchers = _prop_matchers; - - if (!value.jestDeepMatch(prop_matchers, globalThis, true)) { - // TODO: print diff with properties from propertyMatchers - const signature = comptime getSignature("toMatchInlineSnapshot", "propertyMatchers", false); - const fmt = signature ++ "\n\nExpected propertyMatchers to match properties from received object" ++ - "\n\nReceived: {any}\n"; - - var formatter = JSC.ConsoleObject.Formatter{ .globalThis = globalThis }; - return globalThis.throwPretty(fmt, .{value.toFmt(&formatter)}); - } - } + const expected_slice: ?[]const u8 = if (has_expected) expected.slice() else null; - const result: ?[]const u8 = if (has_expected) expected.byteSlice() else null; + const value = try this.getValue(globalThis, thisValue, "toMatchInlineSnapshot", "properties, hint"); + return this.inlineSnapshot(globalThis, callFrame, value, property_matchers, expected_slice, "toMatchInlineSnapshot"); + } + fn inlineSnapshot( + this: *Expect, + globalThis: *JSGlobalObject, + callFrame: *CallFrame, + value: JSValue, + property_matchers: ?JSValue, + result: ?[]const u8, + comptime fn_name: []const u8, + ) bun.JSError!JSValue { + // jest counts inline snapshots towards the snapshot counter for some reason + _ = Jest.runner.?.snapshots.addCount(this, "") catch |e| switch (e) { + error.OutOfMemory => return error.OutOfMemory, + error.NoTest => {}, + }; const update = Jest.runner.?.snapshots.update_snapshots; var needs_write = false; - var pretty_value: MutableString = MutableString.init(default_allocator, 0) catch unreachable; - value.jestSnapshotPrettyFormat(&pretty_value, globalThis) catch { - var formatter = JSC.ConsoleObject.Formatter{ .globalThis = globalThis }; - return globalThis.throw("Failed to pretty format value: {s}", .{value.toFmt(&formatter)}); - }; + var pretty_value: MutableString = try MutableString.init(default_allocator, 0); defer pretty_value.deinit(); + try this.matchAndFmtSnapshot(globalThis, value, property_matchers, &pretty_value, fn_name); if (result) |saved_value| { if (strings.eqlLong(pretty_value.slice(), saved_value, true)) { Jest.runner.?.snapshots.passed += 1; return .undefined; } else if (update) { + Jest.runner.?.snapshots.passed += 1; needs_write = true; } else { Jest.runner.?.snapshots.failed += 1; - const signature = comptime getSignature("toMatchInlineSnapshot", "expected", false); + const signature = comptime getSignature(fn_name, "expected", false); const fmt = signature ++ "\n\n{any}\n"; const diff_format = DiffFormatter{ .received_string = pretty_value.slice(), @@ -2613,24 +2695,40 @@ pub const Expect = struct { if (needs_write) { if (this.testScope() == null) { - const signature = comptime getSignature("toMatchSnapshot", "", true); + const signature = comptime getSignature(fn_name, "", true); return this.throw(globalThis, signature, "\n\nMatcher error: Snapshot matchers cannot be used outside of a test\n", .{}); } // 1. find the src loc of the snapshot const srcloc = callFrame.getCallerSrcLoc(globalThis); - - if (srcloc.source_file_id != this.testScope().?.describe.file_id) { - const signature = comptime getSignature("toMatchSnapshot", "", true); - return this.throw(globalThis, signature, "\n\nMatcher error: Inline snapshot matchers must be called from the same file as the test\n", .{}); + defer srcloc.str.deref(); + const describe = this.testScope().?.describe; + const fget = Jest.runner.?.files.get(describe.file_id); + + if (!srcloc.str.eqlUTF8(fget.source.path.text)) { + const signature = comptime getSignature(fn_name, "", true); + return this.throw(globalThis, signature, + \\ + \\ + \\Matcher error: Inline snapshot matchers must be called from the test file: + \\ Expected to be called from file: "{}" + \\ {s} called from file: "{}" + \\ + , .{ + std.zig.fmtEscapes(fget.source.path.text), + fn_name, + std.zig.fmtEscapes(srcloc.str.toUTF8(Jest.runner.?.snapshots.allocator).slice()), + }); } // 2. save to write later - try Jest.runner.?.snapshots.addInlineSnapshotToWrite(srcloc.source_file_id, .{ + try Jest.runner.?.snapshots.addInlineSnapshotToWrite(describe.file_id, .{ .line = srcloc.line, .col = srcloc.column, .value = pretty_value.toOwnedSlice(), .has_matchers = property_matchers != null, + .is_added = result == null, + .kind = fn_name, }); } @@ -2689,17 +2787,20 @@ pub const Expect = struct { const value: JSValue = try this.getValue(globalThis, thisValue, "toMatchSnapshot", "properties, hint"); - if (!value.isObject() and property_matchers != null) { - const signature = comptime getSignature("toMatchSnapshot", "properties, hint", false); - return this.throw(globalThis, signature, "\n\nMatcher error: received values must be an object when the matcher has properties\n", .{}); - } - + return this.snapshot(globalThis, value, property_matchers, hint.slice(), "toMatchSnapshot"); + } + fn matchAndFmtSnapshot(this: *Expect, globalThis: *JSGlobalObject, value: JSValue, property_matchers: ?JSValue, pretty_value: *MutableString, comptime fn_name: []const u8) bun.JSError!void { if (property_matchers) |_prop_matchers| { + if (!value.isObject()) { + const signature = comptime getSignature(fn_name, "properties, hint", false); + return this.throw(globalThis, signature, "\n\nMatcher error: received values must be an object when the matcher has properties\n", .{}); + } + const prop_matchers = _prop_matchers; if (!value.jestDeepMatch(prop_matchers, globalThis, true)) { // TODO: print diff with properties from propertyMatchers - const signature = comptime getSignature("toMatchSnapshot", "propertyMatchers", false); + const signature = comptime getSignature(fn_name, "propertyMatchers", false); const fmt = signature ++ "\n\nExpected propertyMatchers to match properties from received object" ++ "\n\nReceived: {any}\n"; @@ -2708,14 +2809,17 @@ pub const Expect = struct { } } - var pretty_value: MutableString = MutableString.init(default_allocator, 0) catch unreachable; - value.jestSnapshotPrettyFormat(&pretty_value, globalThis) catch { + value.jestSnapshotPrettyFormat(pretty_value, globalThis) catch { var formatter = JSC.ConsoleObject.Formatter{ .globalThis = globalThis }; return globalThis.throw("Failed to pretty format value: {s}", .{value.toFmt(&formatter)}); }; + } + 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: MutableString = try MutableString.init(default_allocator, 0); defer pretty_value.deinit(); + try this.matchAndFmtSnapshot(globalThis, value, property_matchers, &pretty_value, fn_name); - const existing_value = Jest.runner.?.snapshots.getOrPut(this, pretty_value.slice(), hint.slice()) catch |err| { + const existing_value = Jest.runner.?.snapshots.getOrPut(this, pretty_value.slice(), hint) catch |err| { var formatter = JSC.ConsoleObject.Formatter{ .globalThis = globalThis }; const test_file_path = Jest.runner.?.files.get(this.testScope().?.describe.file_id).source.path.text; return switch (err) { @@ -2734,7 +2838,7 @@ pub const Expect = struct { } Jest.runner.?.snapshots.failed += 1; - const signature = comptime getSignature("toMatchSnapshot", "expected", false); + const signature = comptime getSignature(fn_name, "expected", false); const fmt = signature ++ "\n\n{any}\n"; const diff_format = DiffFormatter{ .received_string = pretty_value.slice(), @@ -4335,8 +4439,6 @@ pub const Expect = struct { pub const toHaveReturnedWith = notImplementedJSCFn; pub const toHaveLastReturnedWith = notImplementedJSCFn; pub const toHaveNthReturnedWith = notImplementedJSCFn; - pub const toThrowErrorMatchingSnapshot = notImplementedJSCFn; - pub const toThrowErrorMatchingInlineSnapshot = notImplementedJSCFn; pub fn getStaticNot(globalThis: *JSGlobalObject, _: JSValue, _: JSValue) JSValue { return ExpectStatic.create(globalThis, .{ .not = true }); diff --git a/src/bun.js/test/snapshot.zig b/src/bun.js/test/snapshot.zig index 2042d360a0b50f..99eb561d40561b 100644 --- a/src/bun.js/test/snapshot.zig +++ b/src/bun.js/test/snapshot.zig @@ -37,8 +37,10 @@ pub const Snapshots = struct { pub const InlineSnapshotToWrite = struct { line: c_ulong, col: c_ulong, - value: []const u8, + value: []const u8, // owned by Snapshots.allocator has_matchers: bool, + is_added: bool, + kind: []const u8, // static lifetime fn lessThanFn(_: void, a: InlineSnapshotToWrite, b: InlineSnapshotToWrite) bool { if (a.line < b.line) return true; @@ -53,6 +55,18 @@ pub const Snapshots = struct { file: std.fs.File, }; + pub fn addCount(this: *Snapshots, expect: *Expect, hint: []const u8) !struct { []const u8, usize } { + this.total += 1; + const snapshot_name = try expect.getSnapshotName(this.allocator, hint); + const count_entry = try this.counts.getOrPut(snapshot_name); + if (count_entry.found_existing) { + this.allocator.free(snapshot_name); + count_entry.value_ptr.* += 1; + return .{ count_entry.key_ptr.*, count_entry.value_ptr.* }; + } + count_entry.value_ptr.* = 1; + return .{ count_entry.key_ptr.*, count_entry.value_ptr.* }; + } pub fn getOrPut(this: *Snapshots, expect: *Expect, target_value: []const u8, hint: string) !?string { switch (try this.getSnapshotFile(expect.testScope().?.describe.file_id)) { .result => {}, @@ -65,21 +79,7 @@ pub const Snapshots = struct { }, } - const snapshot_name = try expect.getSnapshotName(this.allocator, hint); - this.total += 1; - - const count_entry = try this.counts.getOrPut(snapshot_name); - const counter = brk: { - if (count_entry.found_existing) { - this.allocator.free(snapshot_name); - count_entry.value_ptr.* += 1; - break :brk count_entry.value_ptr.*; - } - count_entry.value_ptr.* = 1; - break :brk count_entry.value_ptr.*; - }; - - const name = count_entry.key_ptr.*; + const name, const counter = try this.addCount(expect, hint); var counter_string_buf = [_]u8{0} ** 32; const counter_string = try std.fmt.bufPrint(&counter_string_buf, "{d}", .{counter}); @@ -276,7 +276,7 @@ pub const Snapshots = struct { inline_snapshot_dbg("Finding byte for {}/{}", .{ ils.line, ils.col }); const byte_offset_add = logger.Source.lineColToByteOffset(file_text[last_byte..], last_line, last_col, ils.line, ils.col) orelse { inline_snapshot_dbg("-> Could not find byte", .{}); - try log.addErrorFmt(&source, .{ .start = @intCast(uncommitted_segment_end) }, arena, "Failed to update inline snapshot: Could not find byte for line/column: {d}/{d}", .{ ils.line, ils.col }); + try log.addErrorFmt(&source, .{ .start = @intCast(uncommitted_segment_end) }, arena, "Failed to update inline snapshot: Ln {d}, Col {d} not found", .{ ils.line, ils.col }); continue; }; @@ -296,7 +296,7 @@ pub const Snapshots = struct { }, else => {}, }; - const fn_name = "toMatchInlineSnapshot"; + const fn_name = ils.kind; if (!bun.strings.startsWith(file_text[next_start..], fn_name)) { try log.addErrorFmt(&source, .{ .start = @intCast(next_start) }, arena, "Failed to update inline snapshot: Could not find 'toMatchInlineSnapshot' here", .{}); continue; @@ -400,6 +400,8 @@ pub const Snapshots = struct { try result_text.appendSlice("`"); try bun.js_printer.writePreQuotedString(ils.value, @TypeOf(result_text_writer), result_text_writer, '`', false, false, .utf8); try result_text.appendSlice("`"); + + if (ils.is_added) Jest.runner.?.snapshots.added += 1; } // commit the last segment diff --git a/test/js/bun/test/__snapshots__/test-interop.js.snap b/test/js/bun/test/__snapshots__/test-interop.js.snap index c626a5ab564ee8..39cfad68b83f72 100644 --- a/test/js/bun/test/__snapshots__/test-interop.js.snap +++ b/test/js/bun/test/__snapshots__/test-interop.js.snap @@ -1,3 +1,7 @@ // Bun Snapshot v1, https://goo.gl/fbAQLP exports[`expect() toMatchSnapshot to return undefined 1`] = `"abc"`; + +exports[`expect() toThrowErrorMatchingSnapshot to return undefined 1`] = `undefined`; + +exports[`expect() toThrowErrorMatchingSnapshot to return undefined: undefined 1`] = `undefined`; diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index 4f5b339ffb83cd..cc3ff40dce80a1 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -4744,7 +4744,7 @@ describe("expect()", () => { expect(expect("abc").toMatch("a")).toBeUndefined(); }); test.todo("toMatchInlineSnapshot to return undefined", () => { - expect(expect("abc").toMatchInlineSnapshot()).toBeUndefined(); + expect(expect("abc").toMatchInlineSnapshot('"abc"')).toBeUndefined(); }); test("toMatchObject to return undefined", () => { expect(expect({}).toMatchObject({})).toBeUndefined(); @@ -4768,11 +4768,19 @@ describe("expect()", () => { }).toThrow(), ).toBeUndefined(); }); - test.todo("toThrowErrorMatchingInlineSnapshot to return undefined", () => { - expect(expect(() => {}).toThrowErrorMatchingInlineSnapshot()).toBeUndefined(); + test("toThrowErrorMatchingInlineSnapshot to return undefined", () => { + expect( + expect(() => { + throw 0; + }).toThrowErrorMatchingInlineSnapshot("undefined"), + ).toBeUndefined(); }); - test.todo("toThrowErrorMatchingSnapshot to return undefined", () => { - expect(expect(() => {}).toThrowErrorMatchingSnapshot()).toBeUndefined(); + test("toThrowErrorMatchingSnapshot to return undefined", () => { + expect( + expect(() => { + throw 0; + }).toThrowErrorMatchingSnapshot("undefined"), + ).toBeUndefined(); }); test(' " " to contain ""', () => { diff --git a/test/js/bun/test/snapshot-tests/snapshots/__snapshots__/snapshot.test.ts.snap b/test/js/bun/test/snapshot-tests/snapshots/__snapshots__/snapshot.test.ts.snap index f6c84aef6a8954..5effa6968c9223 100644 --- a/test/js/bun/test/snapshot-tests/snapshots/__snapshots__/snapshot.test.ts.snap +++ b/test/js/bun/test/snapshot-tests/snapshots/__snapshots__/snapshot.test.ts.snap @@ -587,3 +587,23 @@ exports[`snapshots unicode surrogate halves 1`] = ` exports[\`abc 1\`] = \`"😊abc\\\`\\\${def} �, � "\`; " `; + +exports[`error inline snapshots 1`] = `"hello"`; + +exports[`error inline snapshots 2`] = `undefined`; + +exports[`error inline snapshots 3`] = `undefined`; + +exports[`error inline snapshots 4`] = `undefined`; + +exports[`error inline snapshots: hint 1`] = `undefined`; + +exports[`snapshot numbering 1`] = `"item one"`; + +exports[`snapshot numbering 2`] = `"snap"`; + +exports[`snapshot numbering 4`] = `"snap"`; + +exports[`snapshot numbering 6`] = `"hello"`; + +exports[`snapshot numbering: hinted 1`] = `"hello"`; diff --git a/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts b/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts index abbb6cf96f1802..fc014b9faf818d 100644 --- a/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts +++ b/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts @@ -483,13 +483,14 @@ class InlineSnapshotTester { describe("inline snapshots", () => { const bad = '"bad"'; + const helper_js = /*js*/ ` + import {expect} from "bun:test"; + export function wrongFile(value) { + expect(value).toMatchInlineSnapshot(); + } + `; const tester = new InlineSnapshotTester({ - "helper.js": /*js*/ ` - import {expect} from "bun:test"; - export function wrongFile(value) { - expect(value).toMatchInlineSnapshot(); - } - `, + "helper.js": helper_js, }); test("changing inline snapshot", () => { tester.test( @@ -718,7 +719,7 @@ describe("inline snapshots", () => { tester.testError( { update: true, - msg: "Matcher error: Inline snapshot matchers must be called from the same file as the test", + msg: "Inline snapshot matchers must be called from the test file", }, /*js*/ ` import {wrongFile} from "./helper"; @@ -727,5 +728,91 @@ describe("inline snapshots", () => { }); `, ); + expect(readFileSync(tester.tmpdir + "/helper.js", "utf-8")).toBe(helper_js); + }); + it("is right file", () => { + tester.test( + v => /*js*/ ` + import {wrongFile} from "./helper"; + test("cases", () => { + expect("rightfile").toMatchInlineSnapshot(${v("", '"9"', '`"rightfile"`')}); + expect(wrongFile).toMatchInlineSnapshot(${v("", '"9"', "`[Function: wrongFile]`")}); + }); + `, + ); + }); +}); + +test("error snapshots", () => { + expect(() => { + throw new Error("hello"); + }).toThrowErrorMatchingInlineSnapshot(`"hello"`); + expect(() => { + throw 0; + }).toThrowErrorMatchingInlineSnapshot(`undefined`); + expect(() => { + throw { a: "b" }; + }).toThrowErrorMatchingInlineSnapshot(`undefined`); + expect(() => { + throw undefined; // this one doesn't work in jest because it doesn't think the function threw + }).toThrowErrorMatchingInlineSnapshot(`undefined`); +}); +test("error inline snapshots", () => { + expect(() => { + throw new Error("hello"); + }).toThrowErrorMatchingSnapshot(); + expect(() => { + throw 0; + }).toThrowErrorMatchingSnapshot(); + expect(() => { + throw { a: "b" }; + }).toThrowErrorMatchingSnapshot(); + expect(() => { + throw undefined; + }).toThrowErrorMatchingSnapshot(); + expect(() => { + throw "abcdef"; + }).toThrowErrorMatchingSnapshot("hint"); + expect(() => { + throw new Error("😊"); + }).toThrowErrorMatchingInlineSnapshot(`"😊"`); +}); + +test("snapshot numbering", () => { + function fails() { + throw new Error("snap"); + } + expect("item one").toMatchSnapshot(); + expect(fails).toThrowErrorMatchingSnapshot(); + expect("1").toMatchInlineSnapshot(`"1"`); + expect(fails).toThrowErrorMatchingSnapshot(); + expect(fails).toThrowErrorMatchingInlineSnapshot(`"snap"`); + expect("hello").toMatchSnapshot(); + expect("hello").toMatchSnapshot("hinted"); +}); + +test("write snapshot from filter", async () => { + const sver = (m: string, a: boolean) => /*js*/ ` + test("mysnap", () => { + expect("${m}").toMatchInlineSnapshot(${a ? '`"' + m + '"`' : ""}); + expect(() => {throw new Error("${m}!")}).toThrowErrorMatchingInlineSnapshot(${a ? '`"' + m + '!"`' : ""}); + }) + `; + const dir = tempDirWithFiles("writesnapshotfromfilter", { + "mytests": { + "snap.test.ts": sver("a", false), + "snap2.test.ts": sver("b", false), + "more": { + "testing.test.ts": sver("TEST", false), + }, + }, }); + await $`cd ${dir} && ${bunExe()} test mytests`; + expect(await Bun.file(dir + "/mytests/snap.test.ts").text()).toBe(sver("a", true)); + expect(await Bun.file(dir + "/mytests/snap2.test.ts").text()).toBe(sver("b", true)); + expect(await Bun.file(dir + "/mytests/more/testing.test.ts").text()).toBe(sver("TEST", true)); + await $`cd ${dir} && ${bunExe()} test mytests`; + expect(await Bun.file(dir + "/mytests/snap.test.ts").text()).toBe(sver("a", true)); + expect(await Bun.file(dir + "/mytests/snap2.test.ts").text()).toBe(sver("b", true)); + expect(await Bun.file(dir + "/mytests/more/testing.test.ts").text()).toBe(sver("TEST", true)); });