From b16ddd95d9225338c86d67cd52a57099ed679cd5 Mon Sep 17 00:00:00 2001 From: Jarred Sumner <709451+Jarred-Sumner@users.noreply.github.com> Date: Sat, 20 May 2023 23:22:12 -0700 Subject: [PATCH] [bun:test] `preload` now supports `beforeAll`, `beforeEach`, `afterAll`, `afterEach` hooks Towards #198 --- src/bun.js/javascript.zig | 130 +++++++++++++++++++++----------------- src/bun.js/test/jest.zig | 130 ++++++++++++++++++++++++++++++++++---- src/bunfig.zig | 54 ++++++++-------- src/cli.zig | 2 +- src/cli/test_command.zig | 13 +++- 5 files changed, 227 insertions(+), 102 deletions(-) diff --git a/src/bun.js/javascript.zig b/src/bun.js/javascript.zig index 93851f7c05..4a1fcbcb16 100644 --- a/src/bun.js/javascript.zig +++ b/src/bun.js/javascript.zig @@ -397,6 +397,9 @@ pub const VirtualMachine = struct { is_from_devserver: bool = false, has_enabled_macro_mode: bool = false, + /// Used by bun:test to set global hooks for beforeAll, beforeEach, etc. + is_in_preload: bool = false, + /// The arguments used to launch the process _after_ the script name and bun and any flags applied to Bun /// "bun run foo --bar" /// ["--bar"] @@ -1572,67 +1575,74 @@ pub const VirtualMachine = struct { return promise; } - for (this.preload) |preload| { - var result = switch (this.bundler.resolver.resolveAndAutoInstall( - this.bundler.fs.top_level_dir, - normalizeSource(preload), - .stmt, - .read_only, - )) { - .success => |r| r, - .failure => |e| { - this.log.addErrorFmt( - null, - logger.Loc.Empty, - this.allocator, - "{s} resolving preload {any}", - .{ - @errorName(e), - js_printer.formatJSONString(preload), - }, - ) catch unreachable; - return e; - }, - .pending, .not_found => { - this.log.addErrorFmt( - null, - logger.Loc.Empty, - this.allocator, - "preload not found {any}", - .{ - js_printer.formatJSONString(preload), - }, - ) catch unreachable; - return error.ModuleNotFound; - }, - }; - promise = JSModuleLoader.loadAndEvaluateModule(this.global, &ZigString.init(result.path().?.text)); - this.pending_internal_promise = promise; - - // pending_internal_promise can change if hot module reloading is enabled - if (this.bun_watcher != null) { - this.eventLoop().performGC(); - switch (this.pending_internal_promise.status(this.global.vm())) { - JSC.JSPromise.Status.Pending => { - while (this.pending_internal_promise.status(this.global.vm()) == .Pending) { - this.eventLoop().tick(); - - if (this.pending_internal_promise.status(this.global.vm()) == .Pending) { - this.eventLoop().autoTick(); - } - } + { + this.is_in_preload = true; + defer this.is_in_preload = false; + for (this.preload) |preload| { + var result = switch (this.bundler.resolver.resolveAndAutoInstall( + this.bundler.fs.top_level_dir, + normalizeSource(preload), + .stmt, + .read_only, + )) { + .success => |r| r, + .failure => |e| { + this.log.addErrorFmt( + null, + logger.Loc.Empty, + this.allocator, + "{s} resolving preload {any}", + .{ + @errorName(e), + js_printer.formatJSONString(preload), + }, + ) catch unreachable; + return e; }, - else => {}, - } - } else { - this.eventLoop().performGC(); - this.waitForPromise(JSC.AnyPromise{ - .Internal = promise, - }); - } + .pending, .not_found => { + this.log.addErrorFmt( + null, + logger.Loc.Empty, + this.allocator, + "preload not found {any}", + .{ + js_printer.formatJSONString(preload), + }, + ) catch unreachable; + return error.ModuleNotFound; + }, + }; + promise = JSModuleLoader.loadAndEvaluateModule(this.global, &ZigString.init(result.path().?.text)); - if (promise.status(this.global.vm()) == .Rejected) - return promise; + this.pending_internal_promise = promise; + JSValue.fromCell(promise).protect(); + defer JSValue.fromCell(promise).unprotect(); + + // pending_internal_promise can change if hot module reloading is enabled + if (this.bun_watcher != null) { + this.eventLoop().performGC(); + switch (this.pending_internal_promise.status(this.global.vm())) { + JSC.JSPromise.Status.Pending => { + while (this.pending_internal_promise.status(this.global.vm()) == .Pending) { + this.eventLoop().tick(); + + if (this.pending_internal_promise.status(this.global.vm()) == .Pending) { + this.eventLoop().autoTick(); + } + } + }, + else => {}, + } + } else { + this.eventLoop().performGC(); + this.waitForPromise(JSC.AnyPromise{ + .Internal = promise, + }); + } + + if (promise.status(this.global.vm()) == .Rejected) + return promise; + } } // only load preloads once @@ -1640,9 +1650,11 @@ pub const VirtualMachine = struct { promise = JSModuleLoader.loadAndEvaluateModule(this.global, ZigString.static(main_file_name)); this.pending_internal_promise = promise; + JSC.JSValue.fromCell(promise).ensureStillAlive(); } else { promise = JSModuleLoader.loadAndEvaluateModule(this.global, &ZigString.init(this.main)); this.pending_internal_promise = promise; + JSC.JSValue.fromCell(promise).ensureStillAlive(); } return promise; diff --git a/src/bun.js/test/jest.zig b/src/bun.js/test/jest.zig index f841060858..10cd581ad8 100644 --- a/src/bun.js/test/jest.zig +++ b/src/bun.js/test/jest.zig @@ -380,6 +380,13 @@ pub const TestRunner = struct { last_test_timeout_timer_duration: u32 = 0, active_test_for_timeout: ?TestRunner.Test.ID = null, + global_callbacks: struct { + beforeAll: std.ArrayListUnmanaged(JSC.JSValue) = .{}, + beforeEach: std.ArrayListUnmanaged(JSC.JSValue) = .{}, + afterEach: std.ArrayListUnmanaged(JSC.JSValue) = .{}, + afterAll: std.ArrayListUnmanaged(JSC.JSValue) = .{}, + } = .{}, + pub const Drainer = JSC.AnyTask.New(TestRunner, drain); pub fn onTestTimeout(timer: *bun.uws.Timer) callconv(.C) void { @@ -814,6 +821,39 @@ pub const Snapshots = struct { pub const Jest = struct { pub var runner: ?*TestRunner = null; + fn globalHook(comptime name: string) JSC.JSHostFunctionType { + return struct { + pub fn appendGlobalFunctionCallback( + globalThis: *JSC.JSGlobalObject, + callframe: *JSC.CallFrame, + ) callconv(.C) JSValue { + const arguments = callframe.arguments(2); + if (arguments.len < 1) { + globalThis.throwNotEnoughArguments("callback", 1, arguments.len); + return .zero; + } + + const function = arguments.ptr[0]; + if (function.isEmptyOrUndefinedOrNull() or !function.isCallable(globalThis.vm())) { + globalThis.throwInvalidArgumentType(name, "callback", "function"); + return .zero; + } + + if (function.getLengthOfArray(globalThis) > 0) { + globalThis.throw("done() callback is not implemented in global hooks yet. Please make your function take no arguments", .{}); + return .zero; + } + + function.protect(); + @field(Jest.runner.?.global_callbacks, name).append( + bun.default_allocator, + function, + ) catch unreachable; + return JSC.JSValue.jsUndefined(); + } + }.appendGlobalFunctionCallback; + } + pub fn call( _: void, ctx: js.JSContextRef, @@ -840,6 +880,40 @@ pub const Jest = struct { JSError(getAllocator(ctx), "Bun.jest() expects an absolute file path", .{}, ctx, exception); return js.JSValueMakeUndefined(ctx); } + var vm = ctx.bunVM(); + if (vm.is_in_preload) { + var global_hooks_object = JSC.JSValue.createEmptyObject(ctx, 8); + global_hooks_object.ensureStillAlive(); + + const notSupportedHereFn = struct { + pub fn notSupportedHere( + globalThis: *JSC.JSGlobalObject, + _: *JSC.CallFrame, + ) callconv(.C) JSValue { + globalThis.throw("This function can only be used in a test.", .{}); + return .zero; + } + }.notSupportedHere; + const notSupportedHere = JSC.NewFunction(ctx, null, 0, notSupportedHereFn, false); + notSupportedHere.ensureStillAlive(); + + inline for (.{ + "expect", + "describe", + "it", + "test", + }) |name| { + global_hooks_object.put(ctx, ZigString.static(name), notSupportedHere); + } + + inline for (.{ "beforeAll", "beforeEach", "afterAll", "afterEach" }) |name| { + const function = JSC.NewFunction(ctx, null, 1, globalHook(name), false); + function.ensureStillAlive(); + global_hooks_object.put(ctx, ZigString.static(name), function); + } + return global_hooks_object.asObjectRef(); + } + var filepath = Fs.FileSystem.instance.filename_store.append([]const u8, slice) catch unreachable; var scope = runner_.getOrPutFile(filepath); @@ -3686,7 +3760,49 @@ pub const DescribeScope = struct { return JSValue.zero; } + + pub fn runGlobalCallbacks(globalThis: *JSC.JSGlobalObject, comptime hook: LifecycleHook) ?JSValue { + // global callbacks + for (@field(Jest.runner.?.global_callbacks, @tagName(hook)).items) |cb| { + if (cb.isEmpty()) continue; + + const pending_test = Jest.runner.?.pending_test; + // forbid `expect()` within hooks + Jest.runner.?.pending_test = null; + const orig_did_pending_test_fail = Jest.runner.?.did_pending_test_fail; + + Jest.runner.?.did_pending_test_fail = false; + + const vm = VirtualMachine.get(); + // note: we do not support "done" callback in global hooks in the first release. + var result: JSC.JSValue = cb.call(globalThis, &.{}); + if (result.asAnyPromise()) |promise| { + if (promise.status(globalThis.vm()) == .Pending) { + result.protect(); + vm.waitForPromise(promise); + result.unprotect(); + } + + result = promise.result(globalThis.vm()); + } + + Jest.runner.?.pending_test = pending_test; + Jest.runner.?.did_pending_test_fail = orig_did_pending_test_fail; + if (result.isAnyError()) return result; + } + + if (comptime hook == .beforeAll or hook == .afterAll) { + @field(Jest.runner.?.global_callbacks, @tagName(hook)).items.len = 0; + } + + return null; + } + pub fn runCallback(this: *DescribeScope, ctx: js.JSContextRef, comptime hook: LifecycleHook) JSValue { + if (runGlobalCallbacks(ctx, hook)) |err| { + return err; + } + var parent = this.parent; while (parent) |scope| { const ret = scope.execCallback(ctx, hook); @@ -3890,20 +4006,6 @@ pub const DescribeScope = struct { // // } // } - 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, diff --git a/src/bunfig.zig b/src/bunfig.zig index ef7cfba4df..af38424517 100644 --- a/src/bunfig.zig +++ b/src/bunfig.zig @@ -118,6 +118,32 @@ pub const Bunfig = struct { }; } + fn loadPreload( + this: *Parser, + allocator: std.mem.Allocator, + expr: js_ast.Expr, + ) !void { + if (expr.asArray()) |array_| { + var array = array_; + var preloads = try std.ArrayList(string).initCapacity(allocator, array.array.items.len); + errdefer preloads.deinit(); + while (array.next()) |item| { + try this.expect(item, .e_string); + if (item.data.e_string.len() > 0) + preloads.appendAssumeCapacity(try item.data.e_string.string(allocator)); + } + this.ctx.preloads = preloads.items; + } else if (expr.data == .e_string) { + if (expr.data.e_string.len() > 0) { + var preloads = try allocator.alloc(string, 1); + preloads[0] = try expr.data.e_string.string(allocator); + this.ctx.preloads = preloads; + } + } else if (expr.data != .e_null) { + try this.addError(expr.loc, "Expected preload to be an array"); + } + } + pub fn parse(this: *Parser, comptime cmd: Command.Tag) !void { const json = this.json; var allocator = this.allocator; @@ -171,19 +197,7 @@ pub const Bunfig = struct { } if (json.get("preload")) |expr| { - if (expr.asArray()) |array_| { - var array = array_; - var preloads = try std.ArrayList(string).initCapacity(allocator, array.array.items.len); - errdefer preloads.deinit(); - while (array.next()) |item| { - try this.expect(item, .e_string); - if (item.data.e_string.len() > 0) - preloads.appendAssumeCapacity(try item.data.e_string.string(allocator)); - } - this.ctx.preloads = preloads.items; - } else if (expr.data != .e_null) { - try this.addError(expr.loc, "Expected preload to be an array"); - } + try this.loadPreload(allocator, expr); } } @@ -214,19 +228,7 @@ pub const Bunfig = struct { } if (test_.get("preload")) |expr| { - if (expr.asArray()) |array_| { - var array = array_; - var preloads = try std.ArrayList(string).initCapacity(allocator, array.array.items.len); - errdefer preloads.deinit(); - while (array.next()) |item| { - try this.expect(item, .e_string); - if (item.data.e_string.len() > 0) - preloads.appendAssumeCapacity(try item.data.e_string.string(allocator)); - } - this.ctx.preloads = preloads.items; - } else if (expr.data != .e_null) { - try this.addError(expr.loc, "Expected preload to be an array"); - } + try this.loadPreload(allocator, expr); } } } diff --git a/src/cli.zig b/src/cli.zig index 3b1f7e3058..be8f9fe76d 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -439,7 +439,7 @@ pub const Arguments = struct { opts.no_summary = args.flag("--no-summary"); - if (cmd != .DevCommand) { + if (cmd == .AutoCommand or cmd == .RunCommand or cmd == .TestCommand) { const preloads = args.options("--preload"); if (ctx.preloads.len > 0 and preloads.len > 0) { var all = std.ArrayList(string).initCapacity(ctx.allocator, ctx.preloads.len + preloads.len) catch unreachable; diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index 548ed2dc60..996289ac4c 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -654,12 +654,12 @@ pub const TestCommand = struct { if (files.len > 1) { for (files[0 .. files.len - 1]) |file_name| { - TestCommand.run(reporter, vm, file_name.slice(), allocator) catch {}; + TestCommand.run(reporter, vm, file_name.slice(), allocator, false) catch {}; Global.mimalloc_cleanup(false); } } - TestCommand.run(reporter, vm, files[files.len - 1].slice(), allocator) catch {}; + TestCommand.run(reporter, vm, files[files.len - 1].slice(), allocator, true) catch {}; } }; @@ -678,6 +678,7 @@ pub const TestCommand = struct { vm: *JSC.VirtualMachine, file_name: string, _: std.mem.Allocator, + is_last: bool, ) !void { defer { js_ast.Expr.Data.Store.reset(); @@ -769,5 +770,13 @@ pub const TestCommand = struct { Output.flush(); } } + + if (is_last) { + if (jest.Jest.runner != null) { + if (jest.DescribeScope.runGlobalCallbacks(vm.global, .afterAll)) |after| { + vm.global.bunVM().runErrorHandler(after, null); + } + } + } } };