diff options
Diffstat (limited to 'src/bun.js/test/jest.zig')
-rw-r--r-- | src/bun.js/test/jest.zig | 1059 |
1 files changed, 1059 insertions, 0 deletions
diff --git a/src/bun.js/test/jest.zig b/src/bun.js/test/jest.zig new file mode 100644 index 000000000..2128bc131 --- /dev/null +++ b/src/bun.js/test/jest.zig @@ -0,0 +1,1059 @@ +const std = @import("std"); +const Api = @import("../../api/schema.zig").Api; +const RequestContext = @import("../../http.zig").RequestContext; +const MimeType = @import("../../http.zig").MimeType; +const ZigURL = @import("../../url.zig").URL; +const HTTPClient = @import("http"); +const NetworkThread = HTTPClient.NetworkThread; +const Environment = @import("../../env.zig"); + +const JSC = @import("../../jsc.zig"); +const js = JSC.C; + +const logger = @import("../../logger.zig"); +const Method = @import("../../http/method.zig").Method; + +const ObjectPool = @import("../../pool.zig").ObjectPool; + +const Output = @import("../../global.zig").Output; +const MutableString = @import("../../global.zig").MutableString; +const strings = @import("../../global.zig").strings; +const string = @import("../../global.zig").string; +const default_allocator = @import("../../global.zig").default_allocator; +const FeatureFlags = @import("../../global.zig").FeatureFlags; +const ArrayBuffer = @import("../base.zig").ArrayBuffer; +const Properties = @import("../base.zig").Properties; +const NewClass = @import("../base.zig").NewClass; +const d = @import("../base.zig").d; +const castObj = @import("../base.zig").castObj; +const getAllocator = @import("../base.zig").getAllocator; +const JSPrivateDataPtr = @import("../base.zig").JSPrivateDataPtr; +const GetJSPrivateData = @import("../base.zig").GetJSPrivateData; + +const ZigString = JSC.ZigString; +const JSInternalPromise = JSC.JSInternalPromise; +const JSPromise = JSC.JSPromise; +const JSValue = JSC.JSValue; +const JSError = JSC.JSError; +const JSGlobalObject = JSC.JSGlobalObject; + +const VirtualMachine = @import("../javascript.zig").VirtualMachine; +const Task = @import("../javascript.zig").Task; + +const Fs = @import("../../fs.zig"); +const is_bindgen: bool = std.meta.globalOption("bindgen", bool) orelse false; + +fn notImplementedFn(_: *anyopaque, ctx: js.JSContextRef, _: js.JSObjectRef, _: js.JSObjectRef, _: []const js.JSValueRef, exception: js.ExceptionRef) js.JSValueRef { + JSError(getAllocator(ctx), "Not implemented yet!", .{}, ctx, exception); + return null; +} + +fn notImplementedProp( + _: anytype, + ctx: js.JSContextRef, + _: js.JSObjectRef, + _: js.JSStringRef, + exception: js.ExceptionRef, +) js.JSValueRef { + JSError(getAllocator(ctx), "Property not implemented yet!", .{}, ctx, exception); + return null; +} + +const ArrayIdentityContext = @import("../../identity_context.zig").ArrayIdentityContext; +pub const TestRunner = struct { + tests: TestRunner.Test.List = .{}, + log: *logger.Log, + files: File.List = .{}, + index: File.Map = File.Map{}, + only: bool = false, + last_file: u64 = 0, + + timeout_seconds: f64 = 5.0, + + allocator: std.mem.Allocator, + callback: *Callback = undefined, + + pub fn setOnly(this: *TestRunner) void { + if (this.only) { + return; + } + + this.only = true; + this.tests.shrinkRetainingCapacity(0); + this.callback.onUpdateCount(this.callback, 0, 0); + } + + pub const Callback = struct { + pub const OnUpdateCount = fn (this: *Callback, delta: u32, total: u32) void; + pub const OnTestStart = fn (this: *Callback, test_id: Test.ID) void; + pub const OnTestUpdate = fn (this: *Callback, test_id: Test.ID, file: string, label: string, expectations: u32, parent: ?*DescribeScope) void; + onUpdateCount: OnUpdateCount, + onTestStart: OnTestStart, + onTestPass: OnTestUpdate, + onTestFail: OnTestUpdate, + }; + + pub fn reportPass(this: *TestRunner, test_id: Test.ID, file: string, label: string, expectations: u32, parent: ?*DescribeScope) void { + this.tests.items(.status)[test_id] = .pass; + this.callback.onTestPass(this.callback, test_id, file, label, expectations, parent); + } + pub fn reportFailure(this: *TestRunner, test_id: Test.ID, file: string, label: string, expectations: u32, parent: ?*DescribeScope) void { + this.tests.items(.status)[test_id] = .fail; + this.callback.onTestFail(this.callback, test_id, file, label, expectations, parent); + } + + pub fn addTestCount(this: *TestRunner, count: u32) u32 { + this.tests.ensureUnusedCapacity(this.allocator, count) catch unreachable; + const start = @truncate(Test.ID, this.tests.len); + this.tests.len += count; + var statuses = this.tests.items(.status)[start..][0..count]; + std.mem.set(Test.Status, statuses, Test.Status.pending); + this.callback.onUpdateCount(this.callback, count, count + start); + return start; + } + + pub fn getOrPutFile(this: *TestRunner, file_path: string) *DescribeScope { + var entry = this.index.getOrPut(this.allocator, @truncate(u32, std.hash.Wyhash.hash(0, file_path))) catch unreachable; + if (entry.found_existing) { + return this.files.items(.module_scope)[entry.value_ptr.*]; + } + var scope = this.allocator.create(DescribeScope) catch unreachable; + const file_id = @truncate(File.ID, this.files.len); + scope.* = DescribeScope{ + .file_id = file_id, + .test_id_start = @truncate(Test.ID, this.tests.len), + }; + this.files.append(this.allocator, .{ .module_scope = scope, .source = logger.Source.initEmptyFile(file_path) }) catch unreachable; + entry.value_ptr.* = file_id; + return scope; + } + + pub const File = struct { + source: logger.Source = logger.Source.initEmptyFile(""), + log: logger.Log = logger.Log.initComptime(default_allocator), + module_scope: *DescribeScope = undefined, + + pub const List = std.MultiArrayList(File); + pub const ID = u32; + pub const Map = std.ArrayHashMapUnmanaged(u32, u32, ArrayIdentityContext, false); + }; + + pub const Test = struct { + status: Status = Status.pending, + + pub const ID = u32; + pub const List = std.MultiArrayList(Test); + + pub const Status = enum(u3) { + pending, + pass, + fail, + }; + }; +}; + +pub const Jest = struct { + pub var runner: ?*TestRunner = null; + + pub fn call( + _: void, + ctx: js.JSContextRef, + _: js.JSObjectRef, + _: js.JSObjectRef, + arguments: []const js.JSValueRef, + exception: js.ExceptionRef, + ) js.JSValueRef { + var runner_ = runner orelse { + JSError(getAllocator(ctx), "Run bun test to run a test", .{}, ctx, exception); + return js.JSValueMakeUndefined(ctx); + }; + + if (arguments.len < 1 or !js.JSValueIsString(ctx, arguments[0])) { + JSError(getAllocator(ctx), "Bun.jest() expects a string filename", .{}, ctx, exception); + return js.JSValueMakeUndefined(ctx); + } + var str = js.JSValueToStringCopy(ctx, arguments[0], exception); + defer js.JSStringRelease(str); + var ptr = js.JSStringGetCharacters8Ptr(str); + const len = js.JSStringGetLength(str); + if (len == 0 or ptr[0] != '/') { + JSError(getAllocator(ctx), "Bun.jest() expects an absolute file path", .{}, ctx, exception); + return js.JSValueMakeUndefined(ctx); + } + var str_value = ptr[0..len]; + var filepath = Fs.FileSystem.instance.filename_store.append([]const u8, str_value) catch unreachable; + + var scope = runner_.getOrPutFile(filepath); + DescribeScope.active = scope; + + return DescribeScope.Class.make(ctx, scope); + } +}; + +/// https://jestjs.io/docs/expect +// To support async tests, we need to track the test ID +pub const Expect = struct { + test_id: TestRunner.Test.ID, + scope: *DescribeScope, + value: JSValue, + op: Op.Set = Op.Set.init(.{}), + + pub const Op = enum(u3) { + resolves, + rejects, + not, + pub const Set = std.EnumSet(Op); + }; + + pub fn finalize( + this: *Expect, + ) void { + this.value.unprotect(); + VirtualMachine.vm.allocator.destroy(this); + } + + pub const Class = NewClass( + Expect, + .{ .name = "Expect" }, + .{ + .toBe = .{ + .rfn = Expect.toBe, + .name = "toBe", + }, + .toHaveBeenCalledTimes = .{ + .rfn = Expect.toHaveBeenCalledTimes, + .name = "toHaveBeenCalledTimes", + }, + .finalize = .{ .rfn = Expect.finalize, .name = "finalize" }, + .toHaveBeenCalledWith = .{ + .rfn = Expect.toHaveBeenCalledWith, + .name = "toHaveBeenCalledWith", + }, + .toHaveBeenLastCalledWith = .{ + .rfn = Expect.toHaveBeenLastCalledWith, + .name = "toHaveBeenLastCalledWith", + }, + .toHaveBeenNthCalledWith = .{ + .rfn = Expect.toHaveBeenNthCalledWith, + .name = "toHaveBeenNthCalledWith", + }, + .toHaveReturnedTimes = .{ + .rfn = Expect.toHaveReturnedTimes, + .name = "toHaveReturnedTimes", + }, + .toHaveReturnedWith = .{ + .rfn = Expect.toHaveReturnedWith, + .name = "toHaveReturnedWith", + }, + .toHaveLastReturnedWith = .{ + .rfn = Expect.toHaveLastReturnedWith, + .name = "toHaveLastReturnedWith", + }, + .toHaveNthReturnedWith = .{ + .rfn = Expect.toHaveNthReturnedWith, + .name = "toHaveNthReturnedWith", + }, + .toHaveLength = .{ + .rfn = Expect.toHaveLength, + .name = "toHaveLength", + }, + .toHaveProperty = .{ + .rfn = Expect.toHaveProperty, + .name = "toHaveProperty", + }, + .toBeCloseTo = .{ + .rfn = Expect.toBeCloseTo, + .name = "toBeCloseTo", + }, + .toBeGreaterThan = .{ + .rfn = Expect.toBeGreaterThan, + .name = "toBeGreaterThan", + }, + .toBeGreaterThanOrEqual = .{ + .rfn = Expect.toBeGreaterThanOrEqual, + .name = "toBeGreaterThanOrEqual", + }, + .toBeLessThan = .{ + .rfn = Expect.toBeLessThan, + .name = "toBeLessThan", + }, + .toBeLessThanOrEqual = .{ + .rfn = Expect.toBeLessThanOrEqual, + .name = "toBeLessThanOrEqual", + }, + .toBeInstanceOf = .{ + .rfn = Expect.toBeInstanceOf, + .name = "toBeInstanceOf", + }, + .toContain = .{ + .rfn = Expect.toContain, + .name = "toContain", + }, + .toContainEqual = .{ + .rfn = Expect.toContainEqual, + .name = "toContainEqual", + }, + .toEqual = .{ + .rfn = Expect.toEqual, + .name = "toEqual", + }, + .toMatch = .{ + .rfn = Expect.toMatch, + .name = "toMatch", + }, + .toMatchObject = .{ + .rfn = Expect.toMatchObject, + .name = "toMatchObject", + }, + .toMatchSnapshot = .{ + .rfn = Expect.toMatchSnapshot, + .name = "toMatchSnapshot", + }, + .toMatchInlineSnapshot = .{ + .rfn = Expect.toMatchInlineSnapshot, + .name = "toMatchInlineSnapshot", + }, + .toStrictEqual = .{ + .rfn = Expect.toStrictEqual, + .name = "toStrictEqual", + }, + .toThrow = .{ + .rfn = Expect.toThrow, + .name = "toThrow", + }, + .toThrowErrorMatchingSnapshot = .{ + .rfn = Expect.toThrowErrorMatchingSnapshot, + .name = "toThrowErrorMatchingSnapshot", + }, + .toThrowErrorMatchingInlineSnapshot = .{ + .rfn = Expect.toThrowErrorMatchingInlineSnapshot, + .name = "toThrowErrorMatchingInlineSnapshot", + }, + }, + .{ + .not = .{ + .get = Expect.not, + .name = "not", + }, + .resolves = .{ + .get = Expect.resolves, + .name = "resolves", + }, + .rejects = .{ + .get = Expect.rejects, + .name = "rejects", + }, + }, + ); + + /// Object.is() + pub fn toBe( + this: *Expect, + ctx: js.JSContextRef, + _: js.JSObjectRef, + thisObject: js.JSObjectRef, + arguments: []const js.JSValueRef, + exception: js.ExceptionRef, + ) js.JSValueRef { + if (arguments.len != 1) { + JSC.JSError( + getAllocator(ctx), + ".toBe() takes 1 argument", + .{}, + ctx, + exception, + ); + return js.JSValueMakeUndefined(ctx); + } + this.scope.tests.items[this.test_id].counter.actual += 1; + const left = JSValue.fromRef(arguments[0]); + left.ensureStillAlive(); + const right = this.value; + right.ensureStillAlive(); + const eql = left.isSameValue(right, ctx.ptr()); + if (comptime Environment.allow_assert) { + std.debug.assert(eql == JSC.C.JSValueIsStrictEqual(ctx, left.asObjectRef(), right.asObjectRef())); + } + + if (!eql) { + if (comptime Environment.allow_assert) { + if (left.isString() and right.isString()) { + var left_slice = left.toSlice(ctx, getAllocator(ctx)); + defer left_slice.deinit(); + var right_slice = right.toSlice(ctx, getAllocator(ctx)); + defer right_slice.deinit(); + std.debug.assert(!strings.eqlLong(left_slice.slice(), right_slice.slice(), true)); + } + } + + var lhs_formatter: JSC.ZigConsoleClient.Formatter = JSC.ZigConsoleClient.Formatter{ .globalThis = ctx.ptr() }; + var rhs_formatter: JSC.ZigConsoleClient.Formatter = JSC.ZigConsoleClient.Formatter{ .globalThis = ctx.ptr() }; + + if (comptime Environment.allow_assert) { + Output.prettyErrorln("\nJSType: {s}\nJSType: {s}\n\n", .{ @tagName(left.jsType()), @tagName(right.jsType()) }); + } + + JSC.JSError( + getAllocator(ctx), + "Expected: {}\n\tReceived: {}", + .{ + left.toFmt(ctx.ptr(), &lhs_formatter), + right.toFmt(ctx.ptr(), &rhs_formatter), + }, + ctx, + exception, + ); + + return null; + } + + return thisObject; + } + + pub fn toHaveLength( + this: *Expect, + ctx: js.JSContextRef, + _: js.JSObjectRef, + thisObject: js.JSObjectRef, + arguments: []const js.JSValueRef, + exception: js.ExceptionRef, + ) js.JSValueRef { + if (arguments.len != 1) { + JSC.JSError( + getAllocator(ctx), + ".toHaveLength() takes 1 argument", + .{}, + ctx, + exception, + ); + return js.JSValueMakeUndefined(ctx); + } + this.scope.tests.items[this.test_id].counter.actual += 1; + + const expected = JSC.JSValue.fromRef(arguments[0]).toU32(); + const actual = this.value.getLengthOfArray(ctx.ptr()); + if (expected != actual) { + JSC.JSError( + getAllocator(ctx), + "Expected length to equal {d} but received {d}\n Expected: {d}\n Actual: {d}\n", + .{ + expected, + actual, + expected, + actual, + }, + ctx, + exception, + ); + return null; + } + return thisObject; + } + + pub const toHaveBeenCalledTimes = notImplementedFn; + pub const toHaveBeenCalledWith = notImplementedFn; + pub const toHaveBeenLastCalledWith = notImplementedFn; + pub const toHaveBeenNthCalledWith = notImplementedFn; + pub const toHaveReturnedTimes = notImplementedFn; + pub const toHaveReturnedWith = notImplementedFn; + pub const toHaveLastReturnedWith = notImplementedFn; + pub const toHaveNthReturnedWith = notImplementedFn; + pub const toHaveProperty = notImplementedFn; + pub const toBeCloseTo = notImplementedFn; + pub const toBeGreaterThan = notImplementedFn; + pub const toBeGreaterThanOrEqual = notImplementedFn; + pub const toBeLessThan = notImplementedFn; + pub const toBeLessThanOrEqual = notImplementedFn; + pub const toBeInstanceOf = notImplementedFn; + pub const toContain = notImplementedFn; + pub const toContainEqual = notImplementedFn; + pub const toEqual = notImplementedFn; + pub const toMatch = notImplementedFn; + pub const toMatchObject = notImplementedFn; + pub const toMatchSnapshot = notImplementedFn; + pub const toMatchInlineSnapshot = notImplementedFn; + pub const toStrictEqual = notImplementedFn; + pub const toThrow = notImplementedFn; + pub const toThrowErrorMatchingSnapshot = notImplementedFn; + pub const toThrowErrorMatchingInlineSnapshot = notImplementedFn; + + pub const not = notImplementedProp; + pub const resolves = notImplementedProp; + pub const rejects = notImplementedProp; +}; + +pub const ExpectPrototype = struct { + scope: *DescribeScope, + test_id: TestRunner.Test.ID, + op: Expect.Op.Set = Expect.Op.Set.init(.{}), + + pub const Class = NewClass( + ExpectPrototype, + .{ + .name = "ExpectPrototype", + .read_only = true, + }, + .{ + .call = .{ + .rfn = ExpectPrototype.call, + }, + .extend = .{ + .name = "extend", + .rfn = ExpectPrototype.extend, + }, + .anything = .{ + .name = "anything", + .rfn = ExpectPrototype.anything, + }, + .any = .{ + .name = "any", + .rfn = ExpectPrototype.any, + }, + .arrayContaining = .{ + .name = "arrayContaining", + .rfn = ExpectPrototype.arrayContaining, + }, + .assertions = .{ + .name = "assertions", + .rfn = ExpectPrototype.assertions, + }, + .hasAssertions = .{ + .name = "hasAssertions", + .rfn = ExpectPrototype.hasAssertions, + }, + .objectContaining = .{ + .name = "objectContaining", + .rfn = ExpectPrototype.objectContaining, + }, + .stringContaining = .{ + .name = "stringContaining", + .rfn = ExpectPrototype.stringContaining, + }, + .stringMatching = .{ + .name = "stringMatching", + .rfn = ExpectPrototype.stringMatching, + }, + .addSnapshotSerializer = .{ + .name = "addSnapshotSerializer", + .rfn = ExpectPrototype.addSnapshotSerializer, + }, + }, + .{ + .not = .{ + .name = "not", + .get = ExpectPrototype.not, + }, + .resolves = .{ + .name = "resolves", + .get = ExpectPrototype.resolves, + }, + .rejects = .{ + .name = "rejects", + .get = ExpectPrototype.rejects, + }, + }, + ); + pub const extend = notImplementedFn; + pub const anything = notImplementedFn; + pub const any = notImplementedFn; + pub const arrayContaining = notImplementedFn; + pub const assertions = notImplementedFn; + pub const hasAssertions = notImplementedFn; + pub const objectContaining = notImplementedFn; + pub const stringContaining = notImplementedFn; + pub const stringMatching = notImplementedFn; + pub const addSnapshotSerializer = notImplementedFn; + pub const not = notImplementedProp; + pub const resolves = notImplementedProp; + pub const rejects = notImplementedProp; + + pub fn call( + _: *ExpectPrototype, + ctx: js.JSContextRef, + _: js.JSObjectRef, + _: js.JSObjectRef, + arguments: []const js.JSValueRef, + exception: js.ExceptionRef, + ) js.JSObjectRef { + if (arguments.len != 1) { + JSError(getAllocator(ctx), "expect() requires one argument", .{}, ctx, exception); + return js.JSValueMakeUndefined(ctx); + } + var expect_ = getAllocator(ctx).create(Expect) catch unreachable; + const value = JSC.JSValue.c(arguments[0]); + value.protect(); + expect_.* = .{ + .value = value, + .scope = DescribeScope.active, + .test_id = DescribeScope.active.current_test_id, + }; + expect_.value.ensureStillAlive(); + return Expect.Class.make(ctx, expect_); + } +}; + +pub const TestScope = struct { + counter: Counter = Counter{}, + label: string = "", + parent: *DescribeScope, + callback: js.JSValueRef, + id: TestRunner.Test.ID = 0, + promise: ?*JSInternalPromise = null, + + pub const Class = NewClass(void, .{ .name = "test" }, .{ .call = call, .only = only }, .{}); + + pub const Counter = struct { + expected: u32 = 0, + actual: u32 = 0, + }; + + pub fn only( + // the DescribeScope here is the top of the file, not the real one + _: void, + ctx: js.JSContextRef, + this: js.JSObjectRef, + _: js.JSObjectRef, + arguments: []const js.JSValueRef, + exception: js.ExceptionRef, + ) js.JSObjectRef { + return callMaybeOnly(this, ctx, arguments, exception, true); + } + + pub fn call( + // the DescribeScope here is the top of the file, not the real one + _: void, + ctx: js.JSContextRef, + this: js.JSObjectRef, + _: js.JSObjectRef, + arguments: []const js.JSValueRef, + exception: js.ExceptionRef, + ) js.JSObjectRef { + return callMaybeOnly(this, ctx, arguments, exception, false); + } + + fn callMaybeOnly( + this: js.JSObjectRef, + ctx: js.JSContextRef, + arguments: []const js.JSValueRef, + exception: js.ExceptionRef, + is_only: bool, + ) js.JSObjectRef { + var args = arguments[0..@minimum(arguments.len, 2)]; + var label: string = ""; + if (args.len == 0) { + return this; + } + + if (js.JSValueIsString(ctx, args[0])) { + label = (JSC.JSValue.fromRef(arguments[0]).toSlice(ctx, getAllocator(ctx)).cloneIfNeeded() catch unreachable).slice(); + args = args[1..]; + } + + var function = args[0]; + if (!js.JSValueIsObject(ctx, function) or !js.JSObjectIsFunction(ctx, function)) { + JSError(getAllocator(ctx), "test() expects a function", .{}, ctx, exception); + return this; + } + + if (is_only) { + Jest.runner.?.setOnly(); + } + + if (!is_only and Jest.runner.?.only) + return this; + + js.JSValueProtect(ctx, function); + + DescribeScope.active.tests.append(getAllocator(ctx), TestScope{ + .label = label, + .callback = function, + .parent = DescribeScope.active, + }) catch unreachable; + + return this; + } + + pub const Result = union(TestRunner.Test.Status) { + fail: u32, + pass: u32, // assertion count + pending: void, + }; + + pub fn run( + this: *TestScope, + ) Result { + if (comptime is_bindgen) return undefined; + var vm = VirtualMachine.vm; + defer { + js.JSValueUnprotect(vm.global.ref(), this.callback); + this.callback = null; + } + + const initial_value = js.JSObjectCallAsFunctionReturnValue(vm.global.ref(), this.callback, null, 0, null); + + if (initial_value.isException(vm.global.vm()) or initial_value.isError() or initial_value.isAggregateError(vm.global)) { + vm.defaultErrorHandler(initial_value, null); + return .{ .fail = this.counter.actual }; + } + + if (!initial_value.isEmptyOrUndefinedOrNull() and (initial_value.asPromise() != null or initial_value.asInternalPromise() != null)) { + if (this.promise != null) { + return .{ .pending = .{} }; + } + + this.promise = JSC.JSInternalPromise.resolvedPromise(vm.global, initial_value); + defer { + this.promise = null; + } + + vm.waitForPromise(this.promise.?); + switch (this.promise.?.status(vm.global.vm())) { + .Rejected => { + vm.defaultErrorHandler(this.promise.?.result(vm.global.vm()), null); + return .{ .fail = this.counter.actual }; + }, + else => { + if (this.promise != null) + // don't care about the result + _ = this.promise.?.result(vm.global.vm()); + }, + } + } + + this.callback = null; + + if (this.counter.expected > 0 and this.counter.expected < this.counter.actual) { + Output.prettyErrorln("Test fail: {d} / {d} expectations\n (make this better!)", .{ + this.counter.actual, + this.counter.expected, + }); + return .{ .fail = this.counter.actual }; + } + + return .{ .pass = this.counter.actual }; + } +}; + +pub const DescribeScope = struct { + label: string = "", + parent: ?*DescribeScope = null, + beforeAll: std.ArrayListUnmanaged(JSC.JSValue) = .{}, + beforeEach: std.ArrayListUnmanaged(JSC.JSValue) = .{}, + afterEach: std.ArrayListUnmanaged(JSC.JSValue) = .{}, + afterAll: std.ArrayListUnmanaged(JSC.JSValue) = .{}, + test_id_start: TestRunner.Test.ID = 0, + test_id_len: TestRunner.Test.ID = 0, + tests: std.ArrayListUnmanaged(TestScope) = .{}, + file_id: TestRunner.File.ID, + current_test_id: TestRunner.Test.ID = 0, + + pub const LifecycleHook = enum { + beforeAll, + beforeEach, + afterEach, + afterAll, + }; + + pub const TestEntry = struct { + label: string, + callback: js.JSValueRef, + + pub const List = std.MultiArrayList(TestEntry); + }; + + pub threadlocal var active: *DescribeScope = undefined; + + const CallbackFn = fn ( + this: *DescribeScope, + ctx: js.JSContextRef, + _: js.JSObjectRef, + _: js.JSObjectRef, + arguments: []const js.JSValueRef, + exception: js.ExceptionRef, + ) js.JSObjectRef; + fn createCallback(comptime hook: LifecycleHook) CallbackFn { + return struct { + const this_hook = hook; + pub fn run( + this: *DescribeScope, + ctx: js.JSContextRef, + _: js.JSObjectRef, + _: js.JSObjectRef, + arguments: []const js.JSValueRef, + exception: js.ExceptionRef, + ) js.JSObjectRef { + if (arguments.len == 0 or !JSC.JSValue.c(arguments[0]).isObject() or !JSC.JSValue.c(arguments[0]).isCallable(ctx.vm())) { + JSC.throwInvalidArguments("Expected callback", .{}, ctx, exception); + return null; + } + + JSC.JSValue.c(arguments[0]).protect(); + const name = comptime std.mem.span(@tagName(this_hook)); + @field(this, name).append(getAllocator(ctx), JSC.JSValue.c(arguments[0])) catch unreachable; + return JSC.JSValue.jsBoolean(true).asObjectRef(); + } + }.run; + } + + pub const Class = NewClass( + DescribeScope, + .{ + .name = "describe", + .read_only = true, + }, + .{ + .call = describe, + .afterAll = .{ .rfn = createCallback(.afterAll), .name = "afterAll" }, + .afterEach = .{ .rfn = createCallback(.afterEach), .name = "afterEach" }, + .beforeAll = .{ .rfn = createCallback(.beforeAll), .name = "beforeAll" }, + .beforeEach = .{ .rfn = createCallback(.beforeEach), .name = "beforeEach" }, + }, + .{ + .expect = .{ .get = createExpect, .name = "expect" }, + // kind of a mindfuck but + // describe("foo", () => {}).describe("bar") will wrok + .describe = .{ .get = createDescribe, .name = "describe" }, + .it = .{ .get = createTest, .name = "it" }, + .@"test" = .{ .get = createTest, .name = "test" }, + }, + ); + + pub fn execCallback(this: *DescribeScope, ctx: js.JSContextRef, comptime hook: LifecycleHook) JSValue { + const name = comptime std.mem.span(@tagName(hook)); + var hooks: []JSC.JSValue = @field(this, name).items; + for (hooks) |cb, i| { + if (cb.isEmpty()) continue; + + const err = cb.call(ctx, &.{}); + if (err.isAnyError(ctx)) { + return err; + } + + if (comptime hook == .beforeAll or hook == .afterAll) { + hooks[i] = JSC.JSValue.zero; + } + } + + return JSValue.zero; + } + pub fn runCallback(this: *DescribeScope, ctx: js.JSContextRef, comptime hook: LifecycleHook) JSValue { + var parent = this.parent; + while (parent) |scope| { + const ret = scope.execCallback(ctx, hook); + if (!ret.isEmpty()) { + return ret; + } + parent = scope.parent; + } + + return this.execCallback(ctx, hook); + } + + pub fn describe( + this: *DescribeScope, + ctx: js.JSContextRef, + _: js.JSObjectRef, + _: js.JSObjectRef, + arguments: []const js.JSValueRef, + exception: js.ExceptionRef, + ) js.JSObjectRef { + if (arguments.len == 0 or arguments.len > 2) { + JSError(getAllocator(ctx), "describe() requires 1-2 arguments", .{}, ctx, exception); + return js.JSValueMakeUndefined(ctx); + } + + var label = ZigString.init(""); + var args = arguments; + + if (js.JSValueIsString(ctx, arguments[0])) { + JSC.JSValue.fromRef(arguments[0]).toZigString(&label, ctx.ptr()); + args = args[1..]; + } + + if (args.len == 0 or !js.JSObjectIsFunction(ctx, args[0])) { + JSError(getAllocator(ctx), "describe() requires a callback function", .{}, ctx, exception); + return js.JSValueMakeUndefined(ctx); + } + + var callback = args[0]; + + var scope = getAllocator(ctx).create(DescribeScope) catch unreachable; + scope.* = .{ + .label = (label.toSlice(getAllocator(ctx)).cloneIfNeeded() catch unreachable).slice(), + .parent = this, + .file_id = this.file_id, + }; + var new_this = DescribeScope.Class.make(ctx, scope); + + return scope.run(new_this, ctx, callback, exception); + } + + pub fn run(this: *DescribeScope, thisObject: js.JSObjectRef, ctx: js.JSContextRef, callback: js.JSObjectRef, exception: js.ExceptionRef) js.JSObjectRef { + if (comptime is_bindgen) return undefined; + js.JSValueProtect(ctx, callback); + defer js.JSValueUnprotect(ctx, callback); + var original_active = active; + defer active = original_active; + active = this; + + { + var result = js.JSObjectCallAsFunctionReturnValue(ctx, callback, thisObject, 0, null); + + if (result.asPromise() != null or result.asInternalPromise() != null) { + var vm = JSC.VirtualMachine.vm; + + const promise = JSInternalPromise.resolvedPromise(ctx.ptr(), result); + while (promise.status(ctx.ptr().vm()) == JSPromise.Status.Pending) { + vm.tick(); + } + + switch (promise.status(ctx.ptr().vm())) { + JSPromise.Status.Fulfilled => {}, + else => { + exception.* = promise.result(ctx.ptr().vm()).asObjectRef(); + return null; + }, + } + } else if (result.isAnyError(ctx)) { + exception.* = result.asObjectRef(); + return null; + } + } + + this.runTests(ctx); + return js.JSValueMakeUndefined(ctx); + } + + pub fn runTests(this: *DescribeScope, ctx: js.JSContextRef) void { + // Step 1. Initialize the test block + + const file = this.file_id; + const allocator = getAllocator(ctx); + var tests: []TestScope = this.tests.items; + const end = @truncate(TestRunner.Test.ID, tests.len); + + if (end == 0) return; + + // Step 2. Update the runner with the count of how many tests we have for this block + this.test_id_start = Jest.runner.?.addTestCount(end); + + // Step 3. Run the beforeAll callbacks, in reverse order + // TODO: + + const source: logger.Source = Jest.runner.?.files.items(.source)[file]; + + var i: TestRunner.Test.ID = 0; + + const beforeAll = this.runCallback(ctx, .beforeAll); + if (!beforeAll.isEmpty()) { + while (i < end) { + Jest.runner.?.reportFailure(i + this.test_id_start, source.path.text, tests[i].label, 0, this); + i += 1; + } + this.tests.deinit(allocator); + return; + } + + while (i < end) { + // the test array could resize in the middle of this loop + this.current_test_id = i; + var test_ = tests[i]; + const beforeEach = this.runCallback(ctx, .beforeEach); + + const test_id = i + this.test_id_start; + + if (!beforeEach.isEmpty()) { + Jest.runner.?.reportFailure(test_id, source.path.text, tests[i].label, 0, this); + ctx.bunVM().defaultErrorHandler(beforeEach, null); + i += 1; + continue; + } + + const result = TestScope.run(&test_); + tests[i] = test_; + + switch (result) { + .pass => |count| Jest.runner.?.reportPass(test_id, source.path.text, tests[i].label, count, this), + .fail => |count| Jest.runner.?.reportFailure(test_id, source.path.text, tests[i].label, count, this), + .pending => @panic("Unexpected pending test"), + } + + i += 1; + } + + // invalidate it + this.current_test_id = std.math.maxInt(TestRunner.Test.ID); + + const afterAll = this.execCallback(ctx, .afterAll); + if (!afterAll.isEmpty()) { + ctx.bunVM().defaultErrorHandler(afterAll, null); + } + + this.tests.deinit(allocator); + } + + const ScopeStack = ObjectPool(std.ArrayListUnmanaged(*DescribeScope), null, true, 16); + + // pub fn runBeforeAll(this: *DescribeScope, ctx: js.JSContextRef, exception: js.ExceptionRef) bool { + // var scopes = ScopeStack.get(default_allocator); + // defer scopes.release(); + // scopes.data.clearRetainingCapacity(); + // var cur: ?*DescribeScope = this; + // while (cur) |scope| { + // scopes.data.append(default_allocator, this) catch unreachable; + // cur = scope.parent; + // } + + // // while (scopes.data.popOrNull()) |scope| { + // // scope. + // // } + // } + + pub fn runCallbacks(this: *DescribeScope, ctx: js.JSContextRef, callbacks: std.ArrayListUnmanaged(js.JSObjectRef), exception: js.ExceptionRef) bool { + if (comptime is_bindgen) return undefined; + var i: usize = 0; + while (i < callbacks.items.len) : (i += 1) { + var callback = callbacks.items[i]; + var result = js.JSObjectCallAsFunctionReturnValue(ctx, callback, this, 0); + if (result.isException(ctx.ptr().vm())) { + exception.* = result.asObjectRef(); + return false; + } + } + } + + pub fn createExpect( + _: *DescribeScope, + ctx: js.JSContextRef, + _: js.JSValueRef, + _: js.JSStringRef, + _: js.ExceptionRef, + ) js.JSObjectRef { + var expect_ = getAllocator(ctx).create(ExpectPrototype) catch unreachable; + expect_.* = .{ + .scope = DescribeScope.active, + .test_id = DescribeScope.active.current_test_id, + }; + return ExpectPrototype.Class.make(ctx, expect_); + } + + pub fn createTest( + _: *DescribeScope, + ctx: js.JSContextRef, + _: js.JSValueRef, + _: js.JSStringRef, + _: js.ExceptionRef, + ) js.JSObjectRef { + return js.JSObjectMake(ctx, TestScope.Class.get().*, null); + } + + pub fn createDescribe( + this: *DescribeScope, + ctx: js.JSContextRef, + _: js.JSValueRef, + _: js.JSStringRef, + _: js.ExceptionRef, + ) js.JSObjectRef { + return DescribeScope.Class.make(ctx, this); + } +}; |