From cc6332e6fbf34f5d6a6effc625501b92adcd3d8d Mon Sep 17 00:00:00 2001 From: Otger Rogla <2356820+otgerrogla@users.noreply.github.com> Date: Sun, 26 May 2024 01:47:56 +0200 Subject: [PATCH 1/2] feat(test): expect.getState() + improved test.each support --- packages/bun-types/test.d.ts | 51 ++- src/bun.js/bindings/bindings.zig | 4 + src/bun.js/node/util/format.zig | 406 +++++++++++++++++++ src/bun.js/node/util/validators.zig | 134 +++--- src/bun.js/test/expect.zig | 201 +++++++-- src/bun.js/test/jest.classes.ts | 190 +++------ src/bun.js/test/jest.zig | 234 +++++++---- src/cli.zig | 3 + src/string.zig | 89 +++- test/js/bun/test/expect.test.js | 29 ++ test/js/bun/test/jest-each-testnames.test.js | 44 ++ test/js/bun/test/jest-each.test.ts | 22 + 12 files changed, 1086 insertions(+), 321 deletions(-) create mode 100644 src/bun.js/node/util/format.zig create mode 100644 test/js/bun/test/jest-each-testnames.test.js diff --git a/packages/bun-types/test.d.ts b/packages/bun-types/test.d.ts index 6dfb6709b438..c5efeb6dae6c 100644 --- a/packages/bun-types/test.d.ts +++ b/packages/bun-types/test.d.ts @@ -453,6 +453,14 @@ declare module "bun:test" { each( table: T[], ): (label: string, fn: (...args: T[]) => void | Promise, options?: number | TestOptions) => void; + each( + strings: TemplateStringsArray, + ...values: any[] + ): ( + label: string, + fn: (obj: { [k: string]: any }) => void | Promise, + options?: number | TestOptions, + ) => void; } /** * Runs a test. @@ -582,6 +590,11 @@ declare module "bun:test" { * Ensures that a specific number of assertions are made */ assertions(neededAssertions: number): void; + + /** + * Get information about the current test + */ + getState(): Readonly; } /** @@ -921,7 +934,7 @@ declare module "bun:test" { * * @param expected the expected value */ - toBeOneOf(expected: Array | Iterable): void; + toBeOneOf(expected: unknown[] | Iterable): void; /** * Asserts that a value contains what is expected. * @@ -1560,21 +1573,36 @@ declare module "bun:test" { equals: EqualsFunction; } - interface MatcherState { - //assertionCalls: number; + export interface TestState { + /** The name of the current test */ + currentTestName: string; //currentConcurrentTestName?: () => string | undefined; - //currentTestName?: string; + /** The path of the current test file */ + testPath: string; + /** Number of `expect()` calls that passed so far */ + //numPassingAsserts: number; + /** Total number of `expect()` calls so far */ + assertionCalls: number; //error?: Error; - //expand: boolean; - //expectedAssertionsNumber: number | null; + /** Expected number of `expect()` calls, as set by calling `expect.assertions(n)` */ + expectedAssertionsNumber: number | null; //expectedAssertionsNumberError?: Error; - //isExpectingAssertions: boolean; + /** Whether the test expects at least one `expect()` calls, as set by calling `expect.hasAssertions()` */ + isExpectingAssertions: boolean; //isExpectingAssertionsError?: Error; + + /** Errors for the `expect.soft()` calls that failed so far */ + //suppressedErrors: Error[]; + //snapshotState: unknown; + /** Whether to report more verbose messages */ + expand: boolean; + } + + interface MatcherState extends TestState { + /** Whether the `expect()` call had the ".not" modifier */ isNot: boolean; - //numPassingAsserts: number; - promise: string; - //suppressedErrors: Array; - //testPath?: string; + /** Whether the `expect()` call had the either ".resolves" or the ".rejects" modifier */ + promise: "resolves" | "rejects" | ""; } type MatcherHintColor = (arg: string) => string; // subset of Chalk type @@ -1587,6 +1615,7 @@ declare module "bun:test" { stringify(value: unknown): string; printReceived(value: unknown): string; printExpected(value: unknown): string; + diff(a: unknown, b: unknown): string; matcherHint( matcherName: string, received?: unknown, diff --git a/src/bun.js/bindings/bindings.zig b/src/bun.js/bindings/bindings.zig index cc6e60d8f794..485030144b25 100644 --- a/src/bun.js/bindings/bindings.zig +++ b/src/bun.js/bindings/bindings.zig @@ -5970,6 +5970,10 @@ pub const CallFrame = opaque { pub fn argumentsCount(self: *const CallFrame) usize { return @as(usize, @intCast((@as([*]align(alignment) const JSC.JSValue, @ptrCast(@alignCast(self))) + Sizes.Bun_CallFrame__argumentCountIncludingThis)[0].asInt32() - 1)); } + + pub fn argumentsSlice(self: *const CallFrame) []const JSValue { + return self.argumentsPtr()[0..self.argumentsCount()]; + } }; // pub const WellKnownSymbols = extern struct { diff --git a/src/bun.js/node/util/format.zig b/src/bun.js/node/util/format.zig new file mode 100644 index 000000000000..4f7ea45ae2ec --- /dev/null +++ b/src/bun.js/node/util/format.zig @@ -0,0 +1,406 @@ +const std = @import("std"); +//const bun = @import("root").bun; +const bun = @import("../../../bun.zig"); +const string = bun.string; +const strings = bun.strings; +const String = bun.String; +//const JSC = bun.JSC; +const JSC = @import("../../../bindgen.zig").JavaScriptCore; +const JSValue = JSC.JSValue; +const JSGlobalObject = JSC.JSGlobalObject; +const ConsoleObject = @import("../../ConsoleObject.zig"); +const ZigString = JSC.ZigString; +const MutableString = @import("../../../string_mutable.zig").MutableString; + +const validators = @import("./validators.zig"); +const validateObject = validators.validateObject; + +/// Object containing options that can be passed to util.inspect +/// (note that not all are supported) +pub const InspectOptions = struct { + /// If true, object's non-enumerable symbols and properties are included in the formatted result. + /// WeakMap and WeakSet entries are also included as well as user defined prototype properties (excluding method properties). + showHidden: bool = false, + /// Specifies the number of times to recurse while formatting object. + /// This is useful for inspecting large objects. + /// To recurse up to the maximum call stack size pass Infinity or null. + depth: u16 = 2, + /// If true, the output is styled with ANSI color codes. Colors are customizable. See Customizing util.inspect colors. + colors: bool = false, + /// If false, [util.inspect.custom](depth, opts, inspect) functions are not invoked. + customInspect: bool = true, + /// If true, Proxy inspection includes the target and handler objects. + showProxy: bool = false, + /// Specifies the maximum number of Array, TypedArray, Map, Set, WeakMap, and WeakSet elements to include when formatting. + /// Set to null or Infinity to show all elements. + /// Set to 0 or negative to show no elements. + maxArrayLength: u32 = 100, + /// Specifies the maximum number of characters to include when formatting. Set to null or Infinity to show all elements. + /// Set to 0 or negative to show no characters. + maxStringLength: u32 = 10000, + /// The length at which input values are split across multiple lines. + /// Set to Infinity to format the input as a single line (in combination with compact set to true or any number >= 1). + breakLength: u32 = 80, + /// Setting this to false causes each object key to be displayed on a new line. + /// It will break on new lines in text that is longer than breakLength. + /// If set to a number, the most n inner elements are united on a single line as long as all properties fit into breakLength. + /// Short array elements are also grouped together. + /// For more information, see the example below. + compact: ?u32 = 3, + /// If set to true or a function, all properties of an object, and Set and Map entries are sorted in the resulting string. + /// If set to true the default sort is used. + /// If set to a function, it is used as a compare function. + sorted: JSValue = .undefined, + /// If set to true, getters are inspected. + /// If set to 'get', only getters without a corresponding setter are inspected. + /// If set to 'set', only getters with a corresponding setter are inspected. + /// This might cause side effects depending on the getter function. + getters: enum { false, true, get, set } = .false, + /// If set to true, an underscore is used to separate every three digits in all bigints and numbers. + numericSeparator: bool = false, +}; + +pub fn parseInspectOptions(obj: JSValue, globalObject: *JSGlobalObject) !InspectOptions { + return try validators.parseStruct(obj, globalObject, InspectOptions, "inspectOptions", .{ + .throw = true, + .allowUndefinedFields = true, + }); +} + +/// Maps options of InspectOptions into an ConsoleObject.Formatter instance +pub fn applyInspectOptionsToFormatter(options: *const InspectOptions, formatter: *ConsoleObject.Formatter) void { + formatter.max_depth = options.depth + 1; // bun max_depth=1 corresponds to util.inspect depth=0 + formatter.hide_native = !options.showHidden; + //formatter.color = options.color; + + // TODO: support other options in the formatter +} + +pub const TestLabelFormatExtras = struct { + test_idx: usize, + table_headers: ?JSValue, + root_object: ?JSValue, +}; + +/// Signatures: +/// util.inspect(object[, options]) +/// util.inspect(object[, showHidden[, depth[, colors]]]) +pub fn inspect( + globalObject: *JSGlobalObject, + callFrame: *JSC.CallFrame, +) callconv(.C) JSValue { + JSC.markBinding(@src()); + const arguments = callFrame.argumentsSlice(); + + const target_value = if (arguments.len > 0) arguments[0] else JSValue.undefined; + + var inspect_options: InspectOptions = InspectOptions{}; + if (arguments.len > 1) { + if (arguments[1].isBoolean()) { // Signature #2: inspect(object[, showHidden[, depth[, colors]]]) + inspect_options.showHidden = validators.validateBoolean(globalObject, arguments[1], "showHidden", .{}) catch return .zero; + if (arguments.len > 2) { + inspect_options.depth = validators.validateInteger(u16, globalObject, arguments[2], "depth", .{}, null, null) catch return .zero; + if (arguments.len > 3) { + inspect_options.colors = validators.validateBoolean(globalObject, arguments[3], "colors", .{}) catch return .zero; + } + } + } else { // Signature #1: inspect(object[, options]) + inspect_options = parseInspectOptions(arguments[1], globalObject) catch return .zero; + } + } + + var formatter = ConsoleObject.Formatter{ + .remaining_values = &[_]JSValue{}, + .globalThis = globalObject, + //.ordered_properties = false, + //.quote_strings = true, + }; + applyInspectOptionsToFormatter(&inspect_options, &formatter); + + return JSValue.printString(globalObject, 512, "{}", .{ + target_value.toFmt(globalObject, &formatter), + }) catch { + globalObject.throwOutOfMemory(); + return .zero; + }; +} + +pub fn format( + globalObject: *JSGlobalObject, + callFrame: *JSC.CallFrame, +) callconv(.C) JSValue { + JSC.markBinding(@src()); + const arguments = callFrame.argumentsSlice(); + + return formatWithOptionsInternal(globalObject, .undefined, arguments) catch { + return .zero; + }; +} + +pub fn formatWithOptions( + globalObject: *JSGlobalObject, + callFrame: *JSC.CallFrame, +) callconv(.C) JSValue { + JSC.markBinding(@src()); + const arguments = callFrame.argumentsSlice(); + + const inspect_options = if (arguments.len > 0) arguments[0] else JSValue.zero; + + return formatWithOptionsInternal(globalObject, inspect_options, arguments[1..]) catch { + return .zero; + }; +} + +fn formatWithOptionsInternal( + globalObject: *JSGlobalObject, + inspect_options_value: JSValue, + format_args: []const JSValue, +) !JSValue { + var inspect_options = InspectOptions{}; + if (inspect_options_value != .zero) { + inspect_options = try parseInspectOptions(inspect_options_value, globalObject); + } + + var format_bunstr = bun.String.static(""); + var args = format_args; + const first_arg = if (args.len > 0) args[0] else JSValue.undefined; + if (first_arg.isString()) { + format_bunstr = first_arg.toBunString(globalObject); + args = args[1..]; + } + + var format_str = format_bunstr.toUTF8(globalObject.allocator()); + defer format_str.deinit(); + + const WRITER_STACK_CAPACITY = 1024; + var heap_fallback = std.heap.stackFallback(WRITER_STACK_CAPACITY, globalObject.allocator()); + + var buf = try MutableString.init(heap_fallback.get(), @max(WRITER_STACK_CAPACITY, format_str.len)); + defer buf.deinit(); + + try formatImpl(buf.writer(), globalObject, &inspect_options, format_str.slice(), args, void); + + return String.init(buf.toOwnedSliceLeaky()).toJS(globalObject); +} + +const default_format_inspect_options = InspectOptions{ + .depth = 5, + .colors = false, + .compact = 3, +}; + +pub fn formatImpl( + writer: anytype, + globalObject: *JSGlobalObject, + inspect_options: ?*const InspectOptions, + pattern: []const u8, + format_args: []const JSValue, + extras: anytype, +) !void { + var args_idx: usize = 0; // the current arg index of "format_args" + var idx: usize = 0; + var commited_idx: usize = 0; + + const allow_dollar_refs = comptime (@TypeOf(extras) == TestLabelFormatExtras); + const print_remaining_args = comptime (@TypeOf(extras) != TestLabelFormatExtras); + + const root_object: ?JSValue = if (comptime @TypeOf(extras) == TestLabelFormatExtras) extras.root_object else null; + + while (idx < pattern.len) { + const char = pattern[idx]; + if ((comptime allow_dollar_refs) and char == '$') { + if (commited_idx < idx) { // flush pending unformatted chars + try writer.writeAll(pattern[commited_idx..idx]); + } + + idx += 1; // consume the '$' + const path_start = idx; + var key_start = idx; + var value: ?JSValue = root_object; + while (idx <= pattern.len) : (idx += 1) { + const c = if (idx < pattern.len) pattern[idx] else ' '; + if (!std.ascii.isAlphanumeric(c) and c != '_') { + // current key ends here + const end = idx; + if (key_start == end) { // path is invalid because includes a 0-length key, so just append the path as raw text + if (path_start - 1 < end) { + try writer.writeAll(pattern[(path_start - 1)..end]); + } + break; + } + const key = pattern[key_start..end]; + if (value) |value_| { // resolve nested property + value = value_.get(globalObject, key); + } else { // resolve root object + value = JSValue.undefined; // default fallback + if (extras.table_headers) |headers_| { + const headers_len = headers_.getLength(globalObject); + for (0..headers_len) |col_idx| { + const header = headers_.getIndex(globalObject, @truncate(col_idx)); + if (header.isString() and header.toBunString(globalObject).eqlUTF8(key)) { + if (col_idx < format_args.len) { + value = format_args[col_idx]; + } + break; + } + } + } + } + + if (c != '.') { + // we finished parsing this path, so print the resolved value + var formatter = ConsoleObject.Formatter{ + .remaining_values = &[_]JSValue{}, + .globalThis = globalObject, + .ordered_properties = false, + .quote_strings = true, + }; + applyInspectOptionsToFormatter(inspect_options orelse &default_format_inspect_options, &formatter); + try writer.print("{}", .{(value orelse JSValue.undefined).toFmt(globalObject, &formatter)}); + break; + } + key_start = idx + 1; + } + } + commited_idx = idx; + } else if (char == '%' and args_idx < format_args.len) { + if (commited_idx < idx) { // flush pending unformatted chars + try writer.writeAll(pattern[commited_idx..idx]); + } + + const current_arg = format_args[args_idx]; + + idx = idx + 1; // consume the '%' + if (idx >= pattern.len) { // no followup char, reached end of pattern + continue; // process this sole '%' as raw text + } + + const percent_char = pattern[idx]; + idx = idx + 1; // consume the followup char + + var is_unknown_sequence = false; + + switch (percent_char) { + 's' => { + // %s: String will be used to convert all values except BigInt, Object and -0. + // BigInt values will be represented with an n and Objects that have no user defined toString function are inspected + // using util.inspect() with options { depth: 0, colors: false, compact: 3 }. + if (current_arg.isNumber() and current_arg.asNumber() == -0.0) { + try writer.writeAll("-0"); + } else if (current_arg.isObject() and !current_arg.implementsToString(globalObject)) { + var formatter = ConsoleObject.Formatter{ + .remaining_values = &[_]JSValue{}, + .globalThis = globalObject, + .ordered_properties = false, + .quote_strings = true, + }; + applyInspectOptionsToFormatter(inspect_options orelse &default_format_inspect_options, &formatter); + + try writer.print("{}", .{current_arg.toFmt(globalObject, &formatter)}); + } else { + var str = current_arg.toBunString(globalObject); + defer str.deref(); + + try writer.print("{}", .{str}); + if (current_arg.isBigInt()) { + try writer.writeByte('n'); + } + } + + args_idx += 1; // consume arg + }, + 'd' => { + // %d: Number will be used to convert all values except BigInt and Symbol. + const num = current_arg.coerceToInt64(globalObject); + try writer.print("{d}", .{num}); + args_idx += 1; // consume arg + }, + 'i' => { + // %i: parseInt(value, 10) is used for all values except BigInt and Symbol. + const num = current_arg.coerceToInt64(globalObject); + try writer.print("{d}", .{num}); + args_idx += 1; // consume arg + }, + 'f' => { + // %f: parseFloat(value) is used for all values expect Symbol. + const num = current_arg.coerceToDouble(globalObject); + try writer.print("{d}", .{num}); + args_idx += 1; // consume arg + }, + 'j', 'o', 'O' => { + // %j: JSON. Replaced with the string '[Circular]' if the argument contains circular references. + // + // %o: `Object`. A string representation of an object with generic JavaScript object formatting. + // Similar to util.inspect() with options { showHidden: true, showProxy: true }. + // This will show the full object including non-enumerable properties and proxies. + // + // %O: `Object`. A string representation of an object with generic JavaScript object formatting. + // Similar to util.inspect() without options. + // This will show the full object not including non-enumerable properties and proxies. + + var str = bun.String.empty; + defer str.deref(); + current_arg.jsonStringify(globalObject, 0, &str); + + try writer.print("{}", .{str}); + args_idx += 1; + }, + 'c' => { + // %c: CSS. This specifier is ignored and will skip any CSS passed in. + args_idx += 1; // consume arg + }, + 'p' => { + // %p: (bun only) pretty print + var formatter = ConsoleObject.Formatter{ + .globalThis = globalObject, + .quote_strings = true, + }; + applyInspectOptionsToFormatter(inspect_options orelse &default_format_inspect_options, &formatter); + try writer.print("{any}", .{current_arg.toFmt(globalObject, &formatter)}); + args_idx += 1; // consume arg + }, + '%' => { + // %%: single percent sign ('%'). This does not consume an argument. + try writer.writeByte('%'); + }, + else => { + if ((comptime @TypeOf(extras) == TestLabelFormatExtras) and (percent_char == '#')) { + // #: (only on test labels): the test index in a .each() sequence + try writer.print("{d}", .{extras.test_idx}); + } else { + is_unknown_sequence = true; + } + }, + } + + if (is_unknown_sequence) { + // make the '%' and its followup char be printed as raw text + commited_idx = idx - 2; + } else { + commited_idx = idx; + } + } else { + idx += 1; + } + } + + // flush pending unformatted chars + if (commited_idx < idx) { + try writer.writeAll(pattern[commited_idx..idx]); + } + + // print the remaining unused arguments + if (comptime print_remaining_args) { + while (args_idx < format_args.len) : (args_idx += 1) { + var formatter = ConsoleObject.Formatter{ + .remaining_values = &[_]JSValue{}, + .globalThis = globalObject, + .ordered_properties = false, + .quote_strings = true, + }; + applyInspectOptionsToFormatter(inspect_options orelse &default_format_inspect_options, &formatter); + + try writer.print(" {}", .{format_args[args_idx].toFmt(globalObject, &formatter)}); + } + } +} diff --git a/src/bun.js/node/util/validators.zig b/src/bun.js/node/util/validators.zig index 6ea243b67c0c..165768c5b3d5 100644 --- a/src/bun.js/node/util/validators.zig +++ b/src/bun.js/node/util/validators.zig @@ -60,66 +60,30 @@ pub fn throwRangeError( } /// -(2^53 - 1) -pub const NUMBER__MIN_SAFE_INTEGER: i64 = -9007199254740991; +pub const NUMBER__MIN_SAFE_INTEGER: comptime_int = -9007199254740991; /// (2^53 – 1) -pub const NUMBER__MAX_SAFE_INTEGER: i64 = 9007199254740991; +pub const NUMBER__MAX_SAFE_INTEGER: comptime_int = 9007199254740991; -pub fn validateInteger(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype, min_value: ?i64, max_value: ?i64) !i64 { - const min = min_value orelse NUMBER__MIN_SAFE_INTEGER; - const max = max_value orelse NUMBER__MAX_SAFE_INTEGER; +pub fn validateInteger(comptime T: type, globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype, min_value: ?T, max_value: ?T) !T { + const min = min_value orelse @as(T, @intCast(@max(std.math.minInt(T), NUMBER__MIN_SAFE_INTEGER))); + const max = max_value orelse @as(T, @intCast(@min(std.math.maxInt(T), NUMBER__MAX_SAFE_INTEGER))); if (!value.isNumber()) try throwErrInvalidArgType(globalThis, name_fmt, name_args, "number", value); if (!value.isAnyInt()) { - try throwRangeError(globalThis, "The value of \"" ++ name_fmt ++ "\" is out of range. It must be an integer. Received {s}", name_args ++ .{value}); + var formatter = JSC.ConsoleObject.Formatter{ .globalThis = globalThis }; + try throwRangeError(globalThis, "The value of \"" ++ name_fmt ++ "\" is out of range. It must be an integer. Received {}", name_args ++ .{value.toFmt(globalThis, &formatter)}); } const num = value.asInt52(); if (num < min or num > max) { - try throwRangeError(globalThis, "The value of \"" ++ name_fmt ++ "\" is out of range. It must be >= {d} and <= {d}. Received {s}", name_args ++ .{ min, max, value }); + var formatter = JSC.ConsoleObject.Formatter{ .globalThis = globalThis }; + try throwRangeError(globalThis, "The value of \"" ++ name_fmt ++ "\" is out of range. It must be >= {d} and <= {d}. Received {}", name_args ++ .{ min, max, value.toFmt(globalThis, &formatter) }); } - return num; -} - -pub fn validateInt32(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype, min_value: ?i32, max_value: ?i32) !i32 { - const min = min_value orelse std.math.minInt(i32); - const max = max_value orelse std.math.maxInt(i32); - // The defaults for min and max correspond to the limits of 32-bit integers. - if (!value.isNumber()) { - try throwErrInvalidArgType(globalThis, name_fmt, name_args, "number", value); - } - if (!value.isInt32()) { - try throwRangeError(globalThis, "The value of \"" ++ name_fmt ++ "\" is out of range. It must be an integer. Received {s}", name_args ++ .{value}); - } - const num = value.asInt32(); - if (num < min or num > max) { - try throwRangeError(globalThis, "The value of \"" ++ name_fmt ++ "\" is out of range. It must be >= {d} and <= {d}. Received {s}", name_args ++ .{ min, max, value }); - } - return num; -} - -pub fn validateUint32(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype, greater_than_zero: bool) !u32 { - if (!value.isNumber()) { - try throwErrInvalidArgType(globalThis, name_fmt, name_args, "number", value); - } - if (!value.isAnyInt()) { - try throwRangeError(globalThis, "The value of \"" ++ name_fmt ++ "\" is out of range. It must be an integer. Received {s}", name_args ++ .{value}); - } - const num: i64 = value.asInt52(); - const min: i64 = if (greater_than_zero) 1 else 0; - const max: i64 = @intCast(std.math.maxInt(u32)); - if (num < min or num > max) { - try throwRangeError(globalThis, "The value of \"" ++ name_fmt ++ "\" is out of range. It must be >= {d} and <= {d}. Received {s}", name_args ++ .{ min, max, value }); - } - return @truncate(num); -} - -pub fn validateString(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype) !void { - if (!value.isString()) - try throwErrInvalidArgType(globalThis, name_fmt, name_args, "string", value); + return @intCast(num); } -pub fn validateNumber(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype, min: ?f64, max: ?f64) !f64 { +pub fn validateFloat(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype, min: ?f64, max: ?f64) !f64 { if (!value.isNumber()) try throwErrInvalidArgType(globalThis, name_fmt, name_args, "number", value); @@ -146,6 +110,11 @@ pub fn validateNumber(globalThis: *JSGlobalObject, value: JSValue, comptime name return num; } +pub fn validateString(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype) !void { + if (!value.isString()) + try throwErrInvalidArgType(globalThis, name_fmt, name_args, "string", value); +} + pub fn validateBoolean(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype) !bool { if (!value.isBoolean()) try throwErrInvalidArgType(globalThis, name_fmt, name_args, "boolean", value); @@ -248,3 +217,74 @@ pub fn validateStringEnum(comptime T: type, globalThis: *JSGlobalObject, value: try throwErrInvalidArgTypeWithMessage(globalThis, name_fmt ++ " must be one of: {s}", name_args ++ .{values_info}); return error.InvalidArgument; } + +pub const ParseOptions = struct { + throw: bool, + /// If a struct field is undefined, use the field default value instead of throwing error + allowUndefinedFields: bool, +}; + +pub fn parseStruct(self: JSValue, globalObject: *JSC.JSGlobalObject, comptime T: type, name: string, comptime options: ParseOptions) !T { + try validateObject(globalObject, self, "{s}", .{name}, .{ + .allow_array = true, + }); + + const fields = std.meta.fields(T); + var item: T = if (comptime options.allowUndefinedFields) T{} else undefined; + inline for (fields) |field| { + const jsvalue = self.get(globalObject, field.name) orelse JSValue.undefined; + if (!jsvalue.isUndefined() or !options.allowUndefinedFields) { + @field(item, field.name) = try parseValue(jsvalue, globalObject, field.type, field.name, options); + } + } + return item; +} + +pub fn parseValue(self: JSValue, globalObject: *JSC.JSGlobalObject, comptime T: type, name: string, comptime options: ParseOptions) !T { + return switch (@typeInfo(T)) { + .Null => try self.parse(globalObject, T, options), + .Optional => |optionalType| { + if (self.isUndefined() or self.isNull()) { + return null; + } else { + return try parseValue(self, globalObject, optionalType.child, name, options); + } + }, + .Bool => { + return try validateBoolean(globalObject, self, "{s}", .{name}); + }, + .Int => |int_type| { + _ = int_type; + return try validateInteger(T, globalObject, self, "{s}", .{name}, null, null); + }, + .Float => |float_type| { + _ = float_type; + return @floatCast(validateFloat(globalObject, self, "{s}", .{name})); + }, + .Struct => parseStruct(self, globalObject, T, name, options), + .Enum => |enum_type| { + _ = enum_type; + return try validateStringEnum(T, globalObject, self, "{s}", .{name}); + }, + + //.Array => |arrayType|, + //.Type, + //.Void, + //.NoReturn, + //.Pointer => |info|, + //.ComptimeFloat, + //.ComptimeInt, + //.Undefined, + //.ErrorUnion |info|, + //.ErrorSet |info|, + //.Enum |info|, + //.Union |info|, + //.Fn |info|, + //.Opaque |info|, + //.Frame |info|, + //.AnyFrame |info|, + //.Vector |info|, + //.EnumLiteral, + else => @compileError("Unsupported type"), + }; +} diff --git a/src/bun.js/test/expect.zig b/src/bun.js/test/expect.zig index db715aed39bf..19e52d0401b5 100644 --- a/src/bun.js/test/expect.zig +++ b/src/bun.js/test/expect.zig @@ -56,6 +56,28 @@ pub fn getMatcherFlags(comptime T: type, value: JSValue) Expect.Flags { return .{}; } +pub inline fn getCurrentTest(parent_scope: Expect.ParentScope) ?*jest.TestRunnerTask { + _ = parent_scope; // to be used when supporting concurrent tests + + // try to get from value set during the synchronous execution of the "test" function + if (Jest.runner) |runner| { + if (runner.pending_test) |curr_test| { + return curr_test; + } + } + return null; +} + +pub inline fn getCurrentTestOrThrow(globalObject: *JSGlobalObject, parent_scope: Expect.ParentScope, caller_name: []const u8) ?*jest.TestRunnerTask { + if (getCurrentTest(parent_scope)) |curr_test| { + return curr_test; + } + const err = globalObject.createErrorInstance("{s} must be called in a test", .{caller_name}); + err.put(globalObject, ZigString.static("name"), ZigString.init("TestNotRunningError").toValueGC(globalObject)); + globalObject.throwValue(err); + return null; +} + /// https://jestjs.io/docs/expect // To support async tests, we need to track the test ID pub const Expect = struct { @@ -979,7 +1001,7 @@ pub const Expect = struct { return .zero; } - active_test_expectation_counter.actual += 1; + incrementExpectCallCounter(); const expected = arguments[0]; expected.ensureStillAlive(); @@ -3120,7 +3142,7 @@ pub const Expect = struct { const thisValue = callFrame.this(); const value: JSValue = this.getValue(globalThis, thisValue, "toBeValidDate", "") orelse return .zero; - active_test_expectation_counter.actual += 1; + incrementExpectCallCounter(); const not = this.flags.not; var pass = (value.isDate() and !std.math.isNan(value.getUnixTimestamp())); @@ -4137,9 +4159,9 @@ pub const Expect = struct { return .zero; } - var expect_proto = Expect__getPrototype(globalObject); - var expect_constructor = Expect.getConstructor(globalObject); - var expect_static_proto = ExpectStatic__getPrototype(globalObject); + const expect_proto = Expect__getPrototype(globalObject); + const expect_constructor = Expect.getConstructor(globalObject); + const expect_static_proto = ExpectStatic__getPrototype(globalObject); const matchers_to_register = args[0]; { @@ -4186,7 +4208,7 @@ pub const Expect = struct { var source_slice = source_str.toSlice(this.globalObject.allocator()); defer source_slice.deinit(); - var source: string = source_slice.slice(); + const source: string = source_slice.slice(); if (std.mem.indexOfScalar(u8, source, '(')) |lparen| { if (std.mem.indexOfScalarPos(u8, source, lparen, ')')) |rparen| { const params_str = source[(lparen + 1)..rparen]; @@ -4335,7 +4357,7 @@ pub const Expect = struct { if (comptime Environment.allow_assert) assert(message.isCallable(globalObject.vm())); // checked above - var message_result = message.callWithGlobalThis(globalObject, &[_]JSValue{}); + const message_result = message.callWithGlobalThis(globalObject, &[_]JSValue{}); assert(!message_result.isEmpty()); if (message_result.toError()) |err| { globalObject.throwValue(err); @@ -4373,7 +4395,7 @@ pub const Expect = struct { // retrieve the user-provided matcher function (matcher_fn) const func: JSValue = callFrame.callee(); - var matcher_fn = getCustomMatcherFn(func, globalObject) orelse JSValue.undefined; + const matcher_fn = getCustomMatcherFn(func, globalObject) orelse JSValue.undefined; if (!matcher_fn.jsType().isFunction()) { globalObject.throw("Internal consistency error: failed to retrieve the matcher function for a custom matcher!", .{}); return .zero; @@ -4429,6 +4451,7 @@ pub const Expect = struct { } pub const addSnapshotSerializer = notImplementedStaticFn; + pub const addEqualityTesters = notImplementedStaticFn; pub fn hasAssertions(globalObject: *JSGlobalObject, callFrame: *JSC.CallFrame) callconv(.C) JSValue { _ = callFrame; @@ -4473,6 +4496,24 @@ pub const Expect = struct { return .undefined; } + pub fn getState(globalObject: *JSGlobalObject, _: *JSC.CallFrame) callconv(.C) JSValue { + const curr_test = getCurrentTestOrThrow(globalObject, .{ .global = {} }, "expect.getState()") orelse return .zero; + + const state = globalObject.bunVM().allocator.create(ExpectMatcherContext) catch { + globalObject.throwOutOfMemory(); + return .zero; + }; + state.* = .{ + .flags = null, + .parent = .{ .TestScope = .{ + .test_id = curr_test.test_id, + .describe = curr_test.describe, + } }, + }; + + return state.toJS(globalObject); + } + pub fn notImplementedJSCFn(_: *Expect, globalObject: *JSC.JSGlobalObject, _: *JSC.CallFrame) callconv(.C) JSC.JSValue { globalObject.throw("Not implemented", .{}); return .zero; @@ -4580,7 +4621,7 @@ pub const ExpectStatic = struct { //const this: *ExpectStatic = ExpectStatic.fromJS(callFrame.this()); const instance_jsvalue = T.call(globalObject, callFrame); if (!instance_jsvalue.isEmpty() and !instance_jsvalue.isAnyError()) { - var instance = T.fromJS(instance_jsvalue) orelse { + const instance = T.fromJS(instance_jsvalue) orelse { globalObject.throwOutOfMemory(); return .zero; }; @@ -4639,7 +4680,7 @@ pub const ExpectAnything = struct { const anything_js_value = anything.toJS(globalObject); anything_js_value.ensureStillAlive(); - var vm = globalObject.bunVM(); + const vm = globalObject.bunVM(); vm.autoGarbageCollect(); return anything_js_value; @@ -4677,7 +4718,7 @@ pub const ExpectStringMatching = struct { const string_matching_js_value = string_matching.toJS(globalObject); ExpectStringMatching.testValueSetCached(string_matching_js_value, globalObject, test_value); - var vm = globalObject.bunVM(); + const vm = globalObject.bunVM(); vm.autoGarbageCollect(); return string_matching_js_value; } @@ -4724,7 +4765,7 @@ pub const ExpectCloseTo = struct { ExpectCloseTo.numberValueSetCached(instance_jsvalue, globalObject, number_value); ExpectCloseTo.digitsValueSetCached(instance_jsvalue, globalObject, precision_value); - var vm = globalObject.bunVM(); + const vm = globalObject.bunVM(); vm.autoGarbageCollect(); return instance_jsvalue; } @@ -4761,7 +4802,7 @@ pub const ExpectObjectContaining = struct { const instance_jsvalue = instance.toJS(globalObject); ExpectObjectContaining.objectValueSetCached(instance_jsvalue, globalObject, object_value); - var vm = globalObject.bunVM(); + const vm = globalObject.bunVM(); vm.autoGarbageCollect(); return instance_jsvalue; } @@ -4798,7 +4839,7 @@ pub const ExpectStringContaining = struct { const string_containing_js_value = string_containing.toJS(globalObject); ExpectStringContaining.stringValueSetCached(string_containing_js_value, globalObject, string_value); - var vm = globalObject.bunVM(); + const vm = globalObject.bunVM(); vm.autoGarbageCollect(); return string_containing_js_value; } @@ -4843,7 +4884,7 @@ pub const ExpectAny = struct { ExpectAny.constructorValueSetCached(any_js_value, globalObject, constructor); any_js_value.ensureStillAlive(); - var vm = globalObject.bunVM(); + const vm = globalObject.bunVM(); vm.autoGarbageCollect(); return any_js_value; @@ -4881,7 +4922,7 @@ pub const ExpectArrayContaining = struct { const array_containing_js_value = array_containing.toJS(globalObject); ExpectArrayContaining.arrayValueSetCached(array_containing_js_value, globalObject, array_value); - var vm = globalObject.bunVM(); + const vm = globalObject.bunVM(); vm.autoGarbageCollect(); return array_containing_js_value; } @@ -4935,7 +4976,7 @@ pub const ExpectCustomAsymmetricMatcher = struct { // capture the args as a JS array saved in the instance, so the matcher can be executed later on with them const args_ptr = callFrame.argumentsPtr(); const args_count: usize = callFrame.argumentsCount(); - var args = JSValue.createEmptyArray(globalObject, args_count); + const args = JSValue.createEmptyArray(globalObject, args_count); for (0..args_count) |i| { args.putIndex(globalObject, @truncate(i), args_ptr[i]); } @@ -5006,7 +5047,7 @@ pub const ExpectCustomAsymmetricMatcher = struct { args.appendAssumeCapacity(arg); } - var result = matcher_fn.callWithThis(globalObject, thisValue, args.items); + const result = matcher_fn.callWithThis(globalObject, thisValue, args.items); if (result.toError()) |err| { if (dontThrow) { return false; @@ -5044,7 +5085,9 @@ pub const ExpectCustomAsymmetricMatcher = struct { pub const ExpectMatcherContext = struct { pub usingnamespace JSC.Codegen.JSExpectMatcherContext; - flags: Expect.Flags = .{}, + flags: ?Expect.Flags = null, + + parent: Expect.ParentScope = .{ .global = {} }, pub fn finalize( this: *ExpectMatcherContext, @@ -5052,30 +5095,97 @@ pub const ExpectMatcherContext = struct { VirtualMachine.get().allocator.destroy(this); } - pub fn getUtils(_: *ExpectMatcherContext, globalObject: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue { - return ExpectMatcherUtils__getSingleton(globalObject); + // + // Test state + // + + pub fn getCurrentTestName(this: *ExpectMatcherContext, globalObject: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue { + if (getCurrentTest(this.parent)) |curr_test| { + const test_scope = curr_test.describe.tests.items[curr_test.test_id]; + return bun.String.static(test_scope.label).toJS(globalObject); + } + return JSValue.undefined; } + pub fn getTestPath(this: *ExpectMatcherContext, globalObject: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue { + if (getCurrentTest(this.parent)) |curr_test| { + return bun.String.static(curr_test.source_file_path).toJS(globalObject); + } + return JSValue.undefined; + } + + pub fn getAssertionCalls(this: *ExpectMatcherContext, _: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue { + if (getCurrentTest(this.parent)) |curr_test| { + _ = curr_test; + return JSValue.jsNumber(active_test_expectation_counter.actual); + } + return JSValue.undefined; + } + + pub fn getExpectedAssertionsNumber(this: *ExpectMatcherContext, _: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue { + if (getCurrentTest(this.parent)) |curr_test| { + _ = curr_test; + return if (is_expecting_assertions_count) JSValue.jsNumber(@as(i32, @intCast(expected_assertions_number))) else JSValue.null; + } + return JSValue.undefined; + } + + pub fn getIsExpectingAssertions(this: *ExpectMatcherContext, _: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue { + if (getCurrentTest(this.parent)) |curr_test| { + _ = curr_test; + return JSValue.jsBoolean(is_expecting_assertions); + } + return JSValue.undefined; + } + + pub fn getSnapshotState(this: *ExpectMatcherContext, globalObject: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue { + if (getCurrentTest(this.parent)) |curr_test| { + _ = curr_test; + globalObject.throw("Not implemented", .{}); + return .zero; + } + return JSValue.undefined; + } + + pub fn getExpand(_: *ExpectMatcherContext, _: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue { + if (Jest.runner) |runner| { + return JSValue.jsBoolean(runner.test_options.expand); + } + return JSValue.undefined; + } + + // + // Matcher state + // + pub fn getIsNot(this: *ExpectMatcherContext, _: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue { - return JSValue.jsBoolean(this.flags.not); + if (this.flags) |flags| { + return JSValue.jsBoolean(flags.not); + } + return JSValue.undefined; } pub fn getPromise(this: *ExpectMatcherContext, globalObject: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue { - return switch (this.flags.promise) { - .rejects => bun.String.static("rejects").toJS(globalObject), - .resolves => bun.String.static("resolves").toJS(globalObject), - else => bun.String.empty.toJS(globalObject), - }; + if (this.flags) |flags| { + return switch (flags.promise) { + .rejects => bun.String.static("rejects").toJS(globalObject), + .resolves => bun.String.static("resolves").toJS(globalObject), + else => bun.String.empty.toJS(globalObject), + }; + } + return JSValue.undefined; } - pub fn getExpand(_: *ExpectMatcherContext, globalObject: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue { - _ = globalObject; - // TODO: this should return whether running tests in verbose mode or not (jest flag --expand), but bun currently doesn't have this switch - return JSValue.false; + // + // Matcher utils + // + + pub fn getUtils(_: *ExpectMatcherContext, globalObject: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue { + return ExpectMatcherUtils__getSingleton(globalObject); } pub fn equals(_: *ExpectMatcherContext, globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSC.JSValue { - var arguments = callframe.arguments(3); + const arguments = callframe.arguments(3); if (arguments.len < 2) { globalObject.throw("expect.extends matcher: this.util.equals expects at least 2 arguments", .{}); return .zero; @@ -5090,10 +5200,11 @@ pub const ExpectMatcherUtils = struct { pub usingnamespace JSC.Codegen.JSExpectMatcherUtils; fn createSingleton(globalObject: *JSC.JSGlobalObject) callconv(.C) JSValue { - var instance = globalObject.bunVM().allocator.create(ExpectMatcherUtils) catch { + const instance = globalObject.bunVM().allocator.create(ExpectMatcherUtils) catch { globalObject.throwOutOfMemory(); return .zero; }; + instance.* = .{}; return instance.toJS(globalObject); } @@ -5121,11 +5232,11 @@ pub const ExpectMatcherUtils = struct { .globalThis = globalObject, .quote_strings = true, }; - try writer.print("{}", .{value.toFmt(globalObject, &formatter)}); + const value_fmt = value.toFmt(globalObject, &formatter); - if (comptime color_or_null) |_| { + if (comptime color_or_null) |color| { if (Output.enable_ansi_colors) { - try writer.writeAll(Output.prettyFmt("", true)); + return try JSValue.printStringPretty(globalObject, 2048, Output.prettyFmt(color, true) ++ "{}", .{value_fmt}); } } @@ -5159,6 +5270,22 @@ pub const ExpectMatcherUtils = struct { return printValueCatched(globalObject, value, ""); } + pub fn diff(_: *ExpectMatcherUtils, globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSValue { + const arguments = callframe.arguments(2).slice(); + const expected = if (arguments.len > 0) arguments[0] else JSValue.undefined; + const received = if (arguments.len > 1) arguments[1] else JSValue.undefined; + + const diff_format = DiffFormatter{ + .expected = expected, + .received = received, + .globalObject = globalObject, + }; + return JSValue.printString(globalObject, 2048, "{any}\n", .{diff_format}) catch { + globalObject.throwOutOfMemory(); + return .zero; + }; + } + pub fn matcherHint(_: *ExpectMatcherUtils, globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSValue { const arguments = callframe.arguments(4).slice(); @@ -5228,7 +5355,7 @@ pub const ExpectMatcherUtils = struct { // Extract the matcher_fn from a JSCustomExpectMatcherFunction instance inline fn getCustomMatcherFn(thisValue: JSValue, globalObject: *JSGlobalObject) ?JSValue { - var matcher_fn = Bun__JSWrappingFunction__getWrappedFunction(thisValue, globalObject); + const matcher_fn = Bun__JSWrappingFunction__getWrappedFunction(thisValue, globalObject); return if (matcher_fn.isEmpty()) null else matcher_fn; } diff --git a/src/bun.js/test/jest.classes.ts b/src/bun.js/test/jest.classes.ts index 62905a5a5af5..f251b55ef9f3 100644 --- a/src/bun.js/test/jest.classes.ts +++ b/src/bun.js/test/jest.classes.ts @@ -95,10 +95,7 @@ export default [ configurable: false, klass: {}, proto: { - asymmetricMatch: { - fn: "asymmetricMatch", - length: 1, - }, + asymmetricMatch: { fn: "asymmetricMatch", length: 1 }, }, }), define({ @@ -111,22 +108,22 @@ export default [ configurable: false, klass: {}, proto: { - utils: { - getter: "getUtils", - }, - isNot: { - getter: "getIsNot", - }, - promise: { - getter: "getPromise", - }, - expand: { - getter: "getExpand", - }, - equals: { - fn: "equals", - length: 3, - }, + // test state + currentTestName: { getter: "getCurrentTestName" }, + testPath: { getter: "getTestPath" }, + assertionCalls: { getter: "getAssertionCalls" }, + expectedAssertionsNumber: { getter: "getExpectedAssertionsNumber" }, + isExpectingAssertions: { getter: "getIsExpectingAssertions" }, + snapshotState: { getter: "getSnapshotState" }, + expand: { getter: "getExpand" }, + + // matcher state + isNot: { getter: "getIsNot" }, + promise: { getter: "getPromise" }, + + // utils + utils: { getter: "getUtils" }, + equals: { fn: "equals", length: 3 }, }, }), define({ @@ -139,22 +136,11 @@ export default [ configurable: false, klass: {}, proto: { - stringify: { - fn: "stringify", - length: 1, - }, - printExpected: { - fn: "printExpected", - length: 1, - }, - printReceived: { - fn: "printReceived", - length: 1, - }, - matcherHint: { - fn: "matcherHint", - length: 1, - }, + stringify: { fn: "stringify", length: 1 }, + printExpected: { fn: "printExpected", length: 1 }, + printReceived: { fn: "printReceived", length: 1 }, + matcherHint: { fn: "matcherHint", length: 4 }, + diff: { fn: "diff", length: 2 }, }, }), define({ @@ -167,46 +153,19 @@ export default [ configurable: false, klass: {}, proto: { - anything: { - fn: "anything", - length: 1, - }, - any: { - fn: "any", - length: 1, - }, - arrayContaining: { - fn: "arrayContaining", - length: 1, - }, - closeTo: { - fn: "closeTo", - length: 1, - }, - objectContaining: { - fn: "objectContaining", - length: 1, - }, - stringContaining: { - fn: "stringContaining", - length: 1, - }, - stringMatching: { - fn: "stringMatching", - length: 1, - }, - not: { - getter: "getNot", - this: true, - }, - resolvesTo: { - getter: "getResolvesTo", - this: true, - }, - rejectsTo: { - getter: "getRejectsTo", - this: true, - }, + // modifiers (static) + not: { getter: "getNot", this: true }, + resolvesTo: { getter: "getResolvesTo", this: true }, + rejectsTo: { getter: "getRejectsTo", this: true }, + + /// asymmetric matchers + any: { fn: "any", length: 1 }, + anything: { fn: "anything", length: 1 }, + arrayContaining: { fn: "arrayContaining", length: 1 }, + closeTo: { fn: "closeTo", length: 1 }, + objectContaining: { fn: "objectContaining", length: 1 }, + stringContaining: { fn: "stringContaining", length: 1 }, + stringMatching: { fn: "stringMatching", length: 1 }, }, }), define({ @@ -218,63 +177,28 @@ export default [ values: ["capturedValue", "resultValue"], configurable: false, klass: { - extend: { - fn: "extend", - length: 1, - }, - anything: { - fn: "anything", - length: 1, - }, - any: { - fn: "any", - length: 1, - }, - arrayContaining: { - fn: "arrayContaining", - length: 1, - }, - assertions: { - fn: "assertions", - length: 1, - }, - hasAssertions: { - fn: "hasAssertions", - length: 1, - }, - closeTo: { - fn: "closeTo", - length: 1, - }, - objectContaining: { - fn: "objectContaining", - length: 1, - }, - stringContaining: { - fn: "stringContaining", - length: 1, - }, - stringMatching: { - fn: "stringMatching", - length: 1, - }, - addSnapshotSerializer: { - fn: "addSnapshotSerializer", - length: 1, - }, - not: { - getter: "getStaticNot", - }, - resolvesTo: { - getter: "getStaticResolvesTo", - }, - rejectsTo: { - getter: "getStaticRejectsTo", - }, - unreachable: { - fn: "doUnreachable", - length: 1, - }, + // modifiers (static) + not: { getter: "getStaticNot" }, + resolvesTo: { getter: "getStaticResolvesTo" }, + rejectsTo: { getter: "getStaticRejectsTo" }, + + // misc + addEqualityTesters: { fn: "addEqualityTesters", length: 1 }, + addSnapshotSerializer: { fn: "addSnapshotSerializer", length: 1 }, + assertions: { fn: "assertions", length: 1 }, + hasAssertions: { fn: "hasAssertions", length: 0 }, + extend: { fn: "extend", length: 1 }, + getState: { fn: "getState", length: 0 }, + unreachable: { fn: "doUnreachable", length: 1 }, + + /// asymmetric matchers + any: { fn: "any", length: 1 }, + anything: { fn: "anything", length: 1 }, + arrayContaining: { fn: "arrayContaining", length: 1 }, + closeTo: { fn: "closeTo", length: 1 }, + objectContaining: { fn: "objectContaining", length: 1 }, + stringContaining: { fn: "stringContaining", length: 1 }, + stringMatching: { fn: "stringMatching", length: 1 }, }, proto: { pass: { diff --git a/src/bun.js/test/jest.zig b/src/bun.js/test/jest.zig index 25f937a07ccf..d710980ef7a2 100644 --- a/src/bun.js/test/jest.zig +++ b/src/bun.js/test/jest.zig @@ -32,6 +32,7 @@ const FeatureFlags = bun.FeatureFlags; const ArrayBuffer = @import("../base.zig").ArrayBuffer; const Properties = @import("../base.zig").Properties; const getAllocator = @import("../base.zig").getAllocator; +const util_format = @import("../node/util/format.zig"); const RegularExpression = bun.RegularExpression; const ZigString = JSC.ZigString; @@ -1772,74 +1773,36 @@ fn consumeArg( } // Generate test label by positionally injecting parameters with printf formatting -fn formatLabel(globalThis: *JSC.JSGlobalObject, label: string, function_args: []JSC.JSValue, test_idx: usize) !string { - const allocator = getAllocator(globalThis); - var idx: usize = 0; - var args_idx: usize = 0; - var list = try std.ArrayListUnmanaged(u8).initCapacity(allocator, label.len); - - while (idx < label.len) { - const char = label[idx]; - if (char == '%' and (idx + 1 < label.len) and !(args_idx >= function_args.len)) { - const current_arg = function_args[args_idx]; - - switch (label[idx + 1]) { - 's' => { - try consumeArg(globalThis, !current_arg.isEmpty() and current_arg.jsType().isString(), &idx, &args_idx, &list, ¤t_arg, "%s"); - }, - 'i' => { - try consumeArg(globalThis, current_arg.isAnyInt(), &idx, &args_idx, &list, ¤t_arg, "%i"); - }, - 'd' => { - try consumeArg(globalThis, current_arg.isNumber(), &idx, &args_idx, &list, ¤t_arg, "%d"); - }, - 'f' => { - try consumeArg(globalThis, current_arg.isNumber(), &idx, &args_idx, &list, ¤t_arg, "%f"); - }, - 'j', 'o' => { - var str = bun.String.empty; - defer str.deref(); - current_arg.jsonStringify(globalThis, 0, &str); - const owned_slice = try str.toOwnedSlice(allocator); - defer allocator.free(owned_slice); - try list.appendSlice(allocator, owned_slice); - idx += 1; - args_idx += 1; - }, - 'p' => { - var formatter = JSC.ConsoleObject.Formatter{ - .globalThis = globalThis, - .quote_strings = true, - }; - const value_fmt = current_arg.toFmt(globalThis, &formatter); - const test_index_str = try std.fmt.allocPrint(allocator, "{any}", .{value_fmt}); - defer allocator.free(test_index_str); - try list.appendSlice(allocator, test_index_str); - idx += 1; - args_idx += 1; - }, - '#' => { - const test_index_str = try std.fmt.allocPrint(allocator, "{d}", .{test_idx}); - defer allocator.free(test_index_str); - try list.appendSlice(allocator, test_index_str); - idx += 1; - }, - '%' => { - try list.append(allocator, '%'); - idx += 1; - }, - else => { - // ignore unrecognized fmt - }, - } - } else try list.append(allocator, char); - idx += 1; - } - - return list.toOwnedSlice(allocator); +fn formatLabel(globalObject: *JSC.JSGlobalObject, label: string, function_args: []JSC.JSValue, root_object: ?JSValue, test_idx: usize, headers: ?JSValue) !string { + var allocator = globalObject.allocator(); + + const WRITER_STACK_CAPACITY = 2048; + var heap_fallback = std.heap.stackFallback(WRITER_STACK_CAPACITY, globalObject.allocator()); + + var buf = try MutableString.init(heap_fallback.get(), @max(WRITER_STACK_CAPACITY, label.len)); + defer buf.deinit(); + + try util_format.formatImpl( + buf.writer(), + globalObject, + null, + label, + function_args, + util_format.TestLabelFormatExtras{ + .test_idx = test_idx, + .table_headers = headers, + .root_object = root_object, + }, + ); + + return allocator.dupe(u8, buf.toOwnedSliceLeaky()); } -pub const EachData = struct { strong: JSC.Strong, is_test: bool }; +pub const EachData = struct { + strong: JSC.Strong, + has_headers: bool, + is_test: bool, +}; fn eachBind( globalThis: *JSGlobalObject, @@ -1912,13 +1875,18 @@ fn eachBind( var iter = array.arrayIterator(globalThis); + const headers: ?JSValue = if (each_data.has_headers) iter.next() else null; + const headers_len: usize = if (headers) |h| h.getLength(globalThis) else 0; + var test_idx: usize = 0; while (iter.next()) |item| { const func_params_length = function.getLength(globalThis); const item_is_array = !item.isEmptyOrUndefinedOrNull() and item.jsType().isArray(); var arg_size: usize = 1; - if (item_is_array) { + if (headers != null) { + arg_size = 1; + } else if (item_is_array) { arg_size = item.getLength(globalThis); } @@ -1928,10 +1896,21 @@ fn eachBind( arg_size += 1; } - var function_args = allocator.alloc(JSC.JSValue, arg_size) catch @panic("can't create function_args"); + const function_args = allocator.alloc(JSC.JSValue, arg_size) catch @panic("can't create function_args"); + var root_object: ?JSValue = null; var idx: u32 = 0; - if (item_is_array) { + if (headers) |headers_| { + // Build an object with table header names as fields + const obj = JSValue.createEmptyObject(globalThis, headers_len); + for (0..headers_len) |i| { + const propname = headers_.getIndex(globalThis, @truncate(i)); + const value = item.getIndex(globalThis, @truncate(i)); + obj.put(globalThis, &propname.getZigString(globalThis), value); + } + obj.protect(); + function_args[0] = obj; + } else if (item_is_array) { // Spread array as args var item_iter = item.arrayIterator(globalThis); while (item_iter.next()) |array_item| { @@ -1946,13 +1925,17 @@ fn eachBind( } else { item.protect(); function_args[0] = item; + if (item.isObject()) { + root_object = item; + } } const label = if (description.isEmptyOrUndefinedOrNull()) "" else (description.toSlice(globalThis, allocator).cloneIfNeeded(allocator) catch unreachable).slice(); - const formattedLabel = formatLabel(globalThis, label, function_args, test_idx) catch return .zero; + + const formatted_label = formatLabel(globalThis, label, function_args, root_object, test_idx, headers) catch return .zero; const tag = parent.tag; @@ -1968,7 +1951,7 @@ fn eachBind( var buffer: bun.MutableString = Jest.runner.?.filter_buffer; buffer.reset(); appendParentLabel(&buffer, parent) catch @panic("Bun ran out of memory while filtering tests"); - buffer.append(formattedLabel) catch unreachable; + buffer.append(formatted_label) catch unreachable; const str = bun.String.fromBytes(buffer.toOwnedSliceLeaky()); is_skip = !regex.matches(str); } @@ -1982,7 +1965,7 @@ fn eachBind( } else { function.protect(); parent.tests.append(allocator, TestScope{ - .label = formattedLabel, + .label = formatted_label, .parent = parent, .tag = tag, .func = function, @@ -2000,7 +1983,7 @@ fn eachBind( } else { var scope = allocator.create(DescribeScope) catch unreachable; scope.* = .{ - .label = formattedLabel, + .label = formatted_label, .parent = parent, .file_id = parent.file_id, .tag = tag, @@ -2017,6 +2000,97 @@ fn eachBind( return .zero; } +fn concatJSValues(globalObject: *JSGlobalObject, a: ?JSValue, b: JSValue) JSValue { + if (a) |a_| { + var concat_result = bun.String.createFromConcat(globalObject.allocator(), &[_]bun.String{ + bun.String.init(a_.toString(globalObject).getZigString(globalObject)), + bun.String.init(b.toString(globalObject).getZigString(globalObject)), + }) catch unreachable; + defer concat_result.deinit(); + return concat_result.str.toJS(globalObject); + } else { + return b; + } +} + +fn parseTaggedTemplateLiteralTable( + globalThis: *JSGlobalObject, + callframe: *CallFrame, +) ?JSValue { + // arguments[0] is the js array with the substituted values + // arguments[1..] are the strings inbetween + const values_len = callframe.argumentsCount() - 1; + const strings_array = callframe.argument(0); + const strings_len = strings_array.getLength(globalThis); + + var stack_fallback = std.heap.stackFallback(512, globalThis.allocator()); + + if (values_len != strings_len - 1) { + return null; // invalid tag function call + } + + var row: usize = 0; + var col: usize = 0; + + const rows = JSValue.createEmptyArray(globalThis, 0); + var curr_row: ?JSValue = null; + var curr_val: ?JSValue = null; + + for (0..strings_len) |val_idx| { + const str = strings_array.getIndex(globalThis, @truncate(val_idx)); + if (!str.isString()) return null; + + var sliced = str.toSlice(globalThis, stack_fallback.get()); + defer sliced.deinit(); + const slice = sliced.slice(); + + var split_idx: usize = 0; + var iter = std.mem.splitAny(u8, slice, "|\n"); + while (iter.next()) |portion| : (split_idx += 1) { + const content = std.mem.trim(u8, portion, " "); + if (content.len > 0) { + curr_val = concatJSValues(globalThis, curr_val, bun.String.static(content).toJS(globalThis)); + } + + var delim: ?u8 = undefined; + if (iter.index) |delim_end| { + delim = slice[delim_end - 1]; + } else if (val_idx == strings_len - 1) { // for last split match of the last template string portion, count the sep as '\n' + delim = '\n'; + } else { + delim = null; + } + + if (delim) |delim_| { + if (curr_val != null or delim_ == '|') { + if (curr_row == null) curr_row = JSValue.createEmptyArray(globalThis, 0); + curr_row.?.push(globalThis, curr_val orelse JSValue.undefined); + curr_val = null; + } + + if (delim_ == '|') { + col += 1; + } else { // '\n' + if (curr_row) |curr_row_| { // if non-empty row + rows.push(globalThis, curr_row_); + row += 1; + col = 0; + curr_row = null; + } + } + } + } + + if (val_idx < values_len) { // consume next value provided to the tagged template string + const val = callframe.argumentsPtr()[1 + val_idx]; + curr_val = concatJSValues(globalThis, curr_val, val); + } + } + + if (row < 2) return null; // invalid: need at least the header row and 1 data row + return rows; +} + inline fn createEach( globalThis: *JSGlobalObject, callframe: *CallFrame, @@ -2024,26 +2098,34 @@ inline fn createEach( comptime signature: string, comptime is_test: bool, ) JSValue { - const arguments = callframe.arguments(1); - const args = arguments.slice(); - + const args = callframe.argumentsSlice(); if (args.len == 0) { globalThis.throwPretty("{s} expects an array", .{signature}); return .zero; } var array = args[0]; + var is_tagged_template_literal = false; if (array.isEmpty() or !array.jsType().isArray()) { globalThis.throwPretty("{s} expects an array", .{signature}); return .zero; } + if (array.get(globalThis, "raw") != null) { // tagged template literal table TODO: find better check + is_tagged_template_literal = true; + array = parseTaggedTemplateLiteralTable(globalThis, callframe) orelse { + globalThis.throwPretty("{s} invalid tagged template literal table", .{signature}); + return .zero; + }; + } + const allocator = getAllocator(globalThis); const name = ZigString.static(property); const strong = JSC.Strong.create(array, globalThis); const each_data = allocator.create(EachData) catch unreachable; each_data.* = EachData{ .strong = strong, + .has_headers = is_tagged_template_literal, .is_test = is_test, }; diff --git a/src/cli.zig b/src/cli.zig index 27afddef28bf..3e1e35302dc4 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -246,6 +246,7 @@ pub const Arguments = struct { clap.parseParam("--rerun-each Re-run each test file times, helps catch certain bugs") catch unreachable, clap.parseParam("--only Only run tests that are marked with \"test.only()\"") catch unreachable, clap.parseParam("--todo Include tests that are marked with \"test.todo()\"") catch unreachable, + clap.parseParam("--expand Output more detailed messages") catch unreachable, clap.parseParam("--coverage Generate a coverage profile") catch unreachable, clap.parseParam("--bail ? Exit the test suite after failures. If you do not specify a number, it defaults to 1.") catch unreachable, clap.parseParam("-t, --test-name-pattern Run only tests with a name that matches the given regex.") catch unreachable, @@ -479,6 +480,7 @@ pub const Arguments = struct { ctx.test_options.update_snapshots = args.flag("--update-snapshots"); ctx.test_options.run_todo = args.flag("--todo"); ctx.test_options.only = args.flag("--only"); + ctx.test_options.expand = args.flag("--expand"); } ctx.args.absolute_working_dir = cwd; @@ -1142,6 +1144,7 @@ pub const Command = struct { repeat_count: u32 = 0, run_todo: bool = false, only: bool = false, + expand: bool = false, bail: u32 = 0, coverage: TestCommand.CodeCoverageOptions = .{}, test_filter_regex: ?*RegularExpression = null, diff --git a/src/string.zig b/src/string.zig index d6814ca0f521..4c6429a1f715 100644 --- a/src/string.zig +++ b/src/string.zig @@ -1190,26 +1190,47 @@ pub const String = extern struct { extern fn JSC__createError(*JSC.JSGlobalObject, str: *const String) JSC.JSValue; - fn concat(comptime n: usize, allocator: std.mem.Allocator, strings: *const [n]String) !String { - var num_16bit: usize = 0; + fn concat(comptime n: usize, allocator: std.mem.Allocator, strings: *const [n]String) !OwnedString { + if (comptime n == 0) { + return OwnedString.init(String.static("")); + } + + var same_encoding: ?bun.strings.EncodingNonAscii = null; inline for (strings) |str| { - if (!str.is8Bit()) num_16bit += 1; + const enc = str.encoding(); + if (same_encoding != null and same_encoding.? != enc) { + same_encoding = null; + break; + } + same_encoding = enc; } - if (num_16bit == n) { - // all are 16bit - var slices: [n][]const u16 = undefined; - for (strings, 0..) |str, i| { - slices[i] = switch (str.tag) { - .WTFStringImpl => str.value.WTFStringImpl.utf16Slice(), - .ZigString, .StaticZigString => str.value.ZigString.utf16SliceAligned(), - else => &[_]u16{}, - }; + if (same_encoding) |encoding_| { + // fast path, all strings have the same encoding: just concat the bytes + switch (encoding_) { + inline .latin1, .utf8 => |enc| { + var slices: [n][]const u8 = undefined; + inline for (strings, 0..) |str, i| { + slices[i] = str.byteSlice(); + } + const result = try std.mem.concat(allocator, u8, &slices); + return OwnedString.initFromOwnedSlice(result, enc, allocator); + }, + .utf16 => { + var slices: [n][]const u16 = undefined; + for (strings, 0..) |str, i| { + slices[i] = switch (str.tag) { + .WTFStringImpl => str.value.WTFStringImpl.utf16Slice(), + .ZigString, .StaticZigString => str.value.ZigString.utf16SliceAligned(), + else => &[_]u16{}, + }; + } + const result = try std.mem.concat(allocator, u16, &slices); + return OwnedString.initFromOwnedSlice(result, .utf16, allocator); + }, } - const result = try std.mem.concat(allocator, u16, &slices); - return init(ZigString.from16Slice(result)); } else { - // either all 8bit, or mixed 8bit and 16bit + // slow path, different encodings: convert all to utf8 var slices_holded: [n]SliceWithUnderlyingString = undefined; var slices: [n][]const u8 = undefined; inline for (strings, 0..) |str, i| { @@ -1220,14 +1241,14 @@ pub const String = extern struct { inline for (0..n) |i| { slices_holded[i].deinit(); } - return createUTF8(result); + return OwnedString.initFromOwnedSlice(result, .utf8, allocator); } } /// Creates a new String from a given tuple (of comptime-known size) of String. /// /// Note: the callee owns the resulting string and must call `.deref()` on it once done - pub inline fn createFromConcat(allocator: std.mem.Allocator, strings: anytype) !String { + pub inline fn createFromConcat(allocator: std.mem.Allocator, strings: anytype) !OwnedString { return try concat(strings.len, allocator, strings); } @@ -1374,3 +1395,37 @@ pub const SliceWithUnderlyingString = struct { return this.underlying.toJS(globalObject); } }; + +/// A wrapper for an String which is owned. +/// The owner must call .deinit() after done with it. +pub const OwnedString = struct { + str: String, + allocator: bun.NullableAllocator = .{}, + + pub fn init(str: String) OwnedString { + str.ref(); + return .{ .str = str }; + } + + pub fn initFromOwnedSlice(owned_slice: anytype, comptime encoding: bun.strings.EncodingNonAscii, allocator: std.mem.Allocator) OwnedString { + switch (encoding) { + .latin1, .utf8 => { + var zstr = ZigString.init(owned_slice); + if (encoding == .utf8) { + zstr.markUTF8(); + } + return .{ .str = String.init(zstr), .allocator = bun.NullableAllocator.init(allocator) }; + }, + .utf16 => { + return .{ .str = String.init(ZigString.from16Slice(owned_slice)), .allocator = bun.NullableAllocator.init(allocator) }; + }, + } + } + + pub fn deinit(this: OwnedString) void { + this.str.deref(); + if (this.allocator.get()) |allocator| { + allocator.free(this.str.byteSlice()); + } + } +}; diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index b0b7f5dab0f3..6ceb2a5fad56 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -4497,3 +4497,32 @@ describe("expect()", () => { expect(" ").toContain(""); }); }); + +describe("expect.getState()", () => { + test("basic", () => { + const ASSERTIONS_TOTAL = 11; // need due to using `expect.assertions()` + + const state = expect.getState(); + + expect(state.assertionCalls).toBe(0); + expect(state.assertionCalls).toBe(1); //expect(state.numPassingAsserts).toBe(1); + expect(state.assertionCalls).toBe(2); + expect(state.assertionCalls).toBe(3); //expect(state.numPassingAsserts).toBe(3); + + expect(state.currentTestName).toBe("basic"); + expect(state.testPath).toEndWith(/*"expect.test.js"*/ ".js"); // FIXME: has some issues with test-interop.js + expect(state.expand).toBeBoolean(); + + expect(state.isExpectingAssertions).toBe(false); + expect.hasAssertions(); + expect(state.isExpectingAssertions).toBe(true); + + expect(state.expectedAssertionsNumber).toBe(null); + expect.assertions(ASSERTIONS_TOTAL); + expect(state.expectedAssertionsNumber).toBe(ASSERTIONS_TOTAL); + + //expect(state.suppressedErrors).toBeArrayOfSize(0); + + //expect(state.snapshotState).toBe(snapshotState: expect.anything(); + }); +}); diff --git a/test/js/bun/test/jest-each-testnames.test.js b/test/js/bun/test/jest-each-testnames.test.js new file mode 100644 index 000000000000..c0ed400079d4 --- /dev/null +++ b/test/js/bun/test/jest-each-testnames.test.js @@ -0,0 +1,44 @@ +// @ts-check + +/** This file is meant to be runnable in Jest, Vitest, and Bun: + * `bun test test/js/bun/test/jest-each-names.test.js` + * `bunx vitest test/js/bun/test/jest-each-names.test.js` + * `NODE_OPTIONS=--experimental-vm-modules npx jest test/js/bun/test/jest-each-names.test.js` + */ + +// Meta-test that checks that only the tests marked with ".only" run + +import test_interop from "./test-interop.js"; +var { test, describe, expect } = await test_interop(); + +test.each([ + { a: 1, b: 2 }, + { a: 3, b: 4 }, +])("test: $a | $b", ({ a, b }) => { + expect(expect.getState?.()?.currentTestName).toBe(`test: ${a} | ${b}`); +}); + +test.each([{ a: { b: 3 } }])("test: $a.b", ({ a }) => { + expect(expect.getState?.()?.currentTestName).toBe(`test: ${a.b}`); +}); + +test.each([{ a: 1 }])("test: $ $$ $.a", _ => { + expect(expect.getState?.()?.currentTestName).toBe("test: $ $$ $.a"); +}); + +// test string interpolation +// FIXME +//test.each([["a", 42, true, { b: 1 }, ["c"]]])("test: %s | %s | %s | %s | %s", _ => { +// expect(expect.getState?.()?.currentTestName).toBe("test: a | 42 | true | { b: 1 } | [ 'c' ]"); +//}); + +let expectedIndex = 0; +test.each([{}, {}, {}])("test index: %#", () => { + expect(expect.getState?.()?.currentTestName).toBe(`test index: ${expectedIndex++}`); +}); + +// ensure test index resets for each .each execution +let expectedIndexB = 0; +test.each([{}, {}])("test index: %#", () => { + expect(expect.getState?.()?.currentTestName).toBe(`test index: ${expectedIndexB++}`); +}); diff --git a/test/js/bun/test/jest-each.test.ts b/test/js/bun/test/jest-each.test.ts index bb8bd54b4c57..455670f791e6 100644 --- a/test/js/bun/test/jest-each.test.ts +++ b/test/js/bun/test/jest-each.test.ts @@ -45,6 +45,28 @@ describe("jest-each", () => { it.each([undefined, null, NaN, Infinity])("stringify %#: %j", (arg, cb) => { cb(); }); + + describe("support each in Tagged Template Literal", () => { + // issue #6364 + + describe("basic", () => { + let numExecutions = 0; + + it.each` + a | b | expected + ${1} | ${1} | ${2} + ${1} | ${2} | ${3} + ${2} | ${1} | ${3} + `("returns $expected when $a is added to $b", ({ a, b, expected }) => { + numExecutions += 1; + expect(a + b).toBe(expected); + }); + + it("should execute all cases", () => { + expect(numExecutions).toBe(3); + }); + }); + }); }); describe.each(["some", "cool", "strings"])("works with describe: %s", s => { From 4f8ee6ba915445c79f451b220baa4af060427ad9 Mon Sep 17 00:00:00 2001 From: Dylan Conway <35280289+dylan-conway@users.noreply.github.com> Date: Mon, 27 May 2024 15:40:48 -0700 Subject: [PATCH 2/2] Update src/bun.js/node/util/format.zig --- src/bun.js/node/util/format.zig | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/bun.js/node/util/format.zig b/src/bun.js/node/util/format.zig index 4f7ea45ae2ec..9033b7adb930 100644 --- a/src/bun.js/node/util/format.zig +++ b/src/bun.js/node/util/format.zig @@ -1,16 +1,14 @@ const std = @import("std"); -//const bun = @import("root").bun; -const bun = @import("../../../bun.zig"); +const bun = @import("root").bun; const string = bun.string; const strings = bun.strings; const String = bun.String; -//const JSC = bun.JSC; -const JSC = @import("../../../bindgen.zig").JavaScriptCore; +const JSC = bun.JSC; const JSValue = JSC.JSValue; const JSGlobalObject = JSC.JSGlobalObject; -const ConsoleObject = @import("../../ConsoleObject.zig"); +const ConsoleObject = JSC.ConsoleObject; const ZigString = JSC.ZigString; -const MutableString = @import("../../../string_mutable.zig").MutableString; +const MutableString = bun.MutableString; const validators = @import("./validators.zig"); const validateObject = validators.validateObject;