diff --git a/src/bun.js/test/Execution.zig b/src/bun.js/test/Execution.zig index 2d439e26e9..3396e4b70e 100644 --- a/src/bun.js/test/Execution.zig +++ b/src/bun.js/test/Execution.zig @@ -89,6 +89,12 @@ pub const ExecutionSequence = struct { exact: u32, } = .not_set, maybe_skip: bool = false, + /// Soft assertion errors collected during test execution. + soft_errors: std.ArrayListUnmanaged(SoftError) = .{}, + + pub const SoftError = struct { + message: []const u8, + }; pub fn init(first_entry: ?*ExecutionEntry, test_entry: ?*ExecutionEntry) ExecutionSequence { return .{ @@ -528,6 +534,21 @@ fn onSequenceCompleted(this: *Execution, sequence: *ExecutionSequence) void { sequence.result = .fail_because_expected_assertion_count; }, } + + // Check soft errors - if any exist and test would otherwise pass, mark as failed and print them + if (sequence.soft_errors.items.len > 0) { + if (sequence.result.isPass(.pending_is_pass)) { + sequence.result = .fail; + } + + // Print all soft assertion errors + for (sequence.soft_errors.items) |soft_error| { + bun.Output.prettyErrorln("", .{}); + bun.Output.prettyErrorln("{s}", .{soft_error.message}); + bun.Output.flush(); + } + } + if (sequence.result == .pending) { sequence.result = switch (sequence.entryMode()) { .failing => .fail_because_failing_test_passed, diff --git a/src/bun.js/test/expect.zig b/src/bun.js/test/expect.zig index 4f0a1ae4ca..c2992adb0a 100644 --- a/src/bun.js/test/expect.zig +++ b/src/bun.js/test/expect.zig @@ -24,6 +24,7 @@ pub const Expect = struct { flags: Flags = .{}, parent: ?*bun.jsc.Jest.bun_test.BunTest.RefData, custom_label: bun.String = bun.String.empty, + is_soft: bool = false, pub const TestScope = struct { test_id: TestRunner.Test.ID, @@ -136,6 +137,11 @@ pub const Expect = struct { return thisValue; } + pub fn getSoft(this: *Expect, thisValue: JSValue, _: *JSGlobalObject) JSValue { + this.is_soft = true; + return thisValue; + } + pub fn getResolves(this: *Expect, thisValue: JSValue, globalThis: *JSGlobalObject) bun.JSError!JSValue { this.flags.promise = switch (this.flags.promise) { .resolves, .none => .resolves, @@ -370,7 +376,7 @@ pub const Expect = struct { return expect_js_value; } - pub fn throw(this: *Expect, globalThis: *JSGlobalObject, comptime signature: [:0]const u8, comptime fmt: [:0]const u8, args: anytype) bun.JSError { + fn throwImpl(this: *Expect, globalThis: *JSGlobalObject, comptime signature: [:0]const u8, comptime fmt: [:0]const u8, args: anytype) bun.JSError { if (this.custom_label.isEmpty()) { return globalThis.throwPretty(signature ++ fmt, args); } else { @@ -378,6 +384,48 @@ pub const Expect = struct { } } + pub fn throw(this: *Expect, globalThis: *JSGlobalObject, comptime signature: [:0]const u8, comptime fmt: [:0]const u8, args: anytype) bun.JSError { + // Handle soft assertions - collect error without throwing + if (this.is_soft) { + if (this.parent) |parent| { + var buntest_strong = parent.bunTest() orelse { + return this.throwImpl(globalThis, signature, fmt, args); + }; + defer buntest_strong.deinit(); + const buntest = buntest_strong.get(); + + if (parent.phase.sequence(buntest)) |sequence| { + // Format the error message + const msg = if (this.custom_label.isEmpty()) + std.fmt.allocPrint(buntest.arena, signature ++ fmt, args) catch null + else + std.fmt.allocPrint(buntest.arena, "{}" ++ fmt, .{this.custom_label} ++ args) catch null; + + if (msg) |m| { + // Store in sequence + sequence.soft_errors.append(buntest.arena, .{ + .message = m, + }) catch { + // If append fails, fall through to regular throw + }; + if (sequence.soft_errors.items.len > 0 and sequence.soft_errors.items[sequence.soft_errors.items.len - 1].message.ptr == m.ptr) { + // Successfully added the soft error + // Return error.JSError WITHOUT calling globalThis.throw + // This is the key trick: we return an error type but don't actually throw + // The matcher propagates error.JSError up, but since no actual exception + // was set, the test continues running + return error.JSError; + } + } + } + } + // If soft error collection failed, fall through to regular throw + } + + // Regular throw + return this.throwImpl(globalThis, signature, fmt, args); + } + pub fn constructor(globalThis: *JSGlobalObject, _: *CallFrame) bun.JSError!*Expect { return globalThis.throw("expect() cannot be called with new", .{}); } @@ -870,6 +918,10 @@ pub const Expect = struct { return ExpectStatic.create(globalThis, .{ .not = true }); } + pub fn getStaticSoft(globalThis: *JSGlobalObject, _: JSValue, _: JSValue) bun.JSError!JSValue { + return ExpectStatic.createSoft(globalThis, .{}, true); + } + pub fn getStaticResolvesTo(globalThis: *JSGlobalObject, _: JSValue, _: JSValue) bun.JSError!JSValue { return ExpectStatic.create(globalThis, .{ .promise = .resolves }); } @@ -1259,6 +1311,7 @@ pub const ExpectStatic = struct { pub const fromJSDirect = js.fromJSDirect; flags: Expect.Flags = .{}, + is_soft: bool = false, pub fn finalize( this: *ExpectStatic, @@ -1269,16 +1322,66 @@ pub const ExpectStatic = struct { pub fn create(globalThis: *JSGlobalObject, flags: Expect.Flags) bun.JSError!JSValue { var expect = try globalThis.bunVM().allocator.create(ExpectStatic); expect.flags = flags; + expect.is_soft = false; const value = expect.toJS(globalThis); value.ensureStillAlive(); return value; } + pub fn createSoft(globalThis: *JSGlobalObject, flags: Expect.Flags, is_soft: bool) bun.JSError!JSValue { + var expect = try globalThis.bunVM().allocator.create(ExpectStatic); + expect.flags = flags; + expect.is_soft = is_soft; + + const value = expect.toJS(globalThis); + value.ensureStillAlive(); + return value; + } + + pub fn call(globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue { + // Create an Expect instance with this ExpectStatic's flags and is_soft setting + const this_value = callFrame.this(); + const this = ExpectStatic.fromJS(this_value) orelse { + return globalThis.throw("Invalid expect.soft call", .{}); + }; + const arguments_ = callFrame.arguments_old(1); + const arguments = arguments_.slice(); + + if (arguments.len < 1) { + return globalThis.throwInvalidArguments("Expected 1 argument", .{}); + } + + const value = arguments[0]; + value.ensureStillAlive(); + + const active_execution_entry_ref = if (bun.jsc.Jest.bun_test.cloneActiveStrong()) |buntest_strong_| blk: { + var buntest_strong = buntest_strong_; + defer buntest_strong.deinit(); + break :blk bun.jsc.Jest.bun_test.BunTest.ref(buntest_strong, buntest_strong.get().getCurrentStateData()); + } else null; + + var expect = try globalThis.bunVM().allocator.create(Expect); + expect.* = Expect{ + .flags = this.flags, + .parent = active_execution_entry_ref, + .is_soft = this.is_soft, + }; + + const expect_js_value = expect.toJS(globalThis); + Expect.js.capturedValueSetCached(expect_js_value, globalThis, value); + expect.postMatch(globalThis); + return expect_js_value; + } + pub fn getNot(this: *ExpectStatic, _: JSValue, globalThis: *JSGlobalObject) bun.JSError!JSValue { var flags = this.flags; flags.not = !this.flags.not; - return create(globalThis, flags); + return createSoft(globalThis, flags, this.is_soft); + } + + pub fn getSoft(this: *ExpectStatic, _: JSValue, globalThis: *JSGlobalObject) bun.JSError!JSValue { + return createSoft(globalThis, this.flags, true); } pub fn getResolvesTo(this: *ExpectStatic, _: JSValue, globalThis: *JSGlobalObject) bun.JSError!JSValue { diff --git a/src/bun.js/test/jest.classes.ts b/src/bun.js/test/jest.classes.ts index ba97450f95..c4636878ee 100644 --- a/src/bun.js/test/jest.classes.ts +++ b/src/bun.js/test/jest.classes.ts @@ -169,7 +169,7 @@ export default [ name: "ExpectStatic", construct: false, noConstructor: true, - call: false, + call: true, finalize: true, JSType: "0b11101110", configurable: false, @@ -207,6 +207,10 @@ export default [ getter: "getNot", this: true, }, + soft: { + getter: "getSoft", + this: true, + }, resolvesTo: { getter: "getResolvesTo", this: true, @@ -273,6 +277,9 @@ export default [ not: { getter: "getStaticNot", }, + soft: { + getter: "getStaticSoft", + }, resolvesTo: { getter: "getStaticResolvesTo", }, @@ -511,6 +518,10 @@ export default [ getter: "getNot", this: true, }, + soft: { + getter: "getSoft", + this: true, + }, resolves: { getter: "getResolves", this: true, diff --git a/test/js/bun/test/expect-soft.test.ts b/test/js/bun/test/expect-soft.test.ts new file mode 100644 index 0000000000..01b1380150 --- /dev/null +++ b/test/js/bun/test/expect-soft.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test"; + +test("expect.soft continues after failure", () => { + let counter = 0; + + expect.soft(1 + 1).toBe(3); // should fail but continue + counter++; + + expect.soft(2 + 2).toBe(5); // should fail but continue + counter++; + + expect(counter).toBe(2); // should pass - proves execution continued +}); + +test("expect.soft with all passing assertions", () => { + expect.soft(1 + 1).toBe(2); // should pass + expect.soft(2 + 2).toBe(4); // should pass + expect(3 + 3).toBe(6); // should pass +}); + +test("expect.soft then hard expect fails", () => { + expect.soft(1 + 1).toBe(3); // should fail but continue + expect(2 + 2).toBe(5); // should fail and stop + expect.soft(3 + 3).toBe(7); // should not run +}); + +test("expect.soft with .not", () => { + expect.soft.not(1 + 1).toBe(3); // should pass + expect.soft.not(2 + 2).toBe(4); // should fail but continue + expect(true).toBe(true); // marker that we got here +}); + +test("multiple expect.soft failures are all reported", () => { + expect.soft(1).toBe(2); + expect.soft(3).toBe(4); + expect.soft(5).toBe(6); + // All three failures should be reported at the end +});