From cf3caba3dc3d8278ff0d06fd149c3f8fcae17979 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sun, 25 Aug 2024 01:39:44 -0700 Subject: [PATCH] [bun test] Introduce a way to filter tests to run by byte range in file --- src/bun.js/module_loader.zig | 2 + src/bun.js/test/jest.zig | 76 ++++++-- src/bundler.zig | 2 + src/cli/test_command.zig | 120 +++++++++--- src/js_ast.zig | 54 +++++- src/js_parser.zig | 99 ++++++++-- src/js_printer.zig | 37 +++- src/options.zig | 4 + src/runtime.zig | 2 + test/js/bun/test/bun-byte-range-fixture.ts | 53 +++++ test/js/bun/test/bun-byte-range.test.ts | 214 +++++++++++++++++++++ 11 files changed, 602 insertions(+), 61 deletions(-) create mode 100644 test/js/bun/test/bun-byte-range-fixture.ts create mode 100644 test/js/bun/test/bun-byte-range.test.ts diff --git a/src/bun.js/module_loader.zig b/src/bun.js/module_loader.zig index 21a472a2e7..78f13c6e94 100644 --- a/src/bun.js/module_loader.zig +++ b/src/bun.js/module_loader.zig @@ -523,6 +523,7 @@ pub const RuntimeTranspilerStore = struct { .dont_bundle_twice = true, .allow_commonjs = true, .inject_jest_globals = bundler.options.rewrite_jest_for_tests and is_main, + .inline_loc_for_tests = bundler.options.has_byte_range_filter_for_tests and is_main, .set_breakpoint_on_first_line = vm.debugger != null and vm.debugger.?.set_breakpoint_on_first_line and is_main and @@ -1664,6 +1665,7 @@ pub const ModuleLoader = struct { .dont_bundle_twice = true, .allow_commonjs = true, .inject_jest_globals = jsc_vm.bundler.options.rewrite_jest_for_tests and is_main, + .inline_loc_for_tests = jsc_vm.bundler.options.has_byte_range_filter_for_tests and is_main, .keep_json_and_toml_as_one_statement = true, .set_breakpoint_on_first_line = is_main and jsc_vm.debugger != null and diff --git a/src/bun.js/test/jest.zig b/src/bun.js/test/jest.zig index 694ab86e48..011cc22d96 100644 --- a/src/bun.js/test/jest.zig +++ b/src/bun.js/test/jest.zig @@ -67,6 +67,7 @@ pub const TestRunner = struct { run_todo: bool = false, last_file: u64 = 0, bail: u32 = 0, + byte_range_filter: []const logger.Range = &.{}, allocator: std.mem.Allocator, callback: *Callback = undefined, @@ -106,6 +107,26 @@ pub const TestRunner = struct { pub const Drainer = JSC.AnyTask.New(TestRunner, drain); + pub fn isOutsideByteRangeFilter(this: *const TestRunner, byte_range: logger.Range) bool { + if (byte_range.len == 0 or this.byte_range_filter.len == 0) { + return false; + } + + const start_test_range = byte_range.loc.start; + const end_test_range = byte_range.end().start; + for (this.byte_range_filter) |range| { + const user_provided_start = range.loc.start; + const user_provided_end = range.end().start; + + // Check if the ranges overlap + if (start_test_range <= user_provided_end and end_test_range >= user_provided_start) { + return false; // Ranges overlap + } + } + + return true; + } + pub fn onTestTimeout(this: *TestRunner, now: *const bun.timespec, vm: *VirtualMachine) void { _ = vm; // autofix this.event_loop_timer.state = .FIRED; @@ -170,6 +191,12 @@ pub const TestRunner = struct { if (this.only) { return; } + + // byte range filter overrides only + if (this.byte_range_filter.len > 0) { + return; + } + this.only = true; const list = this.queue.readableSlice(0); @@ -272,6 +299,7 @@ pub const TestRunner = struct { pub const Jest = struct { pub var runner: ?*TestRunner = null; + pub var is_byte_range_filter_enabled: bool = false; fn globalHook(comptime name: string) JSC.JSHostFunctionType { return struct { @@ -590,6 +618,8 @@ pub const TestScope = struct { tag: Tag = .pass, snapshot_count: usize = 0, + byte_range: logger.Range = .{}, + // null if the test does not set a timeout timeout_millis: u32 = std.math.maxInt(u32), @@ -831,6 +861,7 @@ pub const DescribeScope = struct { done: bool = false, skip_count: u32 = 0, tag: Tag = .pass, + byte_range: logger.Range = .{}, fn isWithinOnlyScope(this: *const DescribeScope) bool { if (this.tag == .only) return true; @@ -1380,7 +1411,7 @@ pub const TestRunnerTask = struct { var test_: TestScope = this.describe.tests.items[test_id]; describe.current_test_id = test_id; - if (test_.func == .zero or !describe.shouldEvaluateScope() or (test_.tag != .only and Jest.runner.?.only)) { + if (test_.func == .zero or !describe.shouldEvaluateScope() or (test_.tag != .only and Jest.runner.?.only) or Jest.runner.?.isOutsideByteRangeFilter(test_.byte_range)) { const tag = if (!describe.shouldEvaluateScope()) describe.tag else test_.tag; switch (tag) { .todo => { @@ -1689,18 +1720,15 @@ inline fn createScope( comptime tag: Tag, ) JSValue { const this = callframe.this(); - const arguments = callframe.arguments(3); - const args = arguments.slice(); + const arguments = callframe.arguments(4); + var description, var function, var options, var test_range_value = arguments.ptr; + const args_len = arguments.len; - if (args.len == 0) { + if (args_len == 0) { globalThis.throwPretty("{s} expects a description or function", .{signature}); return .zero; } - var description = args[0]; - var function = if (args.len > 1) args[1] else .zero; - var options = if (args.len > 2) args[2] else .zero; - if (description.isEmptyOrUndefinedOrNull() or !description.isString()) { function = description; description = .zero; @@ -1713,9 +1741,33 @@ inline fn createScope( } } + var test_range = logger.Range.None; + + if (comptime tag == .pass or tag == .only) { + // Handle the byte ranges inserted by the transpiler. + // Let's not run any of this if it's not enabled. + if (Jest.is_byte_range_filter_enabled) { + if (options.isArray() and args_len == 3) { + test_range_value = options; + options = .zero; + } + + if (test_range_value.isArray() and test_range_value.getLength(globalThis) == 2) { + test_range = logger.Range{ + .loc = .{ .start = test_range_value.getDirectIndex(globalThis, 0).coerceToInt32(globalThis) }, + .len = test_range_value.getDirectIndex(globalThis, 1).coerceToInt32(globalThis), + }; + } + + if (globalThis.hasException()) { + return .zero; + } + } + } + var timeout_ms: u32 = std.math.maxInt(u32); if (options.isNumber()) { - timeout_ms = @as(u32, @intCast(@max(args[2].coerce(i32, globalThis), 0))); + timeout_ms = @as(u32, @intCast(@max(options.coerce(i32, globalThis), 0))); } else if (options.isObject()) { if (options.get(globalThis, "timeout")) |timeout| { if (!timeout.isNumber()) { @@ -1802,6 +1854,7 @@ inline fn createScope( .func_arg = function_args, .func_has_callback = has_callback, .timeout_millis = timeout_ms, + .byte_range = test_range, }) catch unreachable; } else { var scope = allocator.create(DescribeScope) catch unreachable; @@ -1810,6 +1863,7 @@ inline fn createScope( .parent = parent, .file_id = parent.file_id, .tag = tag_to_use, + .byte_range = test_range, }; return scope.run(globalThis, function, &.{}); @@ -2067,7 +2121,7 @@ fn eachBind( if (Jest.runner.?.filter_regex) |regex| { var buffer: bun.MutableString = Jest.runner.?.filter_buffer; buffer.reset(); - appendParentLabel(&buffer, parent) catch @panic("Bun ran out of memory while filtering tests"); + appendParentLabel(&buffer, parent) catch bun.outOfMemory(); buffer.append(formattedLabel) catch unreachable; const str = bun.String.fromBytes(buffer.toOwnedSliceLeaky()); is_skip = !regex.matches(str); @@ -2118,7 +2172,7 @@ inline fn createEach( comptime signature: string, comptime is_test: bool, ) JSValue { - const arguments = callframe.arguments(1); + const arguments = callframe.arguments(2); const args = arguments.slice(); if (args.len == 0) { diff --git a/src/bundler.zig b/src/bundler.zig index d1d561d902..a42bba8481 100644 --- a/src/bundler.zig +++ b/src/bundler.zig @@ -1279,6 +1279,7 @@ pub const Bundler = struct { virtual_source: ?*const logger.Source = null, replace_exports: runtime.Runtime.Features.ReplaceableExport.Map = .{}, inject_jest_globals: bool = false, + inline_loc_for_tests: bool = false, set_breakpoint_on_first_line: bool = false, emit_decorator_metadata: bool = false, remove_cjs_module_wrapper: bool = false, @@ -1433,6 +1434,7 @@ pub const Bundler = struct { opts.features.jsx_optimization_hoist = bundler.options.jsx_optimization_hoist orelse opts.features.jsx_optimization_inline; opts.features.inject_jest_globals = this_parse.inject_jest_globals; + opts.features.inline_loc_for_tests = this_parse.inline_loc_for_tests; opts.features.minify_syntax = bundler.options.minify_syntax; opts.features.minify_identifiers = bundler.options.minify_identifiers; opts.features.dead_code_elimination = bundler.options.dead_code_elimination; diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index 86201c5526..f1acb8a449 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -723,6 +723,51 @@ pub const TestCommand = struct { lcov: bool, }; + const TestFilePath = struct { + path: []const u8 = "", + byte_ranges: std.ArrayListUnmanaged(logger.Range) = .{}, + + pub fn slice(this: *const TestFilePath) string { + return this.path; + } + + pub fn update(this: *TestFilePath, input: []const u8) !void { + var remaining = input; + while (remaining.len > 0) { + const end_index = strings.indexOfChar(remaining, ':') orelse return error.InvalidRange; + const start_buffer = remaining[0..end_index]; + const next_i = strings.indexOf(remaining, "::") orelse remaining.len; + const end_buffer = remaining[@min(end_index + 1, remaining.len)..next_i]; + + const start = std.fmt.parseInt(i32, start_buffer, 10) catch { + Output.err(error.InvalidByteRange, "Invalid start byte range passed to bun test filter: {s}", .{remaining}); + Global.exit(1); + }; + + const len = std.fmt.parseInt(i32, end_buffer, 10) catch { + Output.err(error.InvalidByteRange, "Invalid end range passed to bun test filter: {s}", .{remaining}); + Global.exit(1); + }; + try this.byte_ranges.append(bun.default_allocator, .{ + .loc = .{ .start = start }, + .len = len, + }); + remaining = remaining[@min(next_i + 2, remaining.len)..]; + } + } + }; + const PathsOrFiles = union(enum) { + paths: []const PathString, + files: []const TestFilePath, + + pub fn isEmpty(this: *const PathsOrFiles) bool { + return switch (this.*) { + .paths => |paths| paths.len == 0, + .files => |files| files.len == 0, + }; + } + }; + pub fn exec(ctx: Command.Context) !void { if (comptime is_bindgen) unreachable; @@ -843,10 +888,11 @@ pub const TestCommand = struct { _ = vm.global.setTimeZone(&JSC.ZigString.init(TZ_NAME)); } - var results = try std.ArrayList(PathString).initCapacity(ctx.allocator, ctx.positionals.len); + var results = bun.StringArrayHashMap(TestFilePath).init(ctx.allocator); + try results.ensureTotalCapacity(ctx.positionals.len); defer results.deinit(); - const test_files, const search_count = scan: { + const test_files: PathsOrFiles, const search_count = scan: { if (for (ctx.positionals) |arg| { if (std.fs.path.isAbsolute(arg) or strings.startsWith(arg, "./") or @@ -855,10 +901,22 @@ pub const TestCommand = struct { strings.startsWith(arg, "..\\")))) break true; } else false) { // One of the files is a filepath. Instead of treating the arguments as filters, treat them as filepaths - for (ctx.positionals[1..]) |arg| { - results.appendAssumeCapacity(PathString.init(arg)); + for (ctx.positionals[1..]) |arg_| { + const range_index = strings.indexOf(arg_, "::"); + const path = if (range_index) |index| arg_[0..index] else arg_; + var gpe = results.getOrPutAssumeCapacity(path); + if (!gpe.found_existing) { + gpe.value_ptr.* = TestFilePath{ + .path = path, + .byte_ranges = .{}, + }; + } + + if (range_index != null) { + try gpe.value_ptr.update(arg_[@min(range_index.? + 2, arg_.len)..]); + } } - break :scan .{ results.items, 0 }; + break :scan .{ .{ .files = results.values() }, 0 }; } // Treat arguments as filters and scan the codebase @@ -880,13 +938,13 @@ pub const TestCommand = struct { ctx.allocator.free(i); ctx.allocator.free(filter_names_normalized); }; - + var scanner_results = std.ArrayList(PathString).init(bun.default_allocator); var scanner = Scanner{ .dirs_to_scan = Scanner.Fifo.init(ctx.allocator), .options = &vm.bundler.options, .fs = vm.bundler.fs, .filter_names = filter_names_normalized, - .results = &results, + .results = &scanner_results, }; const dir_to_scan = brk: { if (ctx.debug.test_directory.len > 0) { @@ -899,10 +957,10 @@ pub const TestCommand = struct { scanner.scan(dir_to_scan); scanner.dirs_to_scan.deinit(); - break :scan .{ scanner.results.items, scanner.search_count }; + break :scan .{ .{ .paths = scanner.results.items }, scanner.search_count }; }; - if (test_files.len > 0) { + if (!test_files.isEmpty()) { vm.hot_reload = ctx.debug.hot_reload; switch (vm.hot_reload) { @@ -912,7 +970,7 @@ pub const TestCommand = struct { } // vm.bundler.fs.fs.readDirectory(_dir: string, _handle: ?std.fs.Dir) - runAllTests(reporter, vm, test_files, ctx.allocator); + runAllTests(reporter, vm, test_files); } try jest.Jest.runner.?.snapshots.writeSnapshotFile(); @@ -954,7 +1012,7 @@ pub const TestCommand = struct { Output.flush(); - if (test_files.len == 0) { + if (test_files.isEmpty()) { if (ctx.positionals.len == 0) { Output.prettyErrorln( \\No tests found! @@ -1112,30 +1170,30 @@ pub const TestCommand = struct { pub fn runAllTests( reporter_: *CommandLineReporter, vm_: *JSC.VirtualMachine, - files_: []const PathString, - allocator_: std.mem.Allocator, + files_: PathsOrFiles, ) void { const Context = struct { reporter: *CommandLineReporter, vm: *JSC.VirtualMachine, - files: []const PathString, - allocator: std.mem.Allocator, + files: PathsOrFiles, pub fn begin(this: *@This()) void { const reporter = this.reporter; const vm = this.vm; - var files = this.files; - const allocator = this.allocator; - bun.assert(files.len > 0); + const paths_or_files = this.files; - if (files.len > 1) { - for (files[0 .. files.len - 1]) |file_name| { - TestCommand.run(reporter, vm, file_name.slice(), allocator, false) catch {}; - reporter.jest.default_timeout_override = std.math.maxInt(u32); - Global.mimalloc_cleanup(false); - } + switch (paths_or_files) { + inline else => |files| { + if (files.len > 1) { + for (files[0 .. files.len - 1]) |file_name| { + TestCommand.run(reporter, vm, file_name.slice(), if (comptime @TypeOf(files) == []const PathString) &.{} else file_name.byte_ranges.items, false) catch {}; + reporter.jest.default_timeout_override = std.math.maxInt(u32); + Global.mimalloc_cleanup(false); + } + } + + TestCommand.run(reporter, vm, files[files.len - 1].slice(), if (comptime @TypeOf(files) == []const PathString) &.{} else files[files.len - 1].byte_ranges.items, true) catch {}; + }, } - - TestCommand.run(reporter, vm, files[files.len - 1].slice(), allocator, true) catch {}; } }; @@ -1143,7 +1201,11 @@ pub const TestCommand = struct { vm_.eventLoop().ensureWaker(); vm_.arena = &arena; vm_.allocator = arena.allocator(); - var ctx = Context{ .reporter = reporter_, .vm = vm_, .files = files_, .allocator = allocator_ }; + vm_.bundler.options.has_byte_range_filter_for_tests = files_ == .files; + vm_.bundler.resolver.opts.has_byte_range_filter_for_tests = files_ == .files; + jest.Jest.is_byte_range_filter_enabled = files_ == .files; + + var ctx = Context{ .reporter = reporter_, .vm = vm_, .files = files_ }; vm_.runWithAPILock(Context, &ctx, Context.begin); } @@ -1153,7 +1215,7 @@ pub const TestCommand = struct { reporter: *CommandLineReporter, vm: *JSC.VirtualMachine, file_name: string, - _: std.mem.Allocator, + byte_ranges: []const logger.Range, is_last: bool, ) !void { defer { @@ -1180,6 +1242,7 @@ pub const TestCommand = struct { const file_start = reporter.jest.files.len; const resolution = try vm.bundler.resolveEntryPoint(file_name); vm.clearEntryPoint(); + reporter.jest.byte_range_filter = byte_ranges; const file_path = resolution.path_pair.primary.text; const file_title = bun.path.relative(FileSystem.instance.top_level_dir, file_path); @@ -1243,6 +1306,7 @@ pub const TestCommand = struct { vm.eventLoop().tick(); var prev_unhandled_count = vm.unhandled_error_counter; + while (vm.active_tasks > 0) : (vm.eventLoop().flushImmediateQueue()) { if (!jest.Jest.runner.?.has_pending_tests) { jest.Jest.runner.?.drain(); diff --git a/src/js_ast.zig b/src/js_ast.zig index 94d5f68e8c..92a0746f23 100644 --- a/src/js_ast.zig +++ b/src/js_ast.zig @@ -1686,6 +1686,15 @@ pub const E = struct { was_originally_identifier: bool = false, }; + pub const LocationIdentifier = struct { + ref: Ref = Ref.None, + + /// If true, this was originally an identifier expression such as "foo". If + /// false, this could potentially have been a member access expression such + /// as "ns.foo" off of an imported namespace object. + was_originally_identifier: bool = false, + }; + /// This is a dot expression on exports, such as `exports.`. It is given /// it's own AST node to allow CommonJS unwrapping, in which this can just be /// the identifier in the Ref @@ -3876,6 +3885,17 @@ pub const Expr = struct { }, }; }, + E.LocationIdentifier => { + return Expr{ + .loc = loc, + .data = Data{ + .e_location_identifier = .{ + .ref = st.ref, + .was_originally_identifier = st.was_originally_identifier, + }, + }, + }; + }, E.ImportIdentifier => { return Expr{ .loc = loc, @@ -4266,6 +4286,15 @@ pub const Expr = struct { }, }; }, + E.LocationIdentifier => return Expr{ + .loc = loc, + .data = Data{ + .e_location_identifier = .{ + .ref = st.ref, + .was_originally_identifier = st.was_originally_identifier, + }, + }, + }, E.ImportIdentifier => { return Expr{ .loc = loc, @@ -4449,8 +4478,7 @@ pub const Expr = struct { pub fn isRef(this: Expr, ref: Ref) bool { return switch (this.data) { - .e_import_identifier => |import_identifier| import_identifier.ref.eql(ref), - .e_identifier => |ident| ident.ref.eql(ref), + inline .e_identifier, .e_import_identifier, .e_location_identifier => |ident| ident.ref.eql(ref), else => false, }; } @@ -4478,6 +4506,8 @@ pub const Expr = struct { e_import, e_identifier, e_import_identifier, + e_location_identifier, + e_location_dot, e_private_identifier, e_commonjs_export_identifier, e_module_dot_exports, @@ -4544,6 +4574,8 @@ pub const Expr = struct { .e_arrow => writer.writeAll("arrow"), .e_identifier => writer.writeAll("identifier"), .e_import_identifier => writer.writeAll("import identifier"), + .e_location_identifier => writer.writeAll("location identifier"), + .e_location_dot => writer.writeAll("location dot"), .e_private_identifier => writer.writeAll("#privateIdentifier"), .e_jsx_element => writer.writeAll(""), .e_missing => writer.writeAll(""), @@ -5173,6 +5205,23 @@ pub const Expr = struct { e_identifier: E.Identifier, e_import_identifier: E.ImportIdentifier, + + // These are used by the printer to insert the byte ranges of the source + // locations at print time when calling the functions as an extra argument. + // + // Currently, this is only used by bun:test when calling: + // - test(label, callback, [start, end]) + // - test.only(label, callback, [start, end]) + // - test.each(label, callback, [start, end]) + // - describe(label, callback, [start, end]) + // - describe.only(label, callback, [start, end]) + // - describe.each(label, callback, [start, end]) + // + // We might use it later to implement toMatchInlineSnapshot. + // + e_location_identifier: E.LocationIdentifier, + e_location_dot: *E.Dot, + e_private_identifier: E.PrivateIdentifier, e_commonjs_export_identifier: E.CommonJSExportIdentifier, e_module_dot_exports, @@ -6003,6 +6052,7 @@ pub const Expr = struct { .e_identifier, .e_import_identifier, + .e_location_identifier, .e_private_identifier, .e_commonjs_export_identifier, => error.@"Cannot convert identifier to JS. Try a statically-known value", diff --git a/src/js_parser.zig b/src/js_parser.zig index 539932bf79..fc58774f05 100644 --- a/src/js_parser.zig +++ b/src/js_parser.zig @@ -4923,6 +4923,34 @@ const Jest = struct { beforeAll: Ref = Ref.None, afterAll: Ref = Ref.None, jest: Ref = Ref.None, + + const BunTestField = enum { + expect, + describe, + it, + @"test", + beforeEach, + afterEach, + beforeAll, + afterAll, + jest, + }; + + const map = bun.ComptimeEnumMap(BunTestField); + + pub fn setFromClauseItems(this: *Jest, items: []const js_ast.ClauseItem) void { + for (items) |item| { + if (map.get(item.alias)) |field| { + switch (field) { + inline else => |tag| @field(this, @tagName(tag)) = item.name.ref.?, + } + } + } + } + + pub fn shouldBecomeLocationIdentifierForTests(this: *const Jest, ref: Ref) bool { + return this.it.eql(ref) or this.describe.eql(ref) or this.@"test".eql(ref); + } }; // workaround for https://github.com/ziglang/zig/issues/10903 @@ -6149,6 +6177,11 @@ fn NewParser_( } } + // Handle functions which need to have their source location tracked at runtime + if (p.options.features.inline_loc_for_tests and (p.jest.shouldBecomeLocationIdentifierForTests(ref))) { + return p.newLocationIdentifier(ref, opts.was_originally_identifier, loc); + } + // Substitute an EImportIdentifier now if this is an import item if (p.is_import_item.contains(ref)) { return p.newExpr( @@ -6222,6 +6255,12 @@ fn NewParser_( }; } + fn newLocationIdentifier(p: *P, ref: Ref, was_originally_identifier: bool, loc: logger.Loc) Expr { + // This function exists entirely for @setCold(true); + @setCold(true); + return p.newExpr(E.LocationIdentifier{ .ref = ref, .was_originally_identifier = was_originally_identifier }, loc); + } + pub fn generateImportStmt( p: *P, import_path: string, @@ -6874,15 +6913,11 @@ fn NewParser_( p.filename_ref = try p.declareCommonJSSymbol(.unbound, "__filename"); if (p.options.features.inject_jest_globals) { - p.jest.describe = try p.declareCommonJSSymbol(.unbound, "describe"); - p.jest.@"test" = try p.declareCommonJSSymbol(.unbound, "test"); - p.jest.jest = try p.declareCommonJSSymbol(.unbound, "jest"); - p.jest.it = try p.declareCommonJSSymbol(.unbound, "it"); - p.jest.expect = try p.declareCommonJSSymbol(.unbound, "expect"); - p.jest.beforeEach = try p.declareCommonJSSymbol(.unbound, "beforeEach"); - p.jest.afterEach = try p.declareCommonJSSymbol(.unbound, "afterEach"); - p.jest.beforeAll = try p.declareCommonJSSymbol(.unbound, "beforeAll"); - p.jest.afterAll = try p.declareCommonJSSymbol(.unbound, "afterAll"); + inline for (comptime std.meta.fieldNames(Jest)) |field_name| { + if (@field(p.jest, field_name).isNull()) { + @field(p.jest, field_name) = try p.declareCommonJSSymbol(.unbound, field_name); + } + } } if (p.options.features.hot_module_reloading) { @@ -9290,11 +9325,25 @@ fn NewParser_( try p.validateImportType(path.import_tag, &stmt); } + if (comptime !only_scan_imports_and_do_not_visit) { + if (p.options.features.inline_loc_for_tests and strings.eqlComptime(path.text, "bun:test")) { + p.configureBunTestImport(stmt.items, stmt.import_record_index); + } + } + // Track the items for this namespace try p.import_items_for_namespace.put(p.allocator, stmt.namespace_ref, item_refs); return p.s(stmt, loc); } + // This function mostly exists for @setCold(true). + fn configureBunTestImport(p: *P, clause_items: []const js_ast.ClauseItem, record_index: u32) void { + @setCold(true); + + p.import_records.items[record_index].tag = .bun_test; + p.jest.setFromClauseItems(clause_items); + } + fn validateImportType(p: *P, import_tag: ImportRecord.Tag, stmt: *S.Import) !void { @setCold(true); @@ -9618,7 +9667,7 @@ fn NewParser_( } } }, - .e_identifier => |ident| { + inline .e_location_identifier, .e_identifier => |ident| { return LocRef{ .loc = loc, .ref = ident.ref }; }, .e_import_identifier => |ident| { @@ -15808,13 +15857,7 @@ fn NewParser_( const key = brk: { switch (expr.data) { - .e_import_identifier => |ident| { - break :brk p.newExpr(E.String{ .data = p.loadNameFromRef(ident.ref) }, expr.loc); - }, - .e_commonjs_export_identifier => |ident| { - break :brk p.newExpr(E.String{ .data = p.loadNameFromRef(ident.ref) }, expr.loc); - }, - .e_identifier => |ident| { + inline .e_location_identifier, .e_identifier, .e_commonjs_export_identifier, .e_import_identifier => |ident| { break :brk p.newExpr(E.String{ .data = p.loadNameFromRef(ident.ref) }, expr.loc); }, .e_dot => |dot| { @@ -18779,9 +18822,9 @@ fn NewParser_( // just not module.exports = { bar: function() {} } // just not module.exports = { bar() {} } switch (prop.value.?.data) { - .e_commonjs_export_identifier, .e_import_identifier, .e_identifier => false, + .e_location_identifier, .e_commonjs_export_identifier, .e_import_identifier, .e_identifier => false, .e_call => |call| switch (call.target.data) { - .e_commonjs_export_identifier, .e_import_identifier, .e_identifier => false, + .e_location_identifier, .e_commonjs_export_identifier, .e_import_identifier, .e_identifier => false, else => |call_target| !@as(Expr.Tag, call_target).isPrimitiveLiteral(), }, else => !prop.value.?.isPrimitiveLiteral(), @@ -18968,6 +19011,24 @@ fn NewParser_( } } }, + .e_location_identifier => |id| { + // support: + // - test.each + // - test.only + // - describe.only + // - describe.each + if (identifier_opts.is_call_target and (id.ref.eql(p.jest.@"test") or id.ref.eql(p.jest.describe) or id.ref.eql(p.jest.it)) and ((strings.eqlComptime(name, "each") or strings.eqlComptime(name, "only")))) { + var out = Expr.init(E.Dot, E.Dot{ + .name = name, + .name_loc = name_loc, + .target = target, + }, loc); + out.data = .{ + .e_location_dot = out.data.e_dot, + }; + return out; + } + }, .e_object => |obj| { if (comptime FeatureFlags.inline_properties_in_transpiler) { if (p.options.features.minify_syntax) { diff --git a/src/js_printer.zig b/src/js_printer.zig index a318eb46f5..c486419390 100644 --- a/src/js_printer.zig +++ b/src/js_printer.zig @@ -2493,6 +2493,7 @@ fn NewPrinter( } // We only want to generate an unbound eval() in CommonJS p.call_target = e.target.data; + const is_loc_identifier = is_bun_platform and (e.target.data == .e_location_identifier or e.target.data == .e_location_dot); const is_unbound_eval = (!e.is_direct_eval and p.isUnboundEvalIdentifier(e.target) and @@ -2524,6 +2525,24 @@ fn NewPrinter( if (e.close_paren_loc.start > expr.loc.start) { p.addSourceMapping(e.close_paren_loc); } + + // Append [start, length] to the end of the call + // Used by bun:test. + if (comptime is_bun_platform) { + if (is_loc_identifier) { + if (args.len > 0) { + p.print(","); + p.printSpace(); + } + + p.print(("[")); + p.printNumber(@floatFromInt(e.target.loc.start), level); + p.print(","); + p.printNumber(@floatFromInt(e.close_paren_loc.start - e.target.loc.start), level); + p.print("]"); + } + } + p.print(")"); if (wrap) { p.print(")"); @@ -2656,7 +2675,7 @@ fn NewPrinter( ); } }, - .e_dot => |e| { + .e_location_dot, .e_dot => |e| { const isOptionalChain = e.optional_chain == .start; var wrap = false; @@ -3088,6 +3107,22 @@ fn NewPrinter( p.print(")"); } }, + .e_location_identifier => |e| { + // Pretend this is an import identifier + p.printExpr( + Expr{ + .data = .{ + .e_import_identifier = .{ + .ref = e.ref, + .was_originally_identifier = e.was_originally_identifier, + }, + }, + .loc = expr.loc, + }, + level, + flags, + ); + }, .e_import_identifier => |e| { // Potentially use a property access instead of an identifier var didPrint = false; diff --git a/src/options.zig b/src/options.zig index 61e4fc3539..8f48ee9081 100644 --- a/src/options.zig +++ b/src/options.zig @@ -1493,6 +1493,10 @@ pub const BundleOptions = struct { rewrite_jest_for_tests: bool = false, + /// Appends the byte range of the source location to the test + /// In the parser, this is called `inline_loc_for_tests` + has_byte_range_filter_for_tests: bool = false, + macro_remap: MacroRemap = MacroRemap{}, no_macros: bool = false, diff --git a/src/runtime.zig b/src/runtime.zig index e369e567b1..a01cd217c4 100644 --- a/src/runtime.zig +++ b/src/runtime.zig @@ -251,6 +251,8 @@ pub const Runtime = struct { replace_exports: ReplaceableExport.Map = .{}, + inline_loc_for_tests: bool = false, + dont_bundle_twice: bool = false, /// This is a list of packages which even when require() is used, we will diff --git a/test/js/bun/test/bun-byte-range-fixture.ts b/test/js/bun/test/bun-byte-range-fixture.ts new file mode 100644 index 0000000000..5c6ca3e882 --- /dev/null +++ b/test/js/bun/test/bun-byte-range-fixture.ts @@ -0,0 +1,53 @@ +import { expect, test, describe, beforeEach, beforeAll, afterAll, afterEach } from "bun:test"; + +beforeAll(() => { + console.log("beforeAll"); +}); + +afterAll(() => { + console.log("afterAll"); +}); + +test("", () => { + console.log("Test #1 ran"); +}); + +test("", () => { + console.log("Test #2 ran"); +}); + +describe("", () => { + beforeEach(() => { + console.log("beforeEach"); + }); + + afterEach(() => { + console.log("afterEach"); + }); + + test("", () => { + console.log("Test #3 ran"); + }); + /// --- Before Test#2InDescribe + + test("", () => { + console.log("Test #4 ran"); + }); + + // --- Before Test#3InDescribe + + test("", () => { + console.log("Test #5 ran"); + }); +}); + +// --- Before test.only +test.only("", () => { + console.log("Test #6 ran"); +}); + +// After test.only + +test("", () => { + console.log("Test #7 ran"); +}); diff --git a/test/js/bun/test/bun-byte-range.test.ts b/test/js/bun/test/bun-byte-range.test.ts new file mode 100644 index 0000000000..c572d274f5 --- /dev/null +++ b/test/js/bun/test/bun-byte-range.test.ts @@ -0,0 +1,214 @@ +import { expect, test, describe } from "bun:test"; +import "harness"; +import path from "path"; +import { readFileSync } from "fs"; +import { spawnSync } from "bun"; +import { bunExe, bunEnv } from "harness"; + +const fixture = readFileSync(path.join(import.meta.dir, "bun-byte-range-fixture.ts"), "utf8"); + +function runTest(startMarker: string, endMarker: string, expectedOutput: string[]) { + const startRange = fixture.indexOf(startMarker); + const endRange = fixture.indexOf(endMarker, startRange + startMarker.length); + const length = endRange - startRange; + const byteRange = `${startRange}:${length + endMarker.length}`; + const rangedPath = path.join(import.meta.dir, "bun-byte-range-fixture.ts") + "::" + byteRange; + + const { stdout, exitCode } = spawnSync({ + cmd: [bunExe(), "test", rangedPath], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + + const text = stdout.toString().trim().split("\n"); + expect(text).toEqual(expectedOutput); + expect(exitCode).toBe(0); +} + +function runTestMultipleMarkers(markers: Array<[string, string]>, expectedOutput: string[]) { + const ranges = markers.map(([startMarker, endMarker]) => { + const startRange = fixture.indexOf(startMarker); + const endRange = fixture.indexOf(endMarker, startRange + startMarker.length); + const length = endRange - startRange; + return `${startRange}:${length + endMarker.length}`; + }); + const rangedPath = path.join(import.meta.dir, "bun-byte-range-fixture.ts") + "::" + ranges.join("::"); + console.log({ rangedPath }); + const { stdout, exitCode } = spawnSync({ + cmd: [bunExe(), "test", rangedPath], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + + const text = stdout.toString().trim().split("\n"); + expect(text).toEqual(expectedOutput); + expect(exitCode).toBe(0); +} + +describe("single byte range filter", () => { + test("Test #1 and #2", () => { + runTest("", "", ["beforeAll", "Test #1 ran", "Test #2 ran", "afterAll"]); + }); + + test("Test #1", () => { + runTest("", "Test #1 ran", ["beforeAll", "Test #1 ran", "afterAll"]); + }); + + test("Test #2", () => { + runTest("", "", ["beforeAll", "Test #2 ran", "afterAll"]); + }); + + describe("Describe block tests", () => { + test("all tests in Describe block", () => { + runTest("", "// --- Before test.only", [ + "beforeAll", + "beforeEach", + "Test #3 ran", + "afterEach", + "beforeEach", + "Test #4 ran", + "afterEach", + "beforeEach", + "Test #5 ran", + "afterEach", + "afterAll", + ]); + }); + + test("Test #3 in Describe block", () => { + runTest("", "/// --- Before Test#2InDescribe", [ + "beforeAll", + "beforeEach", + "Test #3 ran", + "afterEach", + "afterAll", + ]); + }); + + test("Test #4 in Describe block", () => { + runTest("", "--- Before Test#3InDescribe", [ + "beforeAll", + "beforeEach", + "Test #4 ran", + "afterEach", + "afterAll", + ]); + }); + + test("Test #5 in Describe block", () => { + runTest("", "});", [ + "beforeAll", + "beforeEach", + "Test #5 ran", + "afterEach", + "afterAll", + ]); + }); + + test("multiple tests in Describe block", () => { + runTest("", "", [ + "beforeAll", + "beforeEach", + "Test #4 ran", + "afterEach", + "beforeEach", + "Test #5 ran", + "afterEach", + "afterAll", + ]); + }); + }); + + test("Test #6 (test.only)", () => { + runTest("", "#6 ran", ["beforeAll", "Test #6 ran", "afterAll"]); + }); + + test("Test #7 after (test.only)", () => { + runTest("// After test.only", "#7 ran", ["beforeAll", "Test #7 ran", "afterAll"]); + }); + + test("entire file", () => { + runTest("", "Test #7 ran", [ + "beforeAll", + "beforeEach", + "Test #3 ran", + "afterEach", + "beforeEach", + "Test #4 ran", + "afterEach", + "beforeEach", + "Test #5 ran", + "afterEach", + "Test #1 ran", + "Test #2 ran", + "Test #6 ran", + "Test #7 ran", + "afterAll", + ]); + }); + + test("entire file", () => { + runTest("", "Test #7 ran", [ + "beforeAll", + "beforeEach", + "Test #3 ran", + "afterEach", + "beforeEach", + "Test #4 ran", + "afterEach", + "beforeEach", + "Test #5 ran", + "afterEach", + "Test #1 ran", + "Test #2 ran", + "Test #6 ran", + "Test #7 ran", + "afterAll", + ]); + }); +}); + +describe("multiple byte range filter", () => { + test("Test #1 and #2", () => { + runTestMultipleMarkers( + [ + ["", ");"], + ["", ");"], + ], + ["beforeAll", "Test #1 ran", "Test #2 ran", "afterAll"], + ); + }); + + test("entire file", () => { + runTestMultipleMarkers( + [ + ["Test #1", ");"], + ["Test #2", ");"], + ["Test #3", ");"], + ["Test #4", ");"], + ["Test #5", ");"], + ["Test #6", ");"], + ["Test #7", ");"], + ], + [ + "beforeAll", + "beforeEach", + "Test #3 ran", + "afterEach", + "beforeEach", + "Test #4 ran", + "afterEach", + "beforeEach", + "Test #5 ran", + "afterEach", + "Test #1 ran", + "Test #2 ran", + "Test #6 ran", + "Test #7 ran", + "afterAll", + ], + ); + }); +});