diff --git a/packages/bun-types/test.d.ts b/packages/bun-types/test.d.ts index be6011fb44..61bec88c67 100644 --- a/packages/bun-types/test.d.ts +++ b/packages/bun-types/test.d.ts @@ -1444,6 +1444,22 @@ declare module "bun:test" { * @param expected the string to end with */ toEndWith(expected: string): void; + /** + * Ensures that a mock function has returned successfully at least once. + * + * A promise that is unfulfilled will be considered a failure. If the + * function threw an error, it will be considered a failure. + */ + toHaveReturned(): void; + + /** + * Ensures that a mock function has returned successfully at `times` times. + * + * A promise that is unfulfilled will be considered a failure. If the + * function threw an error, it will be considered a failure. + */ + toHaveReturnedTimes(times: number): void; + /** * Ensures that a mock function is called. */ diff --git a/src/bun.js/test/expect.zig b/src/bun.js/test/expect.zig index c166b60fb3..cbedd6d4fa 100644 --- a/src/bun.js/test/expect.zig +++ b/src/bun.js/test/expect.zig @@ -3977,8 +3977,110 @@ pub const Expect = struct { return .zero; } - pub const toHaveReturned = notImplementedJSCFn; - pub const toHaveReturnedTimes = notImplementedJSCFn; + const ReturnStatus = enum { + throw, + @"return", + incomplete, + + pub const Map = bun.ComptimeEnumMap(ReturnStatus); + }; + + inline fn toHaveReturnedTimesFn(this: *Expect, globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame, comptime known_index: ?i32) JSC.JSValue { + JSC.markBinding(@src()); + + const thisValue = callframe.this(); + const arguments = callframe.arguments(1).slice(); + defer this.postMatch(globalObject); + + const name = comptime if (known_index != null and known_index.? == 0) "toHaveReturned" else "toHaveReturnedTimes"; + + const value: JSValue = this.getValue(globalObject, thisValue, name, if (known_index != null and known_index.? == 0) "" else "expected") orelse return .zero; + + incrementExpectCallCounter(); + + const returns = JSMockFunction__getReturns(value); + + if (returns == .zero or !returns.jsType().isArray()) { + globalObject.throw("Expected value must be a mock function: {}", .{value}); + return .zero; + } + + const return_count: i32 = if (known_index) |index| index else brk: { + if (arguments.len < 1 or !arguments[0].isUInt32AsAnyInt()) { + globalObject.throwInvalidArguments(name ++ "() requires 1 non-negative integer argument", .{}); + return .zero; + } + + break :brk arguments[0].coerce(i32, globalObject); + }; + + var pass = false; + const index: u32 = @as(u32, @intCast(return_count)) -| 1; + + const times_value = returns.getDirectIndex( + globalObject, + index, + ); + + const total_count = returns.getLength(globalObject); + + 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 (times_value.get(globalObject, "type")) |type_string| { + if (type_string.isString()) { + break :brk ReturnStatus.Map.fromJS(globalObject, type_string) orelse { + if (!globalObject.hasException()) + globalObject.throw("Expected value must be a mock function with returns: {}", .{value}); + return .zero; + }; + } + } + } + + break :brk ReturnStatus.incomplete; + }; + if (globalObject.hasException()) + return .zero; + + pass = return_status == ReturnStatus.@"return"; + + const not = this.flags.not; + if (not) pass = !pass; + if (pass) return .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 = globalObject, + .quote_strings = true, + }; + globalObject.throwPretty(fmt, .{times_value.get(globalObject, "value").?.toFmt(globalObject, &formatter)}); + return .zero; + } + + switch (not) { + inline else => |is_not| { + const signature = comptime getSignature(name, "expected", is_not); + const fmt = signature ++ "\n\n" ++ "Expected number of successful calls: {d}\n" ++ "Received number of calls: {d}\n"; + globalObject.throwPretty(fmt, .{ index, total_count }); + return .zero; + }, + } + } + + pub fn toHaveReturned(this: *Expect, globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSC.JSValue { + return toHaveReturnedTimesFn(this, globalObject, callframe, 0); + } + + pub fn toHaveReturnedTimes(this: *Expect, globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSC.JSValue { + return toHaveReturnedTimesFn(this, globalObject, callframe, null); + } + pub const toHaveReturnedWith = notImplementedJSCFn; pub const toHaveLastReturnedWith = notImplementedJSCFn; pub const toHaveNthReturnedWith = notImplementedJSCFn; diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index d724d62d30..7c80015533 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -90,6 +90,32 @@ describe("mock()", () => { expect(func).toHaveBeenCalled(); }); + test("toHaveReturned()", () => { + const func = jest.fn(() => "the jedi"); + expect(func).not.toHaveReturned(); + func(); + expect(func).toHaveReturned(); + expect(func).toHaveReturnedTimes(1); + expect(func.mock.calls).toHaveLength(1); + expect(func.mock.calls[0]).toBeEmpty(); + func(); + expect(func).toHaveReturnedTimes(2); + const func2 = jest.fn(() => { + throw new Error("the jedi"); + }); + expect(func2).not.toHaveReturned(); + try { + func2(); + } catch (e) {} + + expect(func2).not.toHaveReturned(); + try { + expect(func2).toHaveReturned(); + } catch (e) { + expect(e.message).toContain("Function threw an exception"); + } + }); + test("passes this value", () => { const fn = jest.fn(function hey() { "use strict";