diff --git a/docs/test/writing.md b/docs/test/writing.md index f19ac7bf55..f61e911426 100644 --- a/docs/test/writing.md +++ b/docs/test/writing.md @@ -677,17 +677,17 @@ Bun implements the following matchers. Full Jest compatibility is on the roadmap --- -- ❌ +- ✅ - [`.toHaveReturnedWith()`](https://jestjs.io/docs/expect#tohavereturnedwithvalue) --- -- ❌ +- ✅ - [`.toHaveLastReturnedWith()`](https://jestjs.io/docs/expect#tohavelastreturnedwithvalue) --- -- ❌ +- ✅ - [`.toHaveNthReturnedWith()`](https://jestjs.io/docs/expect#tohaventhreturnedwithnthcall-value) --- diff --git a/packages/bun-types/test.d.ts b/packages/bun-types/test.d.ts index ffff77134b..d049e44e92 100644 --- a/packages/bun-types/test.d.ts +++ b/packages/bun-types/test.d.ts @@ -1642,6 +1642,26 @@ declare module "bun:test" { */ toHaveReturnedTimes(times: number): void; + /** + * Ensures that a mock function has returned a specific value. + * This matcher uses deep equality, like toEqual(), and supports asymmetric matchers. + */ + toHaveReturnedWith(expected: unknown): void; + + /** + * Ensures that a mock function has returned a specific value on its last invocation. + * This matcher uses deep equality, like toEqual(), and supports asymmetric matchers. + */ + toHaveLastReturnedWith(expected: unknown): void; + + /** + * Ensures that a mock function has returned a specific value on the nth invocation. + * This matcher uses deep equality, like toEqual(), and supports asymmetric matchers. + * @param n The 1-based index of the function call + * @param expected The expected return value + */ + toHaveNthReturnedWith(n: number, expected: unknown): void; + /** * Ensures that a mock function is called. */ diff --git a/src/bun.js/api/JSTranspiler.zig b/src/bun.js/api/JSTranspiler.zig index 93151d186a..73e956c104 100644 --- a/src/bun.js/api/JSTranspiler.zig +++ b/src/bun.js/api/JSTranspiler.zig @@ -325,7 +325,7 @@ fn transformOptionsFromJSC(globalObject: *jsc.JSGlobalObject, temp_allocator: st try external.toZigString(&zig_str, globalThis); if (zig_str.len == 0) break :external; var single_external = allocator.alloc(string, 1) catch unreachable; - single_external[0] = std.fmt.allocPrint(allocator, "{}", .{external}) catch unreachable; + single_external[0] = std.fmt.allocPrint(allocator, "{}", .{zig_str}) catch unreachable; transpiler.transform.external = single_external; } else if (toplevel_type.isArray()) { const count = try external.getLength(globalThis); @@ -342,7 +342,7 @@ fn transformOptionsFromJSC(globalObject: *jsc.JSGlobalObject, temp_allocator: st var zig_str = jsc.ZigString.init(""); try entry.toZigString(&zig_str, globalThis); if (zig_str.len == 0) continue; - externals[i] = std.fmt.allocPrint(allocator, "{}", .{external}) catch unreachable; + externals[i] = std.fmt.allocPrint(allocator, "{}", .{zig_str}) catch unreachable; i += 1; } diff --git a/src/bun.js/bindings/JSMockFunction.cpp b/src/bun.js/bindings/JSMockFunction.cpp index 92f4e15cbd..29aa728083 100644 --- a/src/bun.js/bindings/JSMockFunction.cpp +++ b/src/bun.js/bindings/JSMockFunction.cpp @@ -1039,11 +1039,12 @@ extern "C" JSC::EncodedJSValue JSMockFunction__getCalls(EncodedJSValue encodedVa } return encodedJSUndefined(); } -extern "C" JSC::EncodedJSValue JSMockFunction__getReturns(EncodedJSValue encodedValue) +extern "C" [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue JSMockFunction__getReturns(JSC::JSGlobalObject* globalThis, EncodedJSValue encodedValue) { + auto scope = DECLARE_THROW_SCOPE(globalThis->vm()); JSValue value = JSValue::decode(encodedValue); if (auto* mock = tryJSDynamicCast(value)) { - return JSValue::encode(mock->getReturnValues()); + RELEASE_AND_RETURN(scope, JSValue::encode(mock->getReturnValues())); } return encodedJSUndefined(); } diff --git a/src/bun.js/bindings/JSValue.zig b/src/bun.js/bindings/JSValue.zig index a4602912c7..03c9a634c3 100644 --- a/src/bun.js/bindings/JSValue.zig +++ b/src/bun.js/bindings/JSValue.zig @@ -25,6 +25,10 @@ pub const JSValue = enum(i64) { pub const is_pointer = false; pub const JSType = @import("./JSType.zig").JSType; + pub fn format(_: JSValue, comptime _: []const u8, _: std.fmt.FormatOptions, _: anytype) !void { + @compileError("Formatting a JSValue directly is not allowed. Use jsc.ConsoleObject.Formatter"); + } + pub inline fn cast(ptr: anytype) JSValue { return @as(JSValue, @enumFromInt(@as(i64, @bitCast(@intFromPtr(ptr))))); } diff --git a/src/bun.js/node/util/parse_args.zig b/src/bun.js/node/util/parse_args.zig index cfd8b444b1..f4d5d96b29 100644 --- a/src/bun.js/node/util/parse_args.zig +++ b/src/bun.js/node/util/parse_args.zig @@ -339,12 +339,12 @@ fn parseOptionDefinitions(globalThis: *JSGlobalObject, options_obj: JSValue, opt } } - log("[OptionDef] \"{s}\" (type={s}, short={s}, multiple={d}, default={?})", .{ + log("[OptionDef] \"{s}\" (type={s}, short={s}, multiple={d}, default={?s})", .{ String.init(long_option), @tagName(option.type), if (!option.short_name.isEmpty()) option.short_name else String.static("none"), @intFromBool(option.multiple), - option.default_value, + if (option.default_value) |dv| bun.tagName(JSValue, dv) else null, }); try option_definitions.append(option); diff --git a/src/bun.js/test/expect.zig b/src/bun.js/test/expect.zig index 083a39cf8f..c4a80442a8 100644 --- a/src/bun.js/test/expect.zig +++ b/src/bun.js/test/expect.zig @@ -4153,14 +4153,21 @@ pub const Expect = struct { pub fn toHaveBeenCalled(this: *Expect, globalThis: *JSGlobalObject, callframe: *CallFrame) bun.JSError!JSValue { jsc.markBinding(@src()); const thisValue = callframe.this(); + const firstArgument = callframe.argumentsAsArray(1)[0]; defer this.postMatch(globalThis); + if (!firstArgument.isUndefined()) { + return globalThis.throwInvalidArguments("toHaveBeenCalled() must not have an argument", .{}); + } + const value: JSValue = try this.getValue(globalThis, thisValue, "toHaveBeenCalled", ""); const calls = try bun.jsc.fromJSHostCall(globalThis, @src(), JSMockFunction__getCalls, .{value}); incrementExpectCallCounter(); if (!calls.jsType().isArray()) { - return globalThis.throw("Expected value must be a mock function: {}", .{value}); + var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; + defer formatter.deinit(); + return globalThis.throw("Expected value must be a mock function: {any}", .{value.toFmt(&formatter)}); } const calls_length = try calls.getLength(globalThis); @@ -4191,7 +4198,9 @@ pub const Expect = struct { const calls = try bun.jsc.fromJSHostCall(globalThis, @src(), JSMockFunction__getCalls, .{value}); if (!calls.jsType().isArray()) { - return globalThis.throw("Expected value must be a mock function: {}", .{value}); + var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; + defer formatter.deinit(); + return globalThis.throw("Expected value must be a mock function: {any}", .{value.toFmt(&formatter)}); } const calls_length = try calls.getLength(globalThis); @@ -4224,7 +4233,9 @@ pub const Expect = struct { const calls = try bun.jsc.fromJSHostCall(globalThis, @src(), JSMockFunction__getCalls, .{value}); if (!calls.jsType().isArray()) { - return globalThis.throw("Expected value must be a mock function: {}", .{value}); + var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; + defer formatter.deinit(); + return globalThis.throw("Expected value must be a mock function: {any}", .{value.toFmt(&formatter)}); } if (arguments.len < 1 or !arguments[0].isUInt32AsAnyInt()) { @@ -4313,22 +4324,26 @@ pub const Expect = struct { const thisValue = callframe.this(); const arguments = callframe.arguments(); defer this.postMatch(globalThis); - const value: JSValue = try this.getValue(globalThis, thisValue, "toHaveBeenCalledWith", "expected"); + const value: JSValue = try this.getValue(globalThis, thisValue, "toHaveBeenCalledWith", "...expected"); incrementExpectCallCounter(); const calls = try bun.jsc.fromJSHostCall(globalThis, @src(), JSMockFunction__getCalls, .{value}); if (!calls.jsType().isArray()) { - return globalThis.throw("Expected value must be a mock function: {}", .{value}); + var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; + defer formatter.deinit(); + return this.throw(globalThis, comptime getSignature("toHaveBeenCalledWith", "...expected", false), "\n\nMatcher error: received value must be a mock function\nReceived: {any}", .{value.toFmt(&formatter)}); } var pass = false; - if (try calls.getLength(globalThis) > 0) { + const calls_count = @as(u32, @intCast(try calls.getLength(globalThis))); + if (calls_count > 0) { var itr = try calls.arrayIterator(globalThis); while (try itr.next()) |callItem| { if (callItem == .zero or !callItem.jsType().isArray()) { - return globalThis.throw("Expected value must be a mock function with calls: {}", .{value}); + // This indicates a malformed mock object, which is an internal error. + return globalThis.throw("Internal error: expected mock call item to be an array of arguments.", .{}); } if (try callItem.getLength(globalThis) != arguments.len) { @@ -4351,18 +4366,70 @@ pub const Expect = struct { } } - const not = this.flags.not; - if (not) pass = !pass; - if (pass) return .js_undefined; - - // handle failure - if (not) { - const signature = comptime getSignature("toHaveBeenCalledWith", "expected", true); - return this.throw(globalThis, signature, "\n\n" ++ "Number of calls: {any}\n", .{calls.getLength(globalThis)}); + if (pass != this.flags.not) { + return .js_undefined; } - const signature = comptime getSignature("toHaveBeenCalledWith", "expected", false); - return this.throw(globalThis, signature, "\n\n" ++ "Number of calls: {any}\n", .{calls.getLength(globalThis)}); + // handle failure + var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; + defer formatter.deinit(); + + const expected_args_js_array = try JSValue.createEmptyArray(globalThis, arguments.len); + for (arguments, 0..) |arg, i| { + try expected_args_js_array.putIndex(globalThis, @intCast(i), arg); + } + expected_args_js_array.ensureStillAlive(); + + if (this.flags.not) { + const signature = comptime getSignature("toHaveBeenCalledWith", "...expected", true); + return this.throw(globalThis, signature, "\n\nExpected mock function not to have been called with: {any}\nBut it was.", .{ + expected_args_js_array.toFmt(&formatter), + }); + } + const signature = comptime getSignature("toHaveBeenCalledWith", "...expected", false); + + if (calls_count == 0) { + return this.throw(globalThis, signature, "\n\nExpected: {any}\nBut it was not called.", .{ + expected_args_js_array.toFmt(&formatter), + }); + } + + // If there's only one call, provide a nice diff. + if (calls_count == 1) { + const received_call_args = try calls.getIndex(globalThis, 0); + const diff_format = DiffFormatter{ + .expected = expected_args_js_array, + .received = received_call_args, + .globalThis = globalThis, + .not = false, + }; + return this.throw(globalThis, signature, "\n\n{any}\n", .{diff_format}); + } + + // If there are multiple calls, list them all to help debugging. + const list_formatter = AllCallsWithArgsFormatter{ + .globalThis = globalThis, + .calls = calls, + .formatter = &formatter, + }; + + const fmt = + \\ Expected: {any} + \\ Received: + \\{any} + \\ + \\ Number of calls: {d} + ; + + switch (Output.enable_ansi_colors) { + inline else => |colors| { + return this.throw(globalThis, signature, Output.prettyFmt("\n\n" ++ fmt ++ "\n", colors), .{ + expected_args_js_array.toFmt(&formatter), + list_formatter, + calls_count, + }); + }, + } } pub fn toHaveBeenLastCalledWith(this: *Expect, globalThis: *JSGlobalObject, callframe: *CallFrame) bun.JSError!JSValue { @@ -4371,13 +4438,15 @@ pub const Expect = struct { const thisValue = callframe.this(); const arguments = callframe.arguments(); defer this.postMatch(globalThis); - const value: JSValue = try this.getValue(globalThis, thisValue, "toHaveBeenLastCalledWith", "expected"); + const value: JSValue = try this.getValue(globalThis, thisValue, "toHaveBeenLastCalledWith", "...expected"); incrementExpectCallCounter(); const calls = try bun.jsc.fromJSHostCall(globalThis, @src(), JSMockFunction__getCalls, .{value}); if (!calls.jsType().isArray()) { - return globalThis.throw("Expected value must be a mock function: {}", .{value}); + var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; + defer formatter.deinit(); + return this.throw(globalThis, comptime getSignature("toHaveBeenLastCalledWith", "...expected", false), "\n\nMatcher error: received value must be a mock function\nReceived: {any}", .{value.toFmt(&formatter)}); } const totalCalls: u32 = @truncate(try calls.getLength(globalThis)); @@ -4389,7 +4458,9 @@ pub const Expect = struct { lastCallValue = try calls.getIndex(globalThis, totalCalls - 1); if (!lastCallValue.jsType().isArray()) { - return globalThis.throw("Expected value must be a mock function with calls: {}", .{value}); + var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; + defer formatter.deinit(); + return globalThis.throw("Expected value must be a mock function with calls: {any}", .{value.toFmt(&formatter)}); } if (try lastCallValue.getLength(globalThis) != arguments.len) { @@ -4405,22 +4476,41 @@ pub const Expect = struct { } } - const not = this.flags.not; - if (not) pass = !pass; - if (pass) return .js_undefined; + if (pass != this.flags.not) { + return .js_undefined; + } // handle failure var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; defer formatter.deinit(); - const received_fmt = lastCallValue.toFmt(&formatter); - if (not) { - const signature = comptime getSignature("toHaveBeenLastCalledWith", "expected", true); - return this.throw(globalThis, signature, "\n\n" ++ "Received: {any}" ++ "\n\n" ++ "Number of calls: {any}\n", .{ received_fmt, totalCalls }); + const expected_args_js_array = try JSValue.createEmptyArray(globalThis, arguments.len); + for (arguments, 0..) |arg, i| { + try expected_args_js_array.putIndex(globalThis, @intCast(i), arg); + } + expected_args_js_array.ensureStillAlive(); + + if (this.flags.not) { + const signature = comptime getSignature("toHaveBeenLastCalledWith", "...expected", true); + return this.throw(globalThis, signature, "\n\nExpected last call not to be with: {any}\nBut it was.", .{ + expected_args_js_array.toFmt(&formatter), + }); + } + const signature = comptime getSignature("toHaveBeenLastCalledWith", "...expected", false); + + if (totalCalls == 0) { + return this.throw(globalThis, signature, "\n\nExpected: {any}\nBut it was not called.", .{ + expected_args_js_array.toFmt(&formatter), + }); } - const signature = comptime getSignature("toHaveBeenLastCalledWith", "expected", false); - return this.throw(globalThis, signature, "\n\n" ++ "Received: {any}" ++ "\n\n" ++ "Number of calls: {any}\n", .{ received_fmt, totalCalls }); + const diff_format = DiffFormatter{ + .expected = expected_args_js_array, + .received = lastCallValue, + .globalThis = globalThis, + .not = false, + }; + return this.throw(globalThis, signature, "\n\n{any}\n", .{diff_format}); } pub fn toHaveBeenNthCalledWith(this: *Expect, globalThis: *JSGlobalObject, callframe: *CallFrame) bun.JSError!JSValue { @@ -4429,38 +4519,45 @@ pub const Expect = struct { const thisValue = callframe.this(); const arguments = callframe.arguments(); defer this.postMatch(globalThis); - const value: JSValue = try this.getValue(globalThis, thisValue, "toHaveBeenNthCalledWith", "expected"); + const value: JSValue = try this.getValue(globalThis, thisValue, "toHaveBeenNthCalledWith", "n, ...expected"); incrementExpectCallCounter(); const calls = try bun.jsc.fromJSHostCall(globalThis, @src(), JSMockFunction__getCalls, .{value}); if (!calls.jsType().isArray()) { - return globalThis.throw("Expected value must be a mock function: {}", .{value}); + var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; + defer formatter.deinit(); + return this.throw(globalThis, comptime getSignature("toHaveBeenNthCalledWith", "n, ...expected", false), "\n\nMatcher error: received value must be a mock function\nReceived: {any}", .{value.toFmt(&formatter)}); } - const nthCallNum = if (arguments.len > 0 and arguments[0].isUInt32AsAnyInt()) try arguments[0].coerce(i32, globalThis) else 0; - if (nthCallNum < 1) { - return globalThis.throwInvalidArguments("toHaveBeenNthCalledWith() requires a positive integer argument", .{}); + if (arguments.len == 0 or !arguments[0].isAnyInt()) { + return globalThis.throwInvalidArguments("toHaveBeenNthCalledWith() requires a positive integer as the first argument", .{}); } + const nthCallNumI32 = arguments[0].toInt32(); - const totalCalls = try calls.getLength(globalThis); + if (nthCallNumI32 <= 0) { + return globalThis.throwInvalidArguments("toHaveBeenNthCalledWith() first argument must be a positive integer", .{}); + } + const nthCallNum: u32 = @intCast(nthCallNumI32); + + const totalCalls = @as(u32, @intCast(try calls.getLength(globalThis))); + var pass = totalCalls >= nthCallNum; var nthCallValue: JSValue = .zero; - var pass = totalCalls >= nthCallNum; - if (pass) { - nthCallValue = try calls.getIndex(globalThis, @as(u32, @intCast(nthCallNum)) - 1); + nthCallValue = try calls.getIndex(globalThis, nthCallNum - 1); + const expected_args = arguments[1..]; if (!nthCallValue.jsType().isArray()) { - return globalThis.throw("Expected value must be a mock function with calls: {}", .{value}); + return globalThis.throw("Internal error: expected mock call item to be an array of arguments.", .{}); } - if (try nthCallValue.getLength(globalThis) != (arguments.len - 1)) { + if (try nthCallValue.getLength(globalThis) != expected_args.len) { pass = false; } else { var itr = try nthCallValue.arrayIterator(globalThis); while (try itr.next()) |callArg| { - if (!try callArg.jestDeepEquals(arguments[itr.i], globalThis)) { + if (!try callArg.jestDeepEquals(expected_args[itr.i - 1], globalThis)) { pass = false; break; } @@ -4468,24 +4565,74 @@ pub const Expect = struct { } } - const not = this.flags.not; - if (not) pass = !pass; - if (pass) return .js_undefined; + if (pass != this.flags.not) { + return .js_undefined; + } // handle failure var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; defer formatter.deinit(); - const received_fmt = nthCallValue.toFmt(&formatter); - if (not) { - const signature = comptime getSignature("toHaveBeenNthCalledWith", "expected", true); - return this.throw(globalThis, signature, "\n\n" ++ "n: {any}\n" ++ "Received: {any}" ++ "\n\n" ++ "Number of calls: {any}\n", .{ nthCallNum, received_fmt, totalCalls }); + const expected_args_slice = arguments[1..]; + const expected_args_js_array = try JSValue.createEmptyArray(globalThis, expected_args_slice.len); + for (expected_args_slice, 0..) |arg, i| { + try expected_args_js_array.putIndex(globalThis, @intCast(i), arg); + } + expected_args_js_array.ensureStillAlive(); + + if (this.flags.not) { + const signature = comptime getSignature("toHaveBeenNthCalledWith", "n, ...expected", true); + return this.throw(globalThis, signature, "\n\nExpected call #{d} not to be with: {any}\nBut it was.", .{ + nthCallNum, + expected_args_js_array.toFmt(&formatter), + }); + } + const signature = comptime getSignature("toHaveBeenNthCalledWith", "n, ...expected", false); + + // Handle case where function was not called enough times + if (totalCalls < nthCallNum) { + return this.throw(globalThis, signature, "\n\nThe mock function was called {d} time{s}, but call {d} was requested.", .{ + totalCalls, + if (totalCalls == 1) "" else "s", + nthCallNum, + }); } - const signature = comptime getSignature("toHaveBeenNthCalledWith", "expected", false); - return this.throw(globalThis, signature, "\n\n" ++ "n: {any}\n" ++ "Received: {any}" ++ "\n\n" ++ "Number of calls: {any}\n", .{ nthCallNum, received_fmt, totalCalls }); + // The call existed but didn't match. Show a diff. + const diff_format = DiffFormatter{ + .expected = expected_args_js_array, + .received = nthCallValue, + .globalThis = globalThis, + .not = false, + }; + return this.throw(globalThis, signature, "\n\nCall #{d}:\n{any}\n", .{ nthCallNum, diff_format }); } + const AllCallsWithArgsFormatter = struct { + globalThis: *JSGlobalObject, + calls: JSValue, + formatter: *jsc.ConsoleObject.Formatter, + + pub fn format(self: @This(), comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void { + var printed_once = false; + + const calls_count = @as(u32, @intCast(try self.calls.getLength(self.globalThis))); + if (calls_count == 0) { + try writer.writeAll("(no calls)"); + return; + } + + for (0..calls_count) |i| { + if (printed_once) try writer.writeAll("\n"); + printed_once = true; + + try writer.print(" {d: >4}: ", .{i + 1}); + const call_args = try self.calls.getIndex(self.globalThis, @intCast(i)); + try writer.print("{any}", .{call_args.toFmt(self.formatter)}); + } + } + }; + const ReturnStatus = enum { throw, @"return", @@ -4494,93 +4641,476 @@ pub const Expect = struct { pub const Map = bun.ComptimeEnumMap(ReturnStatus); }; - inline fn toHaveReturnedTimesFn(this: *Expect, globalThis: *JSGlobalObject, callframe: *CallFrame, comptime known_index: ?i32) bun.JSError!JSValue { + fn jestMockIterator(globalThis: *JSGlobalObject, value: bun.jsc.JSValue) bun.JSError!bun.jsc.JSArrayIterator { + const returns: bun.jsc.JSValue = try bun.cpp.JSMockFunction__getReturns(globalThis, value); + if (!returns.jsType().isArray()) { + var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; + defer formatter.deinit(); + return globalThis.throw("Expected value must be a mock function: {any}", .{value.toFmt(&formatter)}); + } + + return try returns.arrayIterator(globalThis); + } + fn jestMockReturnObject_type(globalThis: *JSGlobalObject, value: bun.jsc.JSValue) bun.JSError!ReturnStatus { + if (try value.fastGet(globalThis, .type)) |type_string| { + if (type_string.isString()) { + if (try ReturnStatus.Map.fromJS(globalThis, type_string)) |val| return val; + } + } + var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; + defer formatter.deinit(); + return globalThis.throw("Expected value must be a mock function with returns: {any}", .{value.toFmt(&formatter)}); + } + fn jestMockReturnObject_value(globalThis: *JSGlobalObject, value: bun.jsc.JSValue) bun.JSError!JSValue { + return (try value.get(globalThis, "value")) orelse .js_undefined; + } + + inline fn toHaveReturnedTimesFn(this: *Expect, globalThis: *JSGlobalObject, callframe: *CallFrame, comptime mode: enum { toHaveReturned, toHaveReturnedTimes }) bun.JSError!JSValue { jsc.markBinding(@src()); const thisValue = callframe.this(); - const arguments = callframe.arguments_old(1).slice(); + const arguments = callframe.arguments(); defer this.postMatch(globalThis); - const name = comptime if (known_index != null and known_index.? == 0) "toHaveReturned" else "toHaveReturnedTimes"; - - const value: JSValue = try this.getValue(globalThis, thisValue, name, if (known_index != null and known_index.? == 0) "" else "expected"); + const value: JSValue = try this.getValue(globalThis, thisValue, @tagName(mode), "expected"); incrementExpectCallCounter(); - const returns = try bun.jsc.fromJSHostCall(globalThis, @src(), JSMockFunction__getReturns, .{value}); - if (!returns.jsType().isArray()) return globalThis.throw("Expected value must be a mock function: {}", .{value}); + var returns = try jestMockIterator(globalThis, value); - const return_count: i32 = if (known_index) |index| index else brk: { + const expected_success_count: i32 = if (mode == .toHaveReturned) brk: { + if (arguments.len > 0 and !arguments[0].isUndefined()) { + return globalThis.throwInvalidArguments(@tagName(mode) ++ "() must not have an argument", .{}); + } + break :brk 1; + } else brk: { if (arguments.len < 1 or !arguments[0].isUInt32AsAnyInt()) { - return globalThis.throwInvalidArguments(name ++ "() requires 1 non-negative integer argument", .{}); + return globalThis.throwInvalidArguments(@tagName(mode) ++ "() requires 1 non-negative integer argument", .{}); } break :brk try arguments[0].coerce(i32, globalThis); }; var pass = false; - const index: u32 = @as(u32, @intCast(return_count)) -| 1; - const times_value = returns.getDirectIndex( - globalThis, - index, - ); - - const total_count = try returns.getLength(globalThis); - - const return_status: ReturnStatus = brk: { - // Returns is an array of: - // - // { type: "throw" | "incomplete" | "return", value: any} - // - if (total_count >= return_count and times_value.isCell()) { - if (try times_value.get(globalThis, "type")) |type_string| { - if (type_string.isString()) { - break :brk try ReturnStatus.Map.fromJS(globalThis, type_string) orelse { - return globalThis.throw("Expected value must be a mock function with returns: {}", .{value}); - }; - } - } + var actual_success_count: i32 = 0; + var total_call_count: i32 = 0; + while (try returns.next()) |item| { + switch (try jestMockReturnObject_type(globalThis, item)) { + .@"return" => actual_success_count += 1, + else => {}, } + total_call_count += 1; + } - break :brk ReturnStatus.incomplete; + pass = switch (mode) { + .toHaveReturned => actual_success_count >= expected_success_count, + .toHaveReturnedTimes => actual_success_count == expected_success_count, }; - if (globalThis.hasException()) - return .zero; - - pass = return_status == ReturnStatus.@"return"; const not = this.flags.not; if (not) pass = !pass; if (pass) return .js_undefined; - if (!pass and return_status == ReturnStatus.throw) { - const signature = comptime getSignature(name, "expected", false); - const fmt = signature ++ "\n\n" ++ "Function threw an exception\n{any}\n"; - var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; - defer formatter.deinit(); - return globalThis.throwPretty(fmt, .{(try times_value.get(globalThis, "value")).?.toFmt(&formatter)}); - } - switch (not) { inline else => |is_not| { - const signature = comptime getSignature(name, "expected", is_not); - return this.throw(globalThis, signature, "\n\n" ++ "Expected number of successful calls: {d}\n" ++ "Received number of calls: {d}\n", .{ return_count, total_count }); + const signature = comptime getSignature(@tagName(mode), "expected", is_not); + const str: []const u8, const spc: []const u8 = switch (mode) { + .toHaveReturned => switch (not) { + false => .{ ">= ", " " }, + true => .{ "< ", " " }, + }, + .toHaveReturnedTimes => switch (not) { + false => .{ "== ", " " }, + true => .{ "!= ", " " }, + }, + }; + return this.throw(globalThis, signature, + \\ + \\ + \\Expected number of succesful returns: {s}{d} + \\Received number of succesful returns: {s}{d} + \\Received number of calls: {s}{d} + \\ + , .{ str, expected_success_count, spc, actual_success_count, spc, total_call_count }); }, } } pub fn toHaveReturned(this: *Expect, globalThis: *JSGlobalObject, callframe: *CallFrame) bun.JSError!JSValue { - return toHaveReturnedTimesFn(this, globalThis, callframe, 1); + return toHaveReturnedTimesFn(this, globalThis, callframe, .toHaveReturned); } pub fn toHaveReturnedTimes(this: *Expect, globalThis: *JSGlobalObject, callframe: *CallFrame) bun.JSError!JSValue { - return toHaveReturnedTimesFn(this, globalThis, callframe, null); + return toHaveReturnedTimesFn(this, globalThis, callframe, .toHaveReturnedTimes); } - pub const toHaveReturnedWith = notImplementedJSCFn; - pub const toHaveLastReturnedWith = notImplementedJSCFn; - pub const toHaveNthReturnedWith = notImplementedJSCFn; + // Formatter for when there are multiple returns or errors + const AllCallsFormatter = struct { + globalThis: *JSGlobalObject, + returns: JSValue, + formatter: *jsc.ConsoleObject.Formatter, + + pub fn format(self: @This(), comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void { + var printed_once = false; + + var num_returns: i32 = 0; + var num_calls: i32 = 0; + + var iter = try self.returns.arrayIterator(self.globalThis); + while (try iter.next()) |item| { + if (printed_once) try writer.writeAll("\n"); + printed_once = true; + + num_calls += 1; + try writer.print(" {d: >2}: ", .{num_calls}); + + const value = try jestMockReturnObject_value(self.globalThis, item); + switch (try jestMockReturnObject_type(self.globalThis, item)) { + .@"return" => { + try writer.print("{any}", .{value.toFmt(self.formatter)}); + num_returns += 1; + }, + .throw => { + try writer.print("function call threw an error: {any}", .{value.toFmt(self.formatter)}); + }, + .incomplete => { + try writer.print("", .{}); + }, + } + } + } + }; + + const SuccessfulReturnsFormatter = struct { + globalThis: *JSGlobalObject, + successful_returns: *const std.ArrayList(JSValue), + formatter: *jsc.ConsoleObject.Formatter, + + pub fn format(self: @This(), comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void { + const len = self.successful_returns.items.len; + if (len == 0) return; + + var printed_once = false; + + for (self.successful_returns.items, 1..) |val, i| { + if (printed_once) try writer.writeAll("\n"); + printed_once = true; + + try writer.print(" {d: >4}: ", .{i}); + try writer.print("{any}", .{val.toFmt(self.formatter)}); + } + } + }; + + pub fn toHaveReturnedWith(this: *Expect, globalThis: *JSGlobalObject, callframe: *CallFrame) bun.JSError!JSValue { + jsc.markBinding(@src()); + + const thisValue = callframe.this(); + defer this.postMatch(globalThis); + + const value: JSValue = try this.getValue(globalThis, thisValue, "toHaveReturnedWith", "expected"); + + const expected = callframe.argumentsAsArray(1)[0]; + incrementExpectCallCounter(); + + const returns = try bun.cpp.JSMockFunction__getReturns(globalThis, value); + if (!returns.jsType().isArray()) { + var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; + defer formatter.deinit(); + return globalThis.throw("Expected value must be a mock function: {any}", .{value.toFmt(&formatter)}); + } + + const calls_count = @as(u32, @intCast(try returns.getLength(globalThis))); + var pass = false; + + var successful_returns = std.ArrayList(JSValue).init(globalThis.bunVM().allocator); + defer successful_returns.deinit(); + + var has_errors = false; + + // Check for a pass and collect info for error messages + for (0..calls_count) |i| { + const result = returns.getDirectIndex(globalThis, @truncate(i)); + + if (result.isObject()) { + const result_type = try result.get(globalThis, "type") orelse .js_undefined; + if (result_type.isString()) { + const type_str = try result_type.toBunString(globalThis); + defer type_str.deref(); + + if (type_str.eqlComptime("return")) { + const result_value = try result.get(globalThis, "value") orelse .js_undefined; + try successful_returns.append(result_value); + + // Check for pass condition only if not already passed + if (!pass) { + if (try result_value.jestDeepEquals(expected, globalThis)) { + pass = true; + } + } + } else if (type_str.eqlComptime("throw")) { + has_errors = true; + } + } + } + } + + if (pass != this.flags.not) { + return .js_undefined; + } + + // Handle failure + var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; + defer formatter.deinit(); + + const signature = comptime getSignature("toHaveReturnedWith", "expected", false); + + if (this.flags.not) { + const not_signature = comptime getSignature("toHaveReturnedWith", "expected", true); + return this.throw(globalThis, not_signature, "\n\n" ++ "Expected mock function not to have returned: {any}\n", .{expected.toFmt(&formatter)}); + } + + // No match was found. + const successful_returns_count = successful_returns.items.len; + + // Case: Only one successful return, no errors + if (calls_count == 1 and successful_returns_count == 1) { + const received = successful_returns.items[0]; + if (expected.isString() and received.isString()) { + const diff_format = DiffFormatter{ + .expected = expected, + .received = received, + .globalThis = globalThis, + .not = false, + }; + return this.throw(globalThis, signature, "\n\n{any}\n", .{diff_format}); + } + + return this.throw(globalThis, signature, "\n\nExpected: {any}\nReceived: {any}", .{ + expected.toFmt(&formatter), + received.toFmt(&formatter), + }); + } + + if (has_errors) { + // Case: Some calls errored + const list_formatter = AllCallsFormatter{ + .globalThis = globalThis, + .returns = returns, + .formatter = &formatter, + }; + const fmt = + \\Some calls errored: + \\ + \\ Expected: {any} + \\ Received: + \\{any} + \\ + \\ Number of returns: {d} + \\ Number of calls: {d} + ; + + switch (Output.enable_ansi_colors) { + inline else => |colors| { + return this.throw(globalThis, signature, Output.prettyFmt("\n\n" ++ fmt ++ "\n", colors), .{ + expected.toFmt(&formatter), + list_formatter, + successful_returns_count, + calls_count, + }); + }, + } + } else { + // Case: No errors, but no match (and multiple returns) + const list_formatter = SuccessfulReturnsFormatter{ + .globalThis = globalThis, + .successful_returns = &successful_returns, + .formatter = &formatter, + }; + const fmt = + \\ Expected: {any} + \\ Received: + \\{any} + \\ + \\ Number of returns: {d} + ; + + switch (Output.enable_ansi_colors) { + inline else => |colors| { + return this.throw(globalThis, signature, Output.prettyFmt("\n\n" ++ fmt ++ "\n", colors), .{ + expected.toFmt(&formatter), + list_formatter, + successful_returns_count, + }); + }, + } + } + } + + pub fn toHaveLastReturnedWith(this: *Expect, globalThis: *JSGlobalObject, callframe: *CallFrame) bun.JSError!JSValue { + jsc.markBinding(@src()); + + const thisValue = callframe.this(); + defer this.postMatch(globalThis); + + const value: JSValue = try this.getValue(globalThis, thisValue, "toHaveBeenLastReturnedWith", "expected"); + + const expected = callframe.argumentsAsArray(1)[0]; + incrementExpectCallCounter(); + + const returns = try bun.cpp.JSMockFunction__getReturns(globalThis, value); + if (!returns.jsType().isArray()) { + var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; + defer formatter.deinit(); + return globalThis.throw("Expected value must be a mock function: {any}", .{value.toFmt(&formatter)}); + } + + const calls_count = @as(u32, @intCast(try returns.getLength(globalThis))); + var pass = false; + var last_return_value: JSValue = .js_undefined; + var last_call_threw = false; + var last_error_value: JSValue = .js_undefined; + + if (calls_count > 0) { + const last_result = returns.getDirectIndex(globalThis, calls_count - 1); + + if (last_result.isObject()) { + const result_type = try last_result.get(globalThis, "type") orelse .js_undefined; + if (result_type.isString()) { + const type_str = try result_type.toBunString(globalThis); + defer type_str.deref(); + + if (type_str.eqlComptime("return")) { + last_return_value = try last_result.get(globalThis, "value") orelse .js_undefined; + + if (try last_return_value.jestDeepEquals(expected, globalThis)) { + pass = true; + } + } else if (type_str.eqlComptime("throw")) { + last_call_threw = true; + last_error_value = try last_result.get(globalThis, "value") orelse .js_undefined; + } + } + } + } + + if (pass != this.flags.not) { + return .js_undefined; + } + + // Handle failure + var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; + defer formatter.deinit(); + + const signature = comptime getSignature("toHaveBeenLastReturnedWith", "expected", false); + + if (this.flags.not) { + return this.throw(globalThis, comptime getSignature("toHaveBeenLastReturnedWith", "expected", true), "\n\n" ++ "Expected mock function not to have last returned: {any}\n" ++ "But it did.\n", .{expected.toFmt(&formatter)}); + } + + if (calls_count == 0) { + return this.throw(globalThis, signature, "\n\n" ++ "The mock function was not called.", .{}); + } + + if (last_call_threw) { + return this.throw(globalThis, signature, "\n\n" ++ "The last call threw an error: {any}\n", .{last_error_value.toFmt(&formatter)}); + } + + // Diff if possible + if (expected.isString() and last_return_value.isString()) { + const diff_format = DiffFormatter{ .expected = expected, .received = last_return_value, .globalThis = globalThis, .not = false }; + return this.throw(globalThis, signature, "\n\n{any}\n", .{diff_format}); + } + + return this.throw(globalThis, signature, "\n\nExpected: {any}\nReceived: {any}", .{ expected.toFmt(&formatter), last_return_value.toFmt(&formatter) }); + } + pub fn toHaveNthReturnedWith(this: *Expect, globalThis: *JSGlobalObject, callframe: *CallFrame) bun.JSError!JSValue { + jsc.markBinding(@src()); + const thisValue = callframe.this(); + defer this.postMatch(globalThis); + const value: JSValue = try this.getValue(globalThis, thisValue, "toHaveNthReturnedWith", "n, expected"); + + const nth_arg, const expected = callframe.argumentsAsArray(2); + + // Validate n is a number + if (!nth_arg.isAnyInt()) { + return globalThis.throwInvalidArguments("toHaveNthReturnedWith() first argument must be an integer", .{}); + } + + const n = nth_arg.toInt32(); + if (n <= 0) { + return globalThis.throwInvalidArguments("toHaveNthReturnedWith() n must be greater than 0", .{}); + } + + incrementExpectCallCounter(); + const returns = try bun.cpp.JSMockFunction__getReturns(globalThis, value); + if (!returns.jsType().isArray()) { + var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; + defer formatter.deinit(); + return globalThis.throw("Expected value must be a mock function: {any}", .{value.toFmt(&formatter)}); + } + + const calls_count = @as(u32, @intCast(try returns.getLength(globalThis))); + const index = @as(u32, @intCast(n - 1)); // Convert to 0-based index + + var pass = false; + var nth_return_value: JSValue = .js_undefined; + var nth_call_threw = false; + var nth_error_value: JSValue = .js_undefined; + var nth_call_exists = false; + + if (index < calls_count) { + nth_call_exists = true; + const nth_result = returns.getDirectIndex(globalThis, index); + if (nth_result.isObject()) { + const result_type = try nth_result.get(globalThis, "type") orelse .js_undefined; + if (result_type.isString()) { + const type_str = try result_type.toBunString(globalThis); + defer type_str.deref(); + if (type_str.eqlComptime("return")) { + nth_return_value = try nth_result.get(globalThis, "value") orelse .js_undefined; + if (try nth_return_value.jestDeepEquals(expected, globalThis)) { + pass = true; + } + } else if (type_str.eqlComptime("throw")) { + nth_call_threw = true; + nth_error_value = try nth_result.get(globalThis, "value") orelse .js_undefined; + } + } + } + } + + if (pass != this.flags.not) { + return .js_undefined; + } + + // Handle failure + var formatter = jsc.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true }; + defer formatter.deinit(); + + const signature = comptime getSignature("toHaveNthReturnedWith", "n, expected", false); + + if (this.flags.not) { + return this.throw(globalThis, comptime getSignature("toHaveNthReturnedWith", "n, expected", true), "\n\n" ++ "Expected mock function not to have returned on call {d}: {any}\n" ++ "But it did.\n", .{ n, expected.toFmt(&formatter) }); + } + + if (!nth_call_exists) { + return this.throw(globalThis, signature, "\n\n" ++ "The mock function was called {d} time{s}, but call {d} was requested.\n", .{ calls_count, if (calls_count == 1) "" else "s", n }); + } + + if (nth_call_threw) { + return this.throw(globalThis, signature, "\n\n" ++ "Call {d} threw an error: {any}\n", .{ n, nth_error_value.toFmt(&formatter) }); + } + + // Diff if possible + if (expected.isString() and nth_return_value.isString()) { + const diff_format = DiffFormatter{ .expected = expected, .received = nth_return_value, .globalThis = globalThis, .not = false }; + return this.throw(globalThis, signature, "\n\nCall {d}:\n{any}\n", .{ n, diff_format }); + } + + return this.throw(globalThis, signature, "\n\nCall {d}:\nExpected: {any}\nReceived: {any}", .{ n, expected.toFmt(&formatter), nth_return_value.toFmt(&formatter) }); + } pub fn getStaticNot(globalThis: *JSGlobalObject, _: JSValue, _: JSValue) bun.JSError!JSValue { return ExpectStatic.create(globalThis, .{ .not = true }); diff --git a/src/codegen/generate-classes.ts b/src/codegen/generate-classes.ts index 5cb5038cb8..24bf02b37d 100644 --- a/src/codegen/generate-classes.ts +++ b/src/codegen/generate-classes.ts @@ -2620,7 +2620,7 @@ fn log_zig_getter(typename: []const u8, property_name: []const u8) callconv(bun. fn log_zig_setter(typename: []const u8, property_name: []const u8, value: jsc.JSValue) callconv(bun.callconv_inline) void { if (comptime Environment.enable_logs) { - zig("set {s}.{s} = {}", .{typename, property_name, value}); + zig("set {s}.{s} = {?s}", .{typename, property_name, bun.tagName(jsc.JSValue, value)}); } } diff --git a/src/codegen/shared-types.ts b/src/codegen/shared-types.ts index 0307c9c322..2c05247b9c 100644 --- a/src/codegen/shared-types.ts +++ b/src/codegen/shared-types.ts @@ -38,6 +38,7 @@ export const sharedTypes: Record = { // Common Bun types "BunString": "bun.String", "JSC::EncodedJSValue": "jsc.JSValue", + "EncodedJSValue": "jsc.JSValue", "JSC::JSGlobalObject": "jsc.JSGlobalObject", "ZigException": "jsc.ZigException", "Inspector::InspectorHTTPServerAgent": "HTTPServerAgent.InspectorHTTPServerAgent", diff --git a/test/bun.lock b/test/bun.lock index d42fcf7838..8b2d0aabbb 100644 --- a/test/bun.lock +++ b/test/bun.lock @@ -47,6 +47,7 @@ "http2-wrapper": "2.2.1", "https-proxy-agent": "7.0.5", "iconv-lite": "0.6.3", + "immutable": "5.1.3", "isbot": "5.1.13", "jest-extended": "4.0.0", "jimp": "1.6.0", @@ -1515,7 +1516,7 @@ "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], - "immutable": ["immutable@4.3.7", "", {}, "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw=="], + "immutable": ["immutable@5.1.3", "", {}, "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg=="], "import-fresh": ["import-fresh@3.3.0", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw=="], @@ -3191,6 +3192,8 @@ "sass/chokidar": ["chokidar@4.0.1", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA=="], + "sass/immutable": ["immutable@4.3.7", "", {}, "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw=="], + "schema-utils/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "send/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], diff --git a/test/internal/ban-limits.json b/test/internal/ban-limits.json index a0fa9945b4..bb887034c6 100644 --- a/test/internal/ban-limits.json +++ b/test/internal/ban-limits.json @@ -3,7 +3,7 @@ " == undefined": 0, "!= alloc.ptr": 0, "!= allocator.ptr": 0, - ".arguments_old(": 280, + ".arguments_old(": 279, ".stdDir()": 40, ".stdFile()": 18, "// autofix": 168, @@ -18,7 +18,7 @@ "allocator.ptr ==": 0, "global.hasException": 28, "globalObject.hasException": 42, - "globalThis.hasException": 134, + "globalThis.hasException": 133, "std.StringArrayHashMap(": 1, "std.StringArrayHashMapUnmanaged(": 12, "std.StringHashMap(": 0, diff --git a/test/js/bun/test/expect-toHaveReturnedWith.test.js b/test/js/bun/test/expect-toHaveReturnedWith.test.js new file mode 100644 index 0000000000..50a71069a0 --- /dev/null +++ b/test/js/bun/test/expect-toHaveReturnedWith.test.js @@ -0,0 +1,195 @@ +import { expect, jest, test } from "bun:test"; + +test("toHaveReturnedWith basic functionality", () => { + const mockFn = jest.fn(() => "La Croix"); + + // Function hasn't been called yet + expect(() => { + expect(mockFn).toHaveReturnedWith("La Croix"); + }).toThrow(); + + // Call the function + mockFn(); + + // Should pass - the function returned 'La Croix' + expect(mockFn).toHaveReturnedWith("La Croix"); + + // Should fail - the function didn't return this value + expect(() => { + expect(mockFn).toHaveReturnedWith("Pepsi"); + }).toThrow(); +}); + +test("toHaveReturnedWith with multiple returns", () => { + const mockFn = jest.fn(); + + mockFn.mockReturnValueOnce("first"); + mockFn.mockReturnValueOnce("second"); + mockFn.mockReturnValueOnce("third"); + + // Call the function multiple times + mockFn(); + mockFn(); + mockFn(); + + // Should pass for all returned values + expect(mockFn).toHaveReturnedWith("first"); + expect(mockFn).toHaveReturnedWith("second"); + expect(mockFn).toHaveReturnedWith("third"); + + // Should fail for values not returned + expect(() => { + expect(mockFn).toHaveReturnedWith("fourth"); + }).toThrow(); +}); + +test("toHaveReturnedWith with objects", () => { + const obj = { name: "La Croix" }; + const mockFn = jest.fn(() => obj); + + mockFn(); + + // Should pass with deep equality + expect(mockFn).toHaveReturnedWith({ name: "La Croix" }); + + // Should also pass with same object reference + expect(mockFn).toHaveReturnedWith(obj); + + // Should fail with different object + expect(() => { + expect(mockFn).toHaveReturnedWith({ name: "Pepsi" }); + }).toThrow(); +}); + +test("toHaveReturnedWith with arrays", () => { + const mockFn = jest.fn(() => [1, 2, 3]); + + mockFn(); + + // Should pass with deep equality + expect(mockFn).toHaveReturnedWith([1, 2, 3]); + + // Should fail with different array + expect(() => { + expect(mockFn).toHaveReturnedWith([1, 2, 4]); + }).toThrow(); +}); + +test("toHaveReturnedWith with undefined and null", () => { + const mockFn = jest.fn(); + + mockFn(); // returns undefined by default + mockFn.mockReturnValueOnce(null); + mockFn(); + + expect(mockFn).toHaveReturnedWith(undefined); + expect(mockFn).toHaveReturnedWith(null); +}); + +test("toHaveReturnedWith with thrown errors", () => { + const mockFn = jest.fn(); + + mockFn.mockReturnValueOnce("success"); + mockFn(); // returns 'success' + + // Mock a throw for the next call + mockFn.mockImplementationOnce(() => { + throw new Error("Failed"); + }); + + expect(() => mockFn()).toThrow("Failed"); + + // Should still pass for the successful return + expect(mockFn).toHaveReturnedWith("success"); + + // But not for values that were never returned + expect(() => { + expect(mockFn).toHaveReturnedWith("Failed"); + }).toThrow(); +}); + +test("toHaveReturnedWith with .not modifier", () => { + const mockFn = jest.fn(() => "La Croix"); + + mockFn(); + + // Should pass - the function didn't return 'Pepsi' + expect(mockFn).not.toHaveReturnedWith("Pepsi"); + + // Should fail - the function did return 'La Croix' + expect(() => { + expect(mockFn).not.toHaveReturnedWith("La Croix"); + }).toThrow(); +}); + +test("drink returns La Croix example from Jest docs", () => { + const beverage = { name: "La Croix" }; + const drink = jest.fn(beverage => beverage.name); + + drink(beverage); + + expect(drink).toHaveReturnedWith("La Croix"); +}); + +test("toHaveReturnedWith with primitive values", () => { + const mockFn = jest.fn(); + + mockFn.mockReturnValueOnce(42); + mockFn.mockReturnValueOnce(true); + mockFn.mockReturnValueOnce("hello"); + mockFn.mockReturnValueOnce(3.14); + + mockFn(); + mockFn(); + mockFn(); + mockFn(); + + expect(mockFn).toHaveReturnedWith(42); + expect(mockFn).toHaveReturnedWith(true); + expect(mockFn).toHaveReturnedWith("hello"); + expect(mockFn).toHaveReturnedWith(3.14); + + // Should fail for values not returned + expect(() => { + expect(mockFn).toHaveReturnedWith(false); + }).toThrow(); + + expect(() => { + expect(mockFn).toHaveReturnedWith(43); + }).toThrow(); +}); + +test("toHaveReturnedWith should require a mock function", () => { + const notAMock = () => "La Croix"; + + expect(() => { + expect(notAMock).toHaveReturnedWith("La Croix"); + }).toThrow("Expected value must be a mock function"); +}); + +test("toHaveReturnedWith should require an argument", () => { + const mockFn = jest.fn(() => "La Croix"); + mockFn(); + + expect(() => { + // @ts-expect-error - testing invalid usage + expect(mockFn).toHaveReturnedWith(); + }).toThrow(); +}); + +test("toHaveReturnedWith with promises using async/await", async () => { + const mockFn = jest.fn(async () => "async result"); + + await mockFn(); + + expect(mockFn).toHaveReturnedWith(expect.any(Promise)); +}); + +test("toHaveReturnedWith checks the resolved value, not the promise", async () => { + const mockFn = jest.fn(async () => "async result"); + + await mockFn(); + + // The mock tracks the promise as the return value, not the resolved value + expect(mockFn).not.toHaveReturnedWith("async result"); +}); diff --git a/test/js/bun/test/expect/toHaveReturnedWith.test.ts b/test/js/bun/test/expect/toHaveReturnedWith.test.ts new file mode 100644 index 0000000000..b9563a1a6f --- /dev/null +++ b/test/js/bun/test/expect/toHaveReturnedWith.test.ts @@ -0,0 +1,503 @@ +import { beforeEach, describe, expect, jest, test } from "bun:test"; + +// Example functions for testing toHaveReturnedWith +export function add(a: number, b: number): number { + return a + b; +} + +export function multiply(a: number, b: number): number { + return a * b; +} + +export function greet(name: string): string { + return `Hello, ${name}!`; +} + +export function getRandomNumber(): number { + return Math.floor(Math.random() * 100); +} + +export function createUser(name: string, age: number): { name: string; age: number } { + return { name, age }; +} + +console.log("Hello via Bun!"); + +describe("toHaveReturnedWith Examples", () => { + let mockAdd: ReturnType; + let mockMultiply: ReturnType; + let mockGreet: ReturnType; + let mockGetRandomNumber: ReturnType; + let mockCreateUser: ReturnType; + + beforeEach(() => { + // Reset all mocks before each test + mockAdd = jest.fn(add); + mockMultiply = jest.fn(multiply); + mockGreet = jest.fn(greet); + mockGetRandomNumber = jest.fn(getRandomNumber); + mockCreateUser = jest.fn(createUser); + }); + + describe("Success Cases - toHaveReturnedWith", () => { + test("should pass when function returns expected number", () => { + const result = mockAdd(2, 3); + expect(mockAdd).toHaveReturnedWith(5); + expect(result).toBe(5); + }); + + test("should pass when function returns expected string", () => { + const result = mockGreet("Alice"); + expect(mockGreet).toHaveReturnedWith("Hello, Alice!"); + expect(result).toBe("Hello, Alice!"); + }); + + test("should pass when function returns expected object", () => { + const result = mockCreateUser("Bob", 30); + expect(mockCreateUser).toHaveReturnedWith({ name: "Bob", age: 30 }); + expect(result).toEqual({ name: "Bob", age: 30 }); + }); + + test("should pass when function returns expected value after multiple calls", () => { + mockMultiply(2, 3); // Returns 6 + mockMultiply(4, 5); // Returns 20 + mockMultiply(1, 1); // Returns 1 + + expect(mockMultiply).toHaveReturnedWith(6); + expect(mockMultiply).toHaveReturnedWith(20); + expect(mockMultiply).toHaveReturnedWith(1); + }); + + test("should pass with exact array match", () => { + const mockArrayFunction = jest.fn(() => [1, 2, 3]); + const result = mockArrayFunction(); + expect(mockArrayFunction).toHaveReturnedWith([1, 2, 3]); + expect(result).toEqual([1, 2, 3]); + }); + + test("should pass with null return value", () => { + const mockNullFunction = jest.fn(() => null); + const result = mockNullFunction(); + expect(mockNullFunction).toHaveReturnedWith(null); + expect(result).toBeNull(); + }); + + test("should pass with undefined return value", () => { + const mockUndefinedFunction = jest.fn(() => undefined); + const result = mockUndefinedFunction(); + expect(mockUndefinedFunction).toHaveReturnedWith(undefined); + expect(result).toBeUndefined(); + }); + }); + + describe("Fail Cases - toHaveReturnedWith", () => { + test("should fail when function returns different number", () => { + const result = mockAdd(2, 3); + // This will fail because add(2, 3) returns 5, not 10 + expect(() => { + expect(mockAdd).toHaveReturnedWith(10); + }).toThrow(); + }); + + test("should fail when function returns different string", () => { + const result = mockGreet("Alice"); + // This will fail because greet("Alice") returns "Hello, Alice!", not "Hi, Alice!" + expect(() => { + expect(mockGreet).toHaveReturnedWith("Hi, Alice!"); + }).toThrow(); + }); + + test("should fail when function returns different object", () => { + const result = mockCreateUser("Bob", 30); + // This will fail because the returned object has different age + expect(() => { + expect(mockCreateUser).toHaveReturnedWith({ name: "Bob", age: 25 }); + }).toThrow(); + }); + + test("should fail when function was never called", () => { + // mockAdd was never called, so this will fail + expect(() => { + expect(mockAdd).toHaveReturnedWith(5); + }).toThrow(); + }); + + test("should fail when function returns different array", () => { + const mockArrayFunction = jest.fn(() => [1, 2, 3]); + const result = mockArrayFunction(); + // This will fail because the expected array is different + expect(() => { + expect(mockArrayFunction).toHaveReturnedWith([1, 2, 4]); + }).toThrow(); + }); + + test("should fail when expecting null but function returns value", () => { + const result = mockAdd(2, 3); + // This will fail because add returns 5, not null + expect(() => { + expect(mockAdd).toHaveReturnedWith(null); + }).toThrow(); + }); + + test("should fail when expecting value but function returns null", () => { + const mockNullFunction = jest.fn(() => null); + const result = mockNullFunction(); + // This will fail because function returns null, not 5 + expect(() => { + expect(mockNullFunction).toHaveReturnedWith(5); + }).toThrow(); + }); + }); + + describe("Edge Cases and Advanced Examples", () => { + test("should work with multiple return values in sequence", () => { + mockAdd(1, 1); // Returns 2 + mockAdd(2, 2); // Returns 4 + mockAdd(3, 3); // Returns 6 + + // All these should pass + expect(mockAdd).toHaveReturnedWith(2); + expect(mockAdd).toHaveReturnedWith(4); + expect(mockAdd).toHaveReturnedWith(6); + }); + + test("should work with complex objects", () => { + const mockComplexFunction = jest.fn(() => ({ + id: 1, + name: "Test", + metadata: { + createdAt: "2024-01-01", + tags: ["tag1", "tag2"], + }, + })); + + const result = mockComplexFunction(); + expect(mockComplexFunction).toHaveReturnedWith({ + id: 1, + name: "Test", + metadata: { + createdAt: "2024-01-01", + tags: ["tag1", "tag2"], + }, + }); + }); + + test("should fail with partial object match", () => { + const mockComplexFunction = jest.fn(() => ({ + id: 1, + name: "Test", + metadata: { + createdAt: "2024-01-01", + tags: ["tag1", "tag2"], + }, + })); + + const result = mockComplexFunction(); + // This will fail because the expected object is missing the metadata property + expect(() => { + expect(mockComplexFunction).toHaveReturnedWith({ + id: 1, + name: "Test", + }); + }).toThrow(); + }); + + test("should work with functions that return functions", () => { + const mockFunctionFactory = jest.fn(() => (x: number) => x * 2); + const result = mockFunctionFactory(); + + expect(mockFunctionFactory).toHaveReturnedWith(expect.any(Function)); + expect(result(5)).toBe(10); + }); + }); + + describe("Common Mistakes and How to Avoid Them", () => { + test("mistake: checking return value instead of using toHaveReturnedWith", () => { + const result = mockAdd(2, 3); + + // ❌ Wrong way - checking the result directly + expect(result).toBe(5); + + // ✅ Correct way - checking that the mock returned the expected value + expect(mockAdd).toHaveReturnedWith(5); + }); + + test("mistake: not calling the function before checking toHaveReturnedWith", () => { + // ❌ This will fail because the function was never called + expect(() => { + expect(mockAdd).toHaveReturnedWith(5); + }).toThrow(); + + // ✅ Correct way - call the function first + mockAdd(2, 3); + expect(mockAdd).toHaveReturnedWith(5); + }); + + test("mistake: using toHaveReturnedWith on non-mock functions", () => { + // ❌ This won't work because add is not a mock + const result = add(2, 3); + expect(() => { + expect(add).toHaveReturnedWith(5); + }).toThrow(); + + // ✅ Correct way - use the mock + const mockResult = mockAdd(2, 3); + expect(mockAdd).toHaveReturnedWith(5); + }); + }); +}); + +describe("toHaveLastReturnedWith Examples", () => { + let mockAdd: ReturnType; + let mockMultiply: ReturnType; + let mockGreet: ReturnType; + let mockGetRandomNumber: ReturnType; + let mockCreateUser: ReturnType; + let mockDrink: ReturnType; + + beforeEach(() => { + // Reset all mocks before each test + mockAdd = jest.fn(add); + mockMultiply = jest.fn(multiply); + mockGreet = jest.fn(greet); + mockGetRandomNumber = jest.fn(getRandomNumber); + mockCreateUser = jest.fn(createUser); + mockDrink = jest.fn((beverage: { name: string }) => beverage.name); + }); + + describe("Success Cases - toHaveLastReturnedWith", () => { + test("should pass when last call returns expected value", () => { + mockAdd(1, 1); // Returns 2 + mockAdd(2, 3); // Returns 5 + mockAdd(3, 4); // Returns 7 - last call + + expect(mockAdd).toHaveLastReturnedWith(7); + }); + + test("should pass when last call returns expected string", () => { + mockGreet("Alice"); // Returns "Hello, Alice!" + mockGreet("Bob"); // Returns "Hello, Bob!" + mockGreet("Carol"); // Returns "Hello, Carol!" - last call + + expect(mockGreet).toHaveLastReturnedWith("Hello, Carol!"); + }); + + test("should pass when last call returns expected object", () => { + mockCreateUser("Alice", 25); + mockCreateUser("Bob", 30); + mockCreateUser("Carol", 35); // Last call + + expect(mockCreateUser).toHaveLastReturnedWith({ name: "Carol", age: 35 }); + }); + + test("drink returns La Croix (Orange) last", () => { + const beverage1 = { name: "La Croix (Lemon)" }; + const beverage2 = { name: "La Croix (Orange)" }; + + mockDrink(beverage1); + mockDrink(beverage2); + + expect(mockDrink).toHaveLastReturnedWith("La Croix (Orange)"); + }); + + test("should pass with single call", () => { + mockMultiply(5, 6); // Only one call, returns 30 + + expect(mockMultiply).toHaveLastReturnedWith(30); + }); + + test("should pass with null as last return value", () => { + const mockNullFunction = jest.fn().mockReturnValueOnce(5).mockReturnValueOnce("test").mockReturnValueOnce(null); + + mockNullFunction(); + mockNullFunction(); + mockNullFunction(); // Returns null + + expect(mockNullFunction).toHaveLastReturnedWith(null); + }); + + test("should pass with undefined as last return value", () => { + const mockUndefinedFunction = jest.fn().mockReturnValueOnce(10).mockReturnValueOnce(undefined); + + mockUndefinedFunction(); + mockUndefinedFunction(); // Returns undefined + + expect(mockUndefinedFunction).toHaveLastReturnedWith(undefined); + }); + + test("should pass with array as last return value", () => { + const mockArrayFunction = jest.fn(); + mockArrayFunction.mockReturnValueOnce([1, 2]); + mockArrayFunction.mockReturnValueOnce([3, 4, 5]); + + mockArrayFunction(); + mockArrayFunction(); // Returns [3, 4, 5] + + expect(mockArrayFunction).toHaveLastReturnedWith([3, 4, 5]); + }); + }); + + describe("Fail Cases - toHaveLastReturnedWith", () => { + test("should fail when last call returns different value", () => { + mockAdd(1, 1); // Returns 2 + mockAdd(2, 3); // Returns 5 + mockAdd(3, 4); // Returns 7 - last call + + // This will fail because last call returned 7, not 5 + expect(() => { + expect(mockAdd).toHaveLastReturnedWith(5); + }).toThrow(); + }); + + test("should fail when checking non-last return value", () => { + mockGreet("Alice"); // Returns "Hello, Alice!" + mockGreet("Bob"); // Returns "Hello, Bob!" - last call + + // This will fail because last call returned "Hello, Bob!", not "Hello, Alice!" + expect(() => { + expect(mockGreet).toHaveLastReturnedWith("Hello, Alice!"); + }).toThrow(); + }); + + test("should fail when function was never called", () => { + // mockAdd was never called + expect(() => { + expect(mockAdd).toHaveLastReturnedWith(5); + }).toThrow(); + }); + + test("should fail when last call threw an error", () => { + const mockThrowFunction = jest + .fn() + .mockReturnValueOnce(5) + .mockImplementationOnce(() => { + throw new Error("Test error"); + }); + + mockThrowFunction(); // Returns 5 + + // Last call will throw + expect(() => { + mockThrowFunction(); + }).toThrow("Test error"); + + // This will fail because last call threw an error + expect(() => { + expect(mockThrowFunction).toHaveLastReturnedWith(5); + }).toThrow(); + }); + + test("should fail with wrong object in last call", () => { + mockCreateUser("Alice", 25); + mockCreateUser("Bob", 30); // Last call + + // This will fail because last call returned Bob, not Alice + expect(() => { + expect(mockCreateUser).toHaveLastReturnedWith({ name: "Alice", age: 25 }); + }).toThrow(); + }); + + test("should fail with wrong array in last call", () => { + const mockArrayFunction = jest.fn(); + mockArrayFunction.mockReturnValueOnce([1, 2]); + mockArrayFunction.mockReturnValueOnce([3, 4, 5]); + + mockArrayFunction(); + mockArrayFunction(); // Returns [3, 4, 5] + + // This will fail because last call returned [3, 4, 5], not [1, 2] + expect(() => { + expect(mockArrayFunction).toHaveLastReturnedWith([1, 2]); + }).toThrow(); + }); + }); + + describe("Edge Cases - toHaveLastReturnedWith", () => { + test("should work with functions returning functions", () => { + const mockFunctionFactory = jest.fn(); + const fn1 = (x: number) => x * 2; + const fn2 = (x: number) => x * 3; + + mockFunctionFactory.mockReturnValueOnce(fn1); + mockFunctionFactory.mockReturnValueOnce(fn2); + + mockFunctionFactory(); + const lastResult = mockFunctionFactory(); // Returns fn2 + + expect(mockFunctionFactory).toHaveLastReturnedWith(fn2); + expect(lastResult(5)).toBe(15); // 5 * 3 + }); + + test("should work with complex nested objects", () => { + const mockComplexFunction = jest.fn(); + const obj1 = { id: 1, data: { nested: { value: 10 } } }; + const obj2 = { id: 2, data: { nested: { value: 20 } } }; + + mockComplexFunction.mockReturnValueOnce(obj1); + mockComplexFunction.mockReturnValueOnce(obj2); + + mockComplexFunction(); + mockComplexFunction(); // Returns obj2 + + expect(mockComplexFunction).toHaveLastReturnedWith({ + id: 2, + data: { nested: { value: 20 } }, + }); + }); + + test("should distinguish between similar values in sequence", () => { + mockAdd(1, 1); // Returns 2 + mockAdd(1, 1); // Returns 2 + mockAdd(2, 0); // Returns 2 - last call + + // All calls return 2, but toHaveLastReturnedWith should still pass + expect(mockAdd).toHaveLastReturnedWith(2); + }); + + test("should work after many calls", () => { + // Make 100 calls + for (let i = 0; i < 100; i++) { + mockMultiply(i, 2); // Returns i * 2 + } + + // Last call was mockMultiply(99, 2) which returns 198 + expect(mockMultiply).toHaveLastReturnedWith(198); + }); + + test("should handle symbol return values", () => { + const sym1 = Symbol("first"); + const sym2 = Symbol("last"); + const mockSymbolFunction = jest.fn(); + + mockSymbolFunction.mockReturnValueOnce(sym1); + mockSymbolFunction.mockReturnValueOnce(sym2); + + mockSymbolFunction(); + mockSymbolFunction(); // Returns sym2 + + expect(mockSymbolFunction).toHaveLastReturnedWith(sym2); + }); + }); + + describe("Comparison with toHaveReturnedWith", () => { + test("toHaveReturnedWith checks any call, toHaveLastReturnedWith checks only last", () => { + mockAdd(1, 1); // Returns 2 + mockAdd(2, 3); // Returns 5 + mockAdd(3, 4); // Returns 7 - last call + + // toHaveReturnedWith passes for any return value + expect(mockAdd).toHaveReturnedWith(2); + expect(mockAdd).toHaveReturnedWith(5); + expect(mockAdd).toHaveReturnedWith(7); + + // toHaveLastReturnedWith only passes for the last return value + expect(mockAdd).toHaveLastReturnedWith(7); + expect(() => { + expect(mockAdd).toHaveLastReturnedWith(2); + }).toThrow(); + expect(() => { + expect(mockAdd).toHaveLastReturnedWith(5); + }).toThrow(); + }); + }); +}); diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index 6f67cefa1f..128f1fa110 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -164,10 +164,72 @@ describe("mock()", () => { try { expect(func2).toHaveReturned(); } catch (e) { - expect(e.message).toContain("Function threw an exception"); + expect(e.message.replaceAll(/\x1B\[[0-9;]*m/g, "")).toMatchInlineSnapshot(` + "expect(received).toHaveReturned(expected) + + Expected number of succesful returns: >= 1 + Received number of succesful returns: 0 + Received number of calls: 1 + " + `); } }); + test("toHaveNthReturnedWith", () => { + const fn = jest.fn(); + + // Test when function hasn't been called + expect(() => expect(fn).toHaveNthReturnedWith(1, "value")).toThrow(); + + // Call with different return values + fn.mockReturnValueOnce("first"); + fn.mockReturnValueOnce("second"); + fn.mockReturnValueOnce("third"); + + fn(); + fn(); + fn(); + + // Test positive cases + expect(fn).toHaveNthReturnedWith(1, "first"); + expect(fn).toHaveNthReturnedWith(2, "second"); + expect(fn).toHaveNthReturnedWith(3, "third"); + + // Test negative cases + expect(fn).not.toHaveNthReturnedWith(1, "wrong"); + expect(fn).not.toHaveNthReturnedWith(2, "wrong"); + expect(fn).not.toHaveNthReturnedWith(3, "wrong"); + + // Test out of bounds + expect(() => expect(fn).toHaveNthReturnedWith(4, "value")).toThrow(); + expect(() => expect(fn).toHaveNthReturnedWith(0, "value")).toThrow(); + expect(() => expect(fn).toHaveNthReturnedWith(-1, "value")).toThrow(); + + // Test with objects + const obj1 = { a: 1 }; + const obj2 = { b: 2 }; + fn.mockReturnValueOnce(obj1); + fn.mockReturnValueOnce(obj2); + + fn(); + fn(); + + expect(fn).toHaveNthReturnedWith(4, obj1); + expect(fn).toHaveNthReturnedWith(5, obj2); + + // Test with thrown errors + const error = new Error("test error"); + fn.mockImplementationOnce(() => { + throw error; + }); + + try { + fn(); + } catch (e) {} + + expect(() => expect(fn).toHaveNthReturnedWith(6, "value")).toThrow(); + }); + test("passes this value", () => { const fn = jest.fn(function hey() { "use strict"; diff --git a/test/js/bun/test/spyMatchers.test.ts b/test/js/bun/test/spyMatchers.test.ts new file mode 100644 index 0000000000..a9e2ebb98b --- /dev/null +++ b/test/js/bun/test/spyMatchers.test.ts @@ -0,0 +1,1324 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. +Copyright Contributors to the Jest project. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + */ + +import { describe, expect, jest, expect as jestExpect, test } from "bun:test"; +import * as Immutable from "immutable"; +import type { FunctionLike } from "jest-mock"; + +jestExpect.extend({ + optionalFn(fn?: unknown) { + const pass = fn === undefined || typeof fn === "function"; + return { message: () => "expect either a function or undefined", pass }; + }, +}); + +// Given a Jest mock function, return a minimal mock of a spy. +const createSpy = (fn: jest.Mock): jest.Mock => { + const spy = function () {}; + + spy.calls = { + all() { + return fn.mock.calls.map(args => ({ args })); + }, + count() { + return fn.mock.calls.length; + }, + }; + + return spy as unknown as jest.Mock; +}; + +describe("toHaveBeenCalled", () => { + test("works only on spies or jest.fn", () => { + const fn = function fn() {}; + + expect(() => jestExpect(fn).toHaveBeenCalled()).toThrow(); + }); + + test("passes when called", () => { + const fn = jest.fn(); + fn("arg0", "arg1", "arg2"); + // jestExpect(createSpy(fn)).toHaveBeenCalled(); + jestExpect(fn).toHaveBeenCalled(); + expect(() => jestExpect(fn).not.toHaveBeenCalled()).toThrow(); + }); + + test(".not passes when called", () => { + const fn = jest.fn(); + // const spy = createSpy(fn); + + // jestExpect(spy).not.toHaveBeenCalled(); + jestExpect(fn).not.toHaveBeenCalled(); + // expect(() => jestExpect(spy).toHaveBeenCalled()).toThrow(); + expect(() => jestExpect(fn).toHaveBeenCalled()).toThrow(); + }); + + test("fails with any argument passed", () => { + const fn = jest.fn(); + + fn(); + expect(() => + // @ts-expect-error: Testing runtime error + jestExpect(fn).toHaveBeenCalled(555), + ).toThrow(); + }); + + test(".not fails with any argument passed", () => { + const fn = jest.fn(); + + expect(() => + // @ts-expect-error: Testing runtime error + jestExpect(fn).not.toHaveBeenCalled(555), + ).toThrow(); + }); + + test("includes the custom mock name in the error message", () => { + const fn = jest.fn().mockName("named-mock"); + + fn(); + jestExpect(fn).toHaveBeenCalled(); + expect(() => jestExpect(fn).not.toHaveBeenCalled()).toThrow(); + }); +}); + +describe("toHaveBeenCalledTimes", () => { + test(".not works only on spies or jest.fn", () => { + const fn = function fn() {}; + + expect(() => jestExpect(fn).not.toHaveBeenCalledTimes(2)).toThrow(); + }); + + test("only accepts a number argument", () => { + const fn = jest.fn(); + fn(); + jestExpect(fn).toHaveBeenCalledTimes(1); + + for (const value of [{}, [], true, "a", new Map(), () => {}]) { + expect(() => + // @ts-expect-error: Testing runtime error + jestExpect(fn).toHaveBeenCalledTimes(value), + ).toThrow(); + } + }); + + test(".not only accepts a number argument", () => { + const fn = jest.fn(); + jestExpect(fn).not.toHaveBeenCalledTimes(1); + + for (const value of [{}, [], true, "a", new Map(), () => {}]) { + expect(() => + // @ts-expect-error: Testing runtime error + jestExpect(fn).not.toHaveBeenCalledTimes(value), + ).toThrow(); + } + }); + + test("passes if function called equal to expected times", () => { + const fn = jest.fn(); + fn(); + fn(); + + // const spy = createSpy(fn); + // jestExpect(spy).toHaveBeenCalledTimes(2); + jestExpect(fn).toHaveBeenCalledTimes(2); + + // expect(() => jestExpect(spy).not.toHaveBeenCalledTimes(2)).toThrow(); + expect(() => jestExpect(fn).not.toHaveBeenCalledTimes(2)).toThrow(); + }); + + test(".not passes if function called more than expected times", () => { + const fn = jest.fn(); + fn(); + fn(); + fn(); + + // const spy = createSpy(fn); + // jestExpect(spy).toHaveBeenCalledTimes(3); + // jestExpect(spy).not.toHaveBeenCalledTimes(2); + + jestExpect(fn).toHaveBeenCalledTimes(3); + jestExpect(fn).not.toHaveBeenCalledTimes(2); + + expect(() => jestExpect(fn).toHaveBeenCalledTimes(2)).toThrow(); + }); + + test(".not passes if function called less than expected times", () => { + const fn = jest.fn(); + fn(); + + // const spy = createSpy(fn); + // jestExpect(spy).toHaveBeenCalledTimes(1); + // jestExpect(spy).not.toHaveBeenCalledTimes(2); + + jestExpect(fn).toHaveBeenCalledTimes(1); + jestExpect(fn).not.toHaveBeenCalledTimes(2); + + expect(() => jestExpect(fn).toHaveBeenCalledTimes(2)).toThrow(); + }); + + test("includes the custom mock name in the error message", () => { + const fn = jest.fn().mockName("named-mock"); + fn(); + + expect(() => jestExpect(fn).toHaveBeenCalledTimes(2)).toThrow(); + }); +}); + +describe.each(["toHaveBeenLastCalledWith", "toHaveBeenNthCalledWith", "toHaveBeenCalledWith"] as const)( + "%s", + calledWith => { + function isToHaveNth(calledWith: string): calledWith is "toHaveBeenNthCalledWith" { + return calledWith === "toHaveBeenNthCalledWith"; + } + + test("works only on spies or jest.fn", () => { + const fn = function fn() {}; + + if (isToHaveNth(calledWith)) { + expect(() => jestExpect(fn)[calledWith](3)).toThrow(); + } else { + expect(() => jestExpect(fn)[calledWith]()).toThrow(); + } + }); + + test("works when not called", () => { + const fn = jest.fn(); + + if (isToHaveNth(calledWith)) { + // jestExpect(createSpy(fn)).not[calledWith](1, "foo", "bar"); + jestExpect(fn).not[calledWith](1, "foo", "bar"); + + expect(() => jestExpect(fn)[calledWith](1, "foo", "bar")).toThrow(); + } else { + // jestExpect(createSpy(fn)).not[calledWith]("foo", "bar"); + jestExpect(fn).not[calledWith]("foo", "bar"); + + expect(() => jestExpect(fn)[calledWith]("foo", "bar")).toThrow(); + } + }); + + test("works with no arguments", () => { + const fn = jest.fn(); + fn(); + + if (isToHaveNth(calledWith)) { + // jestExpect(createSpy(fn))[calledWith](1); + jestExpect(fn)[calledWith](1); + } else { + // jestExpect(createSpy(fn))[calledWith](); + jestExpect(fn)[calledWith](); + } + }); + + test("works with arguments that don't match", () => { + const fn = jest.fn(); + fn("foo", "bar1"); + + if (isToHaveNth(calledWith)) { + // jestExpect(createSpy(fn)).not[calledWith](1, "foo", "bar"); + jestExpect(fn).not[calledWith](1, "foo", "bar"); + + expect(() => jestExpect(fn)[calledWith](1, "foo", "bar")).toThrow(); + } else { + // jestExpect(createSpy(fn)).not[calledWith]("foo", "bar"); + jestExpect(fn).not[calledWith]("foo", "bar"); + + expect(() => jestExpect(fn)[calledWith]("foo", "bar")).toThrow(); + } + }); + + test("works with arguments that don't match in number of arguments", () => { + const fn = jest.fn(); + fn("foo", "bar", "plop"); + + if (isToHaveNth(calledWith)) { + // jestExpect(createSpy(fn)).not[calledWith](1, "foo", "bar"); + jestExpect(fn).not[calledWith](1, "foo", "bar"); + + expect(() => jestExpect(fn)[calledWith](1, "foo", "bar")).toThrow(); + } else { + // jestExpect(createSpy(fn)).not[calledWith]("foo", "bar"); + jestExpect(fn).not[calledWith]("foo", "bar"); + + expect(() => jestExpect(fn)[calledWith]("foo", "bar")).toThrow(); + } + }); + + test("works with arguments that don't match with matchers", () => { + const fn = jest.fn(); + fn("foo", "bar"); + + if (isToHaveNth(calledWith)) { + // jestExpect(createSpy(fn)).not[calledWith](1, jestExpect.any(String), jestExpect.any(Number)); + jestExpect(fn).not[calledWith](1, jestExpect.any(String), jestExpect.any(Number)); + + expect(() => jestExpect(fn)[calledWith](1, jestExpect.any(String), jestExpect.any(Number))).toThrow(); + } else { + // jestExpect(createSpy(fn)).not[calledWith](jestExpect.any(String), jestExpect.any(Number)); + jestExpect(fn).not[calledWith](jestExpect.any(String), jestExpect.any(Number)); + + expect(() => jestExpect(fn)[calledWith](jestExpect.any(String), jestExpect.any(Number))).toThrow(); + } + }); + + test("works with arguments that don't match with matchers even when argument is undefined", () => { + const fn = jest.fn(); + fn("foo", undefined); + + if (isToHaveNth(calledWith)) { + // jestExpect(createSpy(fn)).not[calledWith](1, "foo", jestExpect.any(String)); + jestExpect(fn).not[calledWith](1, "foo", jestExpect.any(String)); + + expect(() => jestExpect(fn)[calledWith](1, "foo", jestExpect.any(String))).toThrow(); + } else { + // jestExpect(createSpy(fn)).not[calledWith]("foo", jestExpect.any(String)); + jestExpect(fn).not[calledWith]("foo", jestExpect.any(String)); + + expect(() => jestExpect(fn)[calledWith]("foo", jestExpect.any(String))).toThrow(); + } + }); + + test("works with arguments that don't match in size even if one is an optional matcher", () => { + // issue 12463 + const fn = jest.fn(); + fn("foo"); + + if (isToHaveNth(calledWith)) { + jestExpect(fn).not[calledWith](1, "foo", jestExpect.optionalFn()); + expect(() => jestExpect(fn)[calledWith](1, "foo", jestExpect.optionalFn())).toThrow(); + } else { + jestExpect(fn).not[calledWith]("foo", jestExpect.optionalFn()); + expect(() => jestExpect(fn)[calledWith]("foo", jestExpect.optionalFn())).toThrow(); + } + }); + + test("works with arguments that match", () => { + const fn = jest.fn(); + fn("foo", "bar"); + + if (isToHaveNth(calledWith)) { + // jestExpect(createSpy(fn))[calledWith](1, "foo", "bar"); + jestExpect(fn)[calledWith](1, "foo", "bar"); + + expect(() => jestExpect(fn).not[calledWith](1, "foo", "bar")).toThrow(); + } else { + // jestExpect(createSpy(fn))[calledWith]("foo", "bar"); + jestExpect(fn)[calledWith]("foo", "bar"); + + expect(() => jestExpect(fn).not[calledWith]("foo", "bar")).toThrow(); + } + }); + + test("works with arguments that match with matchers", () => { + const fn = jest.fn(); + fn("foo", "bar"); + + if (isToHaveNth(calledWith)) { + // jestExpect(createSpy(fn))[calledWith](1, jestExpect.any(String), jestExpect.any(String)); + jestExpect(fn)[calledWith](1, jestExpect.any(String), jestExpect.any(String)); + + expect(() => jestExpect(fn).not[calledWith](1, jestExpect.any(String), jestExpect.any(String))).toThrow(); + } else { + // jestExpect(createSpy(fn))[calledWith](jestExpect.any(String), jestExpect.any(String)); + jestExpect(fn)[calledWith](jestExpect.any(String), jestExpect.any(String)); + + expect(() => jestExpect(fn).not[calledWith](jestExpect.any(String), jestExpect.any(String))).toThrow(); + } + }); + + test("works with trailing undefined arguments", () => { + const fn = jest.fn(); + fn("foo", undefined); + + if (isToHaveNth(calledWith)) { + expect(() => jestExpect(fn)[calledWith](1, "foo")).toThrow(); + } else { + expect(() => jestExpect(fn)[calledWith]("foo")).toThrow(); + } + }); + + test("works with trailing undefined arguments if requested by the match query", () => { + const fn = jest.fn(); + fn("foo", undefined); + + if (isToHaveNth(calledWith)) { + jestExpect(fn)[calledWith](1, "foo", undefined); + expect(() => jestExpect(fn).not[calledWith](1, "foo", undefined)).toThrow(); + } else { + jestExpect(fn)[calledWith]("foo", undefined); + expect(() => jestExpect(fn).not[calledWith]("foo", undefined)).toThrow(); + } + }); + + test("works with trailing undefined arguments when explicitly requested as optional by matcher", () => { + // issue 12463 + const fn = jest.fn(); + fn("foo", undefined); + + if (isToHaveNth(calledWith)) { + jestExpect(fn)[calledWith](1, "foo", jestExpect.optionalFn()); + expect(() => jestExpect(fn).not[calledWith](1, "foo", jestExpect.optionalFn())).toThrow(); + } else { + jestExpect(fn)[calledWith]("foo", jestExpect.optionalFn()); + expect(() => jestExpect(fn).not[calledWith]("foo", jestExpect.optionalFn())).toThrow(); + } + }); + + test("works with Map", () => { + const fn = jest.fn(); + + const m1 = new Map([ + [1, 2], + [2, 1], + ]); + const m2 = new Map([ + [1, 2], + [2, 1], + ]); + const m3 = new Map([ + ["a", "b"], + ["b", "a"], + ]); + + fn(m1); + + if (isToHaveNth(calledWith)) { + jestExpect(fn)[calledWith](1, m2); + jestExpect(fn).not[calledWith](1, m3); + + expect(() => jestExpect(fn).not[calledWith](1, m2)).toThrow(); + expect(() => jestExpect(fn)[calledWith](1, m3)).toThrow(); + } else { + jestExpect(fn)[calledWith](m2); + jestExpect(fn).not[calledWith](m3); + + expect(() => jestExpect(fn).not[calledWith](m2)).toThrow(); + expect(() => jestExpect(fn)[calledWith](m3)).toThrow(); + } + }); + + test("works with Set", () => { + const fn = jest.fn(); + + const s1 = new Set([1, 2]); + const s2 = new Set([1, 2]); + const s3 = new Set([3, 4]); + + fn(s1); + + if (isToHaveNth(calledWith)) { + jestExpect(fn)[calledWith](1, s2); + jestExpect(fn).not[calledWith](1, s3); + + expect(() => jestExpect(fn).not[calledWith](1, s2)).toThrow(); + expect(() => jestExpect(fn)[calledWith](1, s3)).toThrow(); + } else { + jestExpect(fn)[calledWith](s2); + jestExpect(fn).not[calledWith](s3); + + expect(() => jestExpect(fn).not[calledWith](s2)).toThrow(); + expect(() => jestExpect(fn)[calledWith](s3)).toThrow(); + } + }); + + test.todo("works with Immutable.js objects", () => { + const fn = jest.fn(); + const directlyCreated = Immutable.Map([["a", { b: "c" }]]); + const indirectlyCreated = Immutable.Map().set("a", { b: "c" }); + fn(directlyCreated, indirectlyCreated); + + if (isToHaveNth(calledWith)) { + jestExpect(fn)[calledWith](1, indirectlyCreated, directlyCreated); + + expect(() => jestExpect(fn).not[calledWith](1, indirectlyCreated, directlyCreated)).toThrow(); + } else { + jestExpect(fn)[calledWith](indirectlyCreated, directlyCreated); + + expect(() => jestExpect(fn).not[calledWith](indirectlyCreated, directlyCreated)).toThrow(); + } + }); + + if (!isToHaveNth(calledWith)) { + test("works with many arguments", () => { + const fn = jest.fn(); + fn("foo1", "bar"); + fn("foo", "bar1"); + fn("foo", "bar"); + + jestExpect(fn)[calledWith]("foo", "bar"); + + expect(() => jestExpect(fn).not[calledWith]("foo", "bar")).toThrow(); + }); + + test("works with many arguments that don't match", () => { + const fn = jest.fn(); + fn("foo", "bar1"); + fn("foo", "bar2"); + fn("foo", "bar3"); + + jestExpect(fn).not[calledWith]("foo", "bar"); + + expect(() => jestExpect(fn)[calledWith]("foo", "bar")).toThrow(); + }); + } + + if (isToHaveNth(calledWith)) { + test("works with three calls", () => { + const fn = jest.fn(); + fn("foo1", "bar"); + fn("foo", "bar1"); + fn("foo", "bar"); + + jestExpect(fn)[calledWith](1, "foo1", "bar"); + jestExpect(fn)[calledWith](2, "foo", "bar1"); + jestExpect(fn)[calledWith](3, "foo", "bar"); + + expect(() => { + jestExpect(fn).not[calledWith](1, "foo1", "bar"); + }).toThrow(); + }); + + test("positive throw matcher error for n that is not positive integer", async () => { + const fn = jest.fn(); + fn("foo1", "bar"); + + expect(() => { + jestExpect(fn)[calledWith](0, "foo1", "bar"); + }).toThrow(); + }); + + test("positive throw matcher error for n that is not integer", async () => { + const fn = jest.fn(); + fn("foo1", "bar"); + + expect(() => { + jestExpect(fn)[calledWith](0.1, "foo1", "bar"); + }).toThrow(); + }); + + test("negative throw matcher error for n that is not integer", async () => { + const fn = jest.fn(); + fn("foo1", "bar"); + + expect(() => { + jestExpect(fn).not[calledWith](Number.POSITIVE_INFINITY, "foo1", "bar"); + }).toThrow(); + }); + } + + test("includes the custom mock name in the error message", () => { + const fn = jest.fn().mockName("named-mock"); + fn("foo", "bar"); + + if (isToHaveNth(calledWith)) { + jestExpect(fn)[calledWith](1, "foo", "bar"); + + expect(() => jestExpect(fn).not[calledWith](1, "foo", "bar")).toThrow(); + } else { + jestExpect(fn)[calledWith]("foo", "bar"); + + expect(() => jestExpect(fn).not[calledWith]("foo", "bar")).toThrow(); + } + }); + + test("works with objectContaining", () => { + const fn = jest.fn(); + // Call the function twice with different objects and verify that the + // correct comparison sample is still used (original sample isn't mutated) + fn({ a: 1, b: 2, c: 4 }); + fn({ a: 3, b: 7, c: 4 }); + + if (isToHaveNth(calledWith)) { + jestExpect(fn)[calledWith](1, jestExpect.objectContaining({ b: 2 })); + jestExpect(fn)[calledWith](2, jestExpect.objectContaining({ b: 7 })); + jestExpect(fn)[calledWith](2, jestExpect.not.objectContaining({ b: 2 })); + + expect(() => jestExpect(fn)[calledWith](1, jestExpect.objectContaining({ b: 7 }))).toThrow(); + + expect(() => jestExpect(fn).not[calledWith](1, jestExpect.objectContaining({ b: 2 }))).toThrow(); + + expect(() => jestExpect(fn)[calledWith](1, jestExpect.not.objectContaining({ b: 2 }))).toThrow(); + } else { + jestExpect(fn)[calledWith](jestExpect.objectContaining({ b: 7 })); + jestExpect(fn)[calledWith](jestExpect.not.objectContaining({ b: 3 })); + + // The function was never called with this value. + // Only {"b": 3} should be shown as the expected value in the snapshot + // (no extra properties in the expected value). + expect(() => jestExpect(fn)[calledWith](jestExpect.objectContaining({ b: 3 }))).toThrow(); + + // Only {"b": 7} should be shown in the snapshot. + expect(() => jestExpect(fn).not[calledWith](jestExpect.objectContaining({ b: 7 }))).toThrow(); + } + + if (calledWith === "toHaveBeenCalledWith") { + // The first call had {b: 2}, so this passes. + jestExpect(fn)[calledWith](jestExpect.not.objectContaining({ b: 7 })); + + // Only {"c": 4} should be shown in the snapshot. + expect(() => jestExpect(fn)[calledWith](jestExpect.not.objectContaining({ c: 4 }))).toThrow(); + } + }); + }, +); + +describe("toHaveReturned", () => { + test(".not works only on jest.fn", () => { + const fn = function fn() {}; + + expect(() => jestExpect(fn).not.toHaveReturned()).toThrow(); + }); + + test.todo("throw matcher error if received is spy", () => { + const spy = createSpy(jest.fn()); + + expect(() => jestExpect(spy).toHaveReturned()).toThrow(); + }); + + test("passes when returned", () => { + const fn = jest.fn(() => 42); + fn(); + jestExpect(fn).toHaveReturned(); + expect(() => jestExpect(fn).not.toHaveReturned()).toThrow(); + }); + + test("passes when undefined is returned", () => { + const fn = jest.fn(() => undefined); + fn(); + jestExpect(fn).toHaveReturned(); + expect(() => jestExpect(fn).not.toHaveReturned()).toThrow(); + }); + + test("passes when at least one call does not throw", () => { + const fn = jest.fn((causeError: boolean) => { + if (causeError) { + throw new Error("Error!"); + } + + return 42; + }); + + fn(false); + + try { + fn(true); + } catch { + // ignore error + } + + fn(false); + + jestExpect(fn).toHaveReturned(); + expect(() => jestExpect(fn).not.toHaveReturned()).toThrow(); + }); + + test(".not passes when not returned", () => { + const fn = jest.fn(); + + jestExpect(fn).not.toHaveReturned(); + expect(() => jestExpect(fn).toHaveReturned()).toThrow(); + }); + + test(".not passes when all calls throw", () => { + const fn = jest.fn(() => { + throw new Error("Error!"); + }); + + try { + fn(); + } catch { + // ignore error + } + + try { + fn(); + } catch { + // ignore error + } + + jestExpect(fn).not.toHaveReturned(); + expect(() => jestExpect(fn).toHaveReturned()).toThrow(); + }); + + test(".not passes when a call throws undefined", () => { + const fn = jest.fn(() => { + // eslint-disable-next-line no-throw-literal + throw undefined; + }); + + try { + fn(); + } catch { + // ignore error + } + + jestExpect(fn).not.toHaveReturned(); + expect(() => jestExpect(fn).toHaveReturned()).toThrow(); + }); + + test("fails with any argument passed", () => { + const fn = jest.fn(); + + fn(); + expect(() => + // @ts-expect-error: Testing runtime error + jestExpect(fn).toHaveReturned(555), + ).toThrow(); + }); + + test(".not fails with any argument passed", () => { + const fn = jest.fn(); + + expect(() => + // @ts-expect-error: Testing runtime error + jestExpect(fn).not.toHaveReturned(555), + ).toThrow(); + }); + + test("includes the custom mock name in the error message", () => { + const fn = jest.fn(() => 42).mockName("named-mock"); + fn(); + jestExpect(fn).toHaveReturned(); + expect(() => jestExpect(fn).not.toHaveReturned()).toThrow(); + }); + + test("incomplete recursive calls are handled properly", () => { + // sums up all integers from 0 -> value, using recursion + const fn: jest.Mock<(value: number) => number> = jest.fn(value => { + if (value === 0) { + // Before returning from the base case of recursion, none of the + // calls have returned yet. + jestExpect(fn).not.toHaveReturned(); + expect(() => jestExpect(fn).toHaveReturned()).toThrow(); + return 0; + } else { + return value + fn(value - 1); + } + }); + + fn(3); + }); +}); + +describe("toHaveReturnedTimes", () => { + test.todo("throw matcher error if received is spy", () => { + const spy = createSpy(jest.fn()); + + expect(() => jestExpect(spy).not.toHaveReturnedTimes(2)).toThrow(); + }); + + test("only accepts a number argument", () => { + const fn = jest.fn(() => 42); + fn(); + jestExpect(fn).toHaveReturnedTimes(1); + + for (const value of [{}, [], true, "a", new Map(), () => {}]) { + expect(() => + // @ts-expect-error: Testing runtime error + jestExpect(fn).toHaveReturnedTimes(value), + ).toThrow(); + } + }); + + test(".not only accepts a number argument", () => { + const fn = jest.fn(() => 42); + jestExpect(fn).not.toHaveReturnedTimes(2); + + for (const value of [{}, [], true, "a", new Map(), () => {}]) { + expect(() => + // @ts-expect-error: Testing runtime error + jestExpect(fn).not.toHaveReturnedTimes(value), + ).toThrow(); + } + }); + + test("passes if function returned equal to expected times", () => { + const fn = jest.fn(() => 42); + fn(); + fn(); + + jestExpect(fn).toHaveReturnedTimes(2); + + expect(() => jestExpect(fn).not.toHaveReturnedTimes(2)).toThrow(); + }); + + test("calls that return undefined are counted as returns", () => { + const fn = jest.fn(() => undefined); + fn(); + fn(); + + jestExpect(fn).toHaveReturnedTimes(2); + + expect(() => jestExpect(fn).not.toHaveReturnedTimes(2)).toThrow(); + }); + + test(".not passes if function returned more than expected times", () => { + const fn = jest.fn(() => 42); + fn(); + fn(); + fn(); + + jestExpect(fn).toHaveReturnedTimes(3); + jestExpect(fn).not.toHaveReturnedTimes(2); + + expect(() => jestExpect(fn).toHaveReturnedTimes(2)).toThrow(); + }); + + test(".not passes if function called less than expected times", () => { + const fn = jest.fn(() => 42); + fn(); + + jestExpect(fn).toHaveReturnedTimes(1); + jestExpect(fn).not.toHaveReturnedTimes(2); + + expect(() => jestExpect(fn).toHaveReturnedTimes(2)).toThrow(); + }); + + test("calls that throw are not counted", () => { + const fn = jest.fn((causeError: boolean) => { + if (causeError) { + throw new Error("Error!"); + } + + return 42; + }); + + fn(false); + + try { + fn(true); + } catch { + // ignore error + } + + fn(false); + + jestExpect(fn).not.toHaveReturnedTimes(3); + + expect(() => jestExpect(fn).toHaveReturnedTimes(3)).toThrow(); + }); + + test("calls that throw undefined are not counted", () => { + const fn = jest.fn((causeError: boolean) => { + if (causeError) { + // eslint-disable-next-line no-throw-literal + throw undefined; + } + + return 42; + }); + + fn(false); + + try { + fn(true); + } catch { + // ignore error + } + + fn(false); + + jestExpect(fn).toHaveReturnedTimes(2); + + expect(() => jestExpect(fn).not.toHaveReturnedTimes(2)).toThrow(); + }); + + test("includes the custom mock name in the error message", () => { + const fn = jest.fn(() => 42).mockName("named-mock"); + fn(); + fn(); + + jestExpect(fn).toHaveReturnedTimes(2); + + expect(() => jestExpect(fn).toHaveReturnedTimes(1)).toThrow(); + }); + + test("incomplete recursive calls are handled properly", () => { + // sums up all integers from 0 -> value, using recursion + const fn: jest.Mock<(value: number) => number> = jest.fn(value => { + if (value === 0) { + return 0; + } else { + const recursiveResult = fn(value - 1); + + if (value === 2) { + // Only 2 of the recursive calls have returned at this point + jestExpect(fn).toHaveReturnedTimes(2); + expect(() => jestExpect(fn).not.toHaveReturnedTimes(2)).toThrow(); + } + + return value + recursiveResult; + } + }); + + fn(3); + }); +}); + +describe.each(["toHaveLastReturnedWith", "toHaveNthReturnedWith", "toHaveReturnedWith"] as const)( + "%s", + returnedWith => { + function isToHaveNth(returnedWith: string): returnedWith is "toHaveNthReturnedWith" { + return returnedWith === "toHaveNthReturnedWith"; + } + + function isToHaveLast(returnedWith: string): returnedWith is "toHaveLastReturnedWith" { + return returnedWith === "toHaveLastReturnedWith"; + } + test("works only on spies or jest.fn", () => { + const fn = function fn() {}; + + // @ts-expect-error: Testing runtime error + expect(() => jestExpect(fn)[returnedWith]()).toThrow(); + }); + + test("works when not called", () => { + const fn = jest.fn(); + + if (isToHaveNth(returnedWith)) { + jestExpect(fn).not[returnedWith](1, "foo"); + + expect(() => jestExpect(fn)[returnedWith](1, "foo")).toThrow(); + } else { + jestExpect(fn).not[returnedWith]("foo"); + + expect(() => jestExpect(fn)[returnedWith]("foo")).toThrow(); + } + }); + + test("works with no arguments", () => { + const fn = jest.fn(); + fn(); + + if (isToHaveNth(returnedWith)) { + jestExpect(fn)[returnedWith](1); + } else { + jestExpect(fn)[returnedWith](); + } + }); + + test("works with argument that does not match", () => { + const fn = jest.fn(() => "foo"); + fn(); + + if (isToHaveNth(returnedWith)) { + jestExpect(fn).not[returnedWith](1, "bar"); + + expect(() => jestExpect(fn)[returnedWith](1, "bar")).toThrow(); + } else { + jestExpect(fn).not[returnedWith]("bar"); + + expect(() => jestExpect(fn)[returnedWith]("bar")).toThrow(); + } + }); + + test("works with argument that does match", () => { + const fn = jest.fn(() => "foo"); + fn(); + + if (isToHaveNth(returnedWith)) { + jestExpect(fn)[returnedWith](1, "foo"); + + expect(() => jestExpect(fn).not[returnedWith](1, "foo")).toThrow(); + } else { + jestExpect(fn)[returnedWith]("foo"); + + expect(() => jestExpect(fn).not[returnedWith]("foo")).toThrow(); + } + }); + + test("works with undefined", () => { + const fn = jest.fn(() => undefined); + fn(); + + if (isToHaveNth(returnedWith)) { + jestExpect(fn)[returnedWith](1, undefined); + + expect(() => jestExpect(fn).not[returnedWith](1, undefined)).toThrow(); + } else { + jestExpect(fn)[returnedWith](undefined); + + expect(() => jestExpect(fn).not[returnedWith](undefined)).toThrow(); + } + }); + + test("works with Map", () => { + const m1 = new Map([ + [1, 2], + [2, 1], + ]); + const m2 = new Map([ + [1, 2], + [2, 1], + ]); + const m3 = new Map([ + ["a", "b"], + ["b", "a"], + ]); + + const fn = jest.fn(() => m1); + fn(); + + if (isToHaveNth(returnedWith)) { + jestExpect(fn)[returnedWith](1, m2); + jestExpect(fn).not[returnedWith](1, m3); + + expect(() => jestExpect(fn).not[returnedWith](1, m2)).toThrow(); + expect(() => jestExpect(fn)[returnedWith](1, m3)).toThrow(); + } else { + jestExpect(fn)[returnedWith](m2); + jestExpect(fn).not[returnedWith](m3); + + expect(() => jestExpect(fn).not[returnedWith](m2)).toThrow(); + expect(() => jestExpect(fn)[returnedWith](m3)).toThrow(); + } + }); + + test("works with Set", () => { + const s1 = new Set([1, 2]); + const s2 = new Set([1, 2]); + const s3 = new Set([3, 4]); + + const fn = jest.fn(() => s1); + fn(); + + if (isToHaveNth(returnedWith)) { + jestExpect(fn)[returnedWith](1, s2); + jestExpect(fn).not[returnedWith](1, s3); + + expect(() => jestExpect(fn).not[returnedWith](1, s2)).toThrow(); + expect(() => jestExpect(fn)[returnedWith](1, s3)).toThrow(); + } else { + jestExpect(fn)[returnedWith](s2); + jestExpect(fn).not[returnedWith](s3); + + expect(() => jestExpect(fn).not[returnedWith](s2)).toThrow(); + expect(() => jestExpect(fn)[returnedWith](s3)).toThrow(); + } + }); + + test("works with Immutable.js objects directly created", () => { + const directlyCreated = Immutable.Map([["a", { b: "c" }]]); + const fn = jest.fn(() => directlyCreated); + fn(); + + if (isToHaveNth(returnedWith)) { + jestExpect(fn)[returnedWith](1, directlyCreated); + + expect(() => jestExpect(fn).not[returnedWith](1, directlyCreated)).toThrow(); + } else { + jestExpect(fn)[returnedWith](directlyCreated); + + expect(() => jestExpect(fn).not[returnedWith](directlyCreated)).toThrow(); + } + }); + + test("works with Immutable.js objects indirectly created", () => { + const indirectlyCreated = Immutable.Map().set("a", { b: "c" }); + const fn = jest.fn(() => indirectlyCreated); + fn(); + + if (isToHaveNth(returnedWith)) { + jestExpect(fn)[returnedWith](1, indirectlyCreated); + + expect(() => jestExpect(fn).not[returnedWith](1, indirectlyCreated)).toThrow(); + } else { + jestExpect(fn)[returnedWith](indirectlyCreated); + + expect(() => jestExpect(fn).not[returnedWith](indirectlyCreated)).toThrow(); + } + }); + + test("a call that throws is not considered to have returned", () => { + const fn = jest.fn(() => { + throw new Error("Error!"); + }); + + try { + fn(); + } catch { + // ignore error + } + + if (isToHaveNth(returnedWith)) { + // It doesn't matter what return value is tested if the call threw + jestExpect(fn).not[returnedWith](1, "foo"); + jestExpect(fn).not[returnedWith](1, null); + jestExpect(fn).not[returnedWith](1, undefined); + + expect(() => jestExpect(fn)[returnedWith](1, undefined)).toThrow(); + } else { + // It doesn't matter what return value is tested if the call threw + jestExpect(fn).not[returnedWith]("foo"); + jestExpect(fn).not[returnedWith](null); + jestExpect(fn).not[returnedWith](undefined); + + expect(() => jestExpect(fn)[returnedWith](undefined)).toThrow(); + } + }); + + test("a call that throws undefined is not considered to have returned", () => { + const fn = jest.fn(() => { + // eslint-disable-next-line no-throw-literal + throw undefined; + }); + + try { + fn(); + } catch { + // ignore error + } + + if (isToHaveNth(returnedWith)) { + // It doesn't matter what return value is tested if the call threw + jestExpect(fn).not[returnedWith](1, "foo"); + jestExpect(fn).not[returnedWith](1, null); + jestExpect(fn).not[returnedWith](1, undefined); + + expect(() => jestExpect(fn)[returnedWith](1, undefined)).toThrow(); + } else { + // It doesn't matter what return value is tested if the call threw + jestExpect(fn).not[returnedWith]("foo"); + jestExpect(fn).not[returnedWith](null); + jestExpect(fn).not[returnedWith](undefined); + + expect(() => jestExpect(fn)[returnedWith](undefined)).toThrow(); + } + }); + + if (!isToHaveNth(returnedWith)) { + describe("toHaveReturnedWith", () => { + test("works with more calls than the limit", () => { + const fn = jest.fn<() => string>(); + fn.mockReturnValueOnce("foo1"); + fn.mockReturnValueOnce("foo2"); + fn.mockReturnValueOnce("foo3"); + fn.mockReturnValueOnce("foo4"); + fn.mockReturnValueOnce("foo5"); + fn.mockReturnValueOnce("foo6"); + + fn(); + fn(); + fn(); + fn(); + fn(); + fn(); + + jestExpect(fn).not[returnedWith]("bar"); + + expect(() => { + jestExpect(fn)[returnedWith]("bar"); + }).toThrow(); + }); + + test("incomplete recursive calls are handled properly", () => { + // sums up all integers from 0 -> value, using recursion + const fn: jest.Mock<(value: number) => number> = jest.fn(value => { + if (value === 0) { + // Before returning from the base case of recursion, none of the + // calls have returned yet. + // This test ensures that the incomplete calls are not incorrectly + // interpreted as have returned undefined + jestExpect(fn).not[returnedWith](undefined); + expect(() => jestExpect(fn)[returnedWith](undefined)).toThrow(); + + return 0; + } else { + return value + fn(value - 1); + } + }); + + fn(3); + }); + }); + } + + if (isToHaveNth(returnedWith)) { + describe("toHaveNthReturnedWith", () => { + test("works with three calls", () => { + const fn = jest.fn<() => string>(); + fn.mockReturnValueOnce("foo1"); + fn.mockReturnValueOnce("foo2"); + fn.mockReturnValueOnce("foo3"); + fn(); + fn(); + fn(); + + jestExpect(fn)[returnedWith](1, "foo1"); + jestExpect(fn)[returnedWith](2, "foo2"); + jestExpect(fn)[returnedWith](3, "foo3"); + + expect(() => { + jestExpect(fn).not[returnedWith](1, "foo1"); + jestExpect(fn).not[returnedWith](2, "foo2"); + jestExpect(fn).not[returnedWith](3, "foo3"); + }).toThrow(); + }); + + test("should replace 1st, 2nd, 3rd with first, second, third", async () => { + const fn = jest.fn<() => string>(); + fn.mockReturnValueOnce("foo1"); + fn.mockReturnValueOnce("foo2"); + fn.mockReturnValueOnce("foo3"); + fn(); + fn(); + fn(); + + expect(() => { + jestExpect(fn)[returnedWith](1, "bar1"); + jestExpect(fn)[returnedWith](2, "bar2"); + jestExpect(fn)[returnedWith](3, "bar3"); + }).toThrow(); + + expect(() => { + jestExpect(fn).not[returnedWith](1, "foo1"); + jestExpect(fn).not[returnedWith](2, "foo2"); + jestExpect(fn).not[returnedWith](3, "foo3"); + }).toThrow(); + }); + + test("positive throw matcher error for n that is not positive integer", async () => { + const fn = jest.fn(() => "foo"); + fn(); + + expect(() => { + jestExpect(fn)[returnedWith](0, "foo"); + }).toThrow(); + }); + + test("should reject nth value greater than number of calls", async () => { + const fn = jest.fn(() => "foo"); + fn(); + fn(); + fn(); + + expect(() => { + jestExpect(fn)[returnedWith](4, "foo"); + }).toThrow(); + }); + + test("positive throw matcher error for n that is not integer", async () => { + const fn = jest.fn<(a: string) => string>(() => "foo"); + fn("foo"); + + expect(() => { + jestExpect(fn)[returnedWith](0.1, "foo"); + }).toThrow(); + }); + + test("negative throw matcher error for n that is not number", async () => { + const fn = jest.fn<(a: string) => string>(() => "foo"); + fn("foo"); + + expect(() => { + // @ts-expect-error: Testing runtime error + jestExpect(fn).not[returnedWith](); + }).toThrow(); + }); + + test("incomplete recursive calls are handled properly", () => { + // sums up all integers from 0 -> value, using recursion + const fn: jest.Mock<(value: number) => number> = jest.fn(value => { + if (value === 0) { + return 0; + } else { + const recursiveResult = fn(value - 1); + + if (value === 2) { + // Only 2 of the recursive calls have returned at this point + jestExpect(fn).not[returnedWith](1, 6); + jestExpect(fn).not[returnedWith](2, 3); + jestExpect(fn)[returnedWith](3, 1); + jestExpect(fn)[returnedWith](4, 0); + + expect(() => jestExpect(fn)[returnedWith](1, 6)).toThrow(); + expect(() => jestExpect(fn)[returnedWith](2, 3)).toThrow(); + expect(() => jestExpect(fn).not[returnedWith](3, 1)).toThrow(); + expect(() => jestExpect(fn).not[returnedWith](4, 0)).toThrow(); + } + + return value + recursiveResult; + } + }); + + fn(3); + }); + }); + } + + if (isToHaveLast(returnedWith)) { + describe("toHaveLastReturnedWith", () => { + test("works with three calls", () => { + const fn = jest.fn<() => string>(); + fn.mockReturnValueOnce("foo1"); + fn.mockReturnValueOnce("foo2"); + fn.mockReturnValueOnce("foo3"); + fn(); + fn(); + fn(); + + jestExpect(fn)[returnedWith]("foo3"); + + expect(() => { + jestExpect(fn).not[returnedWith]("foo3"); + }).toThrow(); + }); + + test("incomplete recursive calls are handled properly", () => { + // sums up all integers from 0 -> value, using recursion + const fn: jest.Mock<(value: number) => number> = jest.fn(value => { + if (value === 0) { + // Before returning from the base case of recursion, none of the + // calls have returned yet. + jestExpect(fn).not[returnedWith](0); + expect(() => jestExpect(fn)[returnedWith](0)).toThrow(); + return 0; + } else { + return value + fn(value - 1); + } + }); + + fn(3); + }); + }); + } + + test("includes the custom mock name in the error message", () => { + const fn = jest.fn().mockName("named-mock"); + + if (isToHaveNth(returnedWith)) { + jestExpect(fn).not[returnedWith](1, "foo"); + + expect(() => jestExpect(fn)[returnedWith](1, "foo")).toThrow(); + } else { + jestExpect(fn).not[returnedWith]("foo"); + + expect(() => jestExpect(fn)[returnedWith]("foo")).toThrow(); + } + }); + }, +); diff --git a/test/package.json b/test/package.json index 7eac577d26..c3950db5ae 100644 --- a/test/package.json +++ b/test/package.json @@ -52,6 +52,7 @@ "http2-wrapper": "2.2.1", "https-proxy-agent": "7.0.5", "iconv-lite": "0.6.3", + "immutable": "5.1.3", "isbot": "5.1.13", "jest-extended": "4.0.0", "jimp": "1.6.0", diff --git a/test/regression/issue/10380/spy-matchers-diff.test.ts b/test/regression/issue/10380/spy-matchers-diff.test.ts new file mode 100644 index 0000000000..326d26a378 --- /dev/null +++ b/test/regression/issue/10380/spy-matchers-diff.test.ts @@ -0,0 +1,127 @@ +import { expect, mock, test } from "bun:test"; + +test("toHaveBeenCalledWith should show diff when assertion fails", () => { + const mockedFn = mock(args => args); + + const a = { a: { b: { c: { d: 1 } } } }; + const b = { a: { b: { c: { d: 2 } } } }; + + mockedFn(a); + + let error: Error | undefined; + try { + expect(mockedFn).toHaveBeenCalledWith(b); + } catch (e) { + error = e as Error; + } + + expect(error).toBeDefined(); + expect(error!.message).toContain("- Expected"); + expect(error!.message).toContain("+ Received"); + expect(error!.message).toContain("d: 1"); + expect(error!.message).toContain("d: 2"); +}); + +test("toHaveBeenNthCalledWith should show diff when assertion fails", () => { + const mockedFn = mock(args => args); + + const a = { x: [1, 2, 3] }; + const b = { x: [1, 2, 4] }; + + mockedFn(a); + + let error: Error | undefined; + try { + expect(mockedFn).toHaveBeenNthCalledWith(1, b); + } catch (e) { + error = e as Error; + } + + expect(error).toBeDefined(); + expect(error!.message).toContain("- Expected"); + expect(error!.message).toContain("+ Received"); +}); + +test("toHaveBeenLastCalledWith should show diff when assertion fails", () => { + const mockedFn = mock(args => args); + + const a = { nested: { value: "hello" } }; + const b = { nested: { value: "world" } }; + + mockedFn("first"); + mockedFn(a); + + let error: Error | undefined; + try { + expect(mockedFn).toHaveBeenLastCalledWith(b); + } catch (e) { + error = e as Error; + } + + expect(error).toBeDefined(); + expect(error!.message).toContain("- Expected"); + expect(error!.message).toContain("+ Received"); + expect(error!.message).toContain("hello"); + expect(error!.message).toContain("world"); +}); + +test("toHaveBeenCalledWith should show diff for multiple arguments", () => { + const mockedFn = mock((a, b, c) => [a, b, c]); + + mockedFn(1, { foo: "bar" }, [1, 2, 3]); + + let error: Error | undefined; + try { + expect(mockedFn).toHaveBeenCalledWith(1, { foo: "baz" }, [1, 2, 4]); + } catch (e) { + error = e as Error; + } + + expect(error).toBeDefined(); + expect(error!.message).toContain("- Expected"); + expect(error!.message).toContain("+ Received"); + expect(error!.message).toContain("bar"); + expect(error!.message).toContain("baz"); +}); + +test("toHaveBeenCalledWith should show diff for complex nested structures", () => { + const mockedFn = mock(args => args); + + const received = { + users: [ + { id: 1, name: "Alice", roles: ["admin", "user"] }, + { id: 2, name: "Bob", roles: ["user"] }, + ], + settings: { + theme: "dark", + notifications: { email: true, push: false }, + }, + }; + + const expected = { + users: [ + { id: 1, name: "Alice", roles: ["admin", "user"] }, + { id: 2, name: "Bob", roles: ["moderator", "user"] }, + ], + settings: { + theme: "light", + notifications: { email: true, push: false }, + }, + }; + + mockedFn(received); + + let error: Error | undefined; + try { + expect(mockedFn).toHaveBeenCalledWith(expected); + } catch (e) { + error = e as Error; + } + + expect(error).toBeDefined(); + expect(error!.message).toContain("- Expected"); + expect(error!.message).toContain("+ Received"); + expect(error!.message).toContain("dark"); + expect(error!.message).toContain("light"); + expect(error!.message).toContain("moderator"); +});