From bcf023c8293b7a5339c25adc0c2a6d7cc2da38bd Mon Sep 17 00:00:00 2001 From: pfg Date: Thu, 5 Dec 2024 13:07:10 -0800 Subject: [PATCH] Implement expect().toMatchInlineSnapshot() (#15570) --- docs/guides/test/migrate-from-jest.md | 1 - docs/guides/test/snapshot.md | 6 +- docs/guides/test/update-snapshots.md | 6 +- docs/test/writing.md | 2 +- packages/bun-types/test.d.ts | 23 + src/bun.js/bindings/bindings.cpp | 73 +++ src/bun.js/bindings/bindings.zig | 22 + src/bun.js/event_loop.zig | 1 + src/bun.js/test/expect.zig | 153 ++++- src/bun.js/test/snapshot.zig | 247 +++++++- src/cli/test_command.zig | 29 +- src/js_lexer.zig | 2 +- src/logger.zig | 42 ++ test/harness.ts | 2 +- .../__snapshots__/snapshot.test.ts.snap | 36 ++ .../snapshot-tests/snapshots/snapshot.test.ts | 574 +++++++++++++++--- 16 files changed, 1093 insertions(+), 126 deletions(-) diff --git a/docs/guides/test/migrate-from-jest.md b/docs/guides/test/migrate-from-jest.md index f00eac753701e7..9cf250d6032319 100644 --- a/docs/guides/test/migrate-from-jest.md +++ b/docs/guides/test/migrate-from-jest.md @@ -30,7 +30,6 @@ Bun implements the vast majority of Jest's matchers, but compatibility isn't 100 Some notable missing features: -- `expect().toMatchInlineSnapshot()` - `expect().toHaveReturned()` --- diff --git a/docs/guides/test/snapshot.md b/docs/guides/test/snapshot.md index 7c4867709f0b94..f30feb0492ee13 100644 --- a/docs/guides/test/snapshot.md +++ b/docs/guides/test/snapshot.md @@ -4,10 +4,6 @@ name: Use snapshot testing in `bun test` Bun's test runner supports Jest-style snapshot testing via `.toMatchSnapshot()`. -{% callout %} -The `.toMatchInlineSnapshot()` method is not yet supported. -{% /callout %} - ```ts#snap.test.ts import { test, expect } from "bun:test"; @@ -96,4 +92,4 @@ Ran 1 tests across 1 files. [102.00ms] --- -See [Docs > Test Runner > Snapshots](https://bun.sh/docs/test/mocks) for complete documentation on mocking with the Bun test runner. +See [Docs > Test Runner > Snapshots](https://bun.sh/docs/test/snapshots) for complete documentation on snapshots with the Bun test runner. diff --git a/docs/guides/test/update-snapshots.md b/docs/guides/test/update-snapshots.md index 3d9ba078eb3dec..24f76440a8b002 100644 --- a/docs/guides/test/update-snapshots.md +++ b/docs/guides/test/update-snapshots.md @@ -4,10 +4,6 @@ name: Update snapshots in `bun test` Bun's test runner supports Jest-style snapshot testing via `.toMatchSnapshot()`. -{% callout %} -The `.toMatchInlineSnapshot()` method is not yet supported. -{% /callout %} - ```ts#snap.test.ts import { test, expect } from "bun:test"; @@ -47,4 +43,4 @@ Ran 1 tests across 1 files. [102.00ms] --- -See [Docs > Test Runner > Snapshots](https://bun.sh/docs/test/mocks) for complete documentation on mocking with the Bun test runner. +See [Docs > Test Runner > Snapshots](https://bun.sh/docs/test/snapshots) for complete documentation on snapshots with the Bun test runner. diff --git a/docs/test/writing.md b/docs/test/writing.md index ef1ab57de00b8a..e56c234b10da1c 100644 --- a/docs/test/writing.md +++ b/docs/test/writing.md @@ -531,7 +531,7 @@ Bun implements the following matchers. Full Jest compatibility is on the roadmap --- -- ❌ +- ✅ - [`.toMatchInlineSnapshot()`](https://jestjs.io/docs/expect#tomatchinlinesnapshotpropertymatchers-inlinesnapshot) --- diff --git a/packages/bun-types/test.d.ts b/packages/bun-types/test.d.ts index 6ef3a6040380f1..da6fbd31f7d8c7 100644 --- a/packages/bun-types/test.d.ts +++ b/packages/bun-types/test.d.ts @@ -1295,6 +1295,29 @@ declare module "bun:test" { * @param hint Hint used to identify the snapshot in the snapshot file. */ toMatchSnapshot(propertyMatchers?: object, hint?: string): void; + /** + * Asserts that a value matches the most recent inline snapshot. + * + * @example + * expect("Hello").toMatchInlineSnapshot(`"Hello"`); + * @param value The latest snapshot value. + */ + toMatchInlineSnapshot(value?: string): void; + /** + * Asserts that a value matches the most recent inline snapshot. + * + * @example + * expect("Hello").toMatchInlineSnapshot(`"Hello"`); + * expect({ c: new Date() }).toMatchInlineSnapshot({ c: expect.any(Date) }, ` + * { + * "v": Any, + * } + * `); + * + * @param propertyMatchers Object containing properties to match against the value. + * @param hint Hint used to identify the snapshot in the snapshot file. + */ + toMatchInlineSnapshot(propertyMatchers?: object, value?: string): void; /** * Asserts that an object matches a subset of properties. * diff --git a/src/bun.js/bindings/bindings.cpp b/src/bun.js/bindings/bindings.cpp index d2b353aa346717..38e244d118cfaa 100644 --- a/src/bun.js/bindings/bindings.cpp +++ b/src/bun.js/bindings/bindings.cpp @@ -123,6 +123,7 @@ #include "JavaScriptCore/CustomGetterSetter.h" #include "ErrorStackFrame.h" +#include "ErrorStackTrace.h" #include "ObjectBindings.h" #if OS(DARWIN) @@ -6030,3 +6031,75 @@ CPP_DECL bool Bun__CallFrame__isFromBunMain(JSC::CallFrame* callFrame, JSC::VM* return false; return source.string() == "builtin://bun/main"_s; } + +CPP_DECL void Bun__CallFrame__getCallerSrcLoc(JSC::CallFrame* callFrame, JSC::JSGlobalObject* globalObject, unsigned int* outSourceID, unsigned int* outLine, unsigned int* outColumn) +{ + JSC::VM& vm = globalObject->vm(); + JSC::LineColumn lineColumn; + JSC::SourceID sourceID = 0; + String sourceURL; + + ZigStackFrame remappedFrame = {}; + + JSC::StackVisitor::visit(callFrame, vm, [&](JSC::StackVisitor& visitor) -> WTF::IterationStatus { + if (Zig::isImplementationVisibilityPrivate(visitor)) + return WTF::IterationStatus::Continue; + + if (visitor->hasLineAndColumnInfo()) { + lineColumn = visitor->computeLineAndColumn(); + + String sourceURLForFrame = visitor->sourceURL(); + + // Sometimes, the sourceURL is empty. + // For example, pages in Next.js. + if (sourceURLForFrame.isEmpty()) { + + // hasLineAndColumnInfo() checks codeBlock(), so this is safe to access here. + const auto& source = visitor->codeBlock()->source(); + + // source.isNull() is true when the SourceProvider is a null pointer. + if (!source.isNull()) { + auto* provider = source.provider(); + // I'm not 100% sure we should show sourceURLDirective here. + if (!provider->sourceURLDirective().isEmpty()) { + sourceURLForFrame = provider->sourceURLDirective(); + } else if (!provider->sourceURL().isEmpty()) { + sourceURLForFrame = provider->sourceURL(); + } else { + const auto& origin = provider->sourceOrigin(); + if (!origin.isNull()) { + sourceURLForFrame = origin.string(); + } + } + + sourceID = provider->asID(); + } + } + + sourceURL = sourceURLForFrame; + + return WTF::IterationStatus::Done; + } + + return WTF::IterationStatus::Continue; + }); + + if (!sourceURL.isEmpty() and lineColumn.line > 0) { + OrdinalNumber originalLine = OrdinalNumber::fromOneBasedInt(lineColumn.line); + OrdinalNumber originalColumn = OrdinalNumber::fromOneBasedInt(lineColumn.column); + + remappedFrame.position.line_zero_based = originalLine.zeroBasedInt(); + remappedFrame.position.column_zero_based = originalColumn.zeroBasedInt(); + remappedFrame.source_url = Bun::toStringRef(sourceURL); + + Bun__remapStackFramePositions(globalObject, &remappedFrame, 1); + + sourceURL = remappedFrame.source_url.toWTFString(); + lineColumn.line = OrdinalNumber::fromZeroBasedInt(remappedFrame.position.line_zero_based).oneBasedInt(); + lineColumn.column = OrdinalNumber::fromZeroBasedInt(remappedFrame.position.column_zero_based).oneBasedInt(); + } + + *outSourceID = sourceID; + *outLine = lineColumn.line; + *outColumn = lineColumn.column; +} diff --git a/src/bun.js/bindings/bindings.zig b/src/bun.js/bindings/bindings.zig index 2caa3b93ab6fe3..5034ae9806993c 100644 --- a/src/bun.js/bindings/bindings.zig +++ b/src/bun.js/bindings/bindings.zig @@ -6618,6 +6618,10 @@ pub const CallFrame = opaque { }; } + pub fn arguments(self: *const CallFrame) []const JSValue { + // this presumably isn't allowed given that it doesn't exist + return self.argumentsPtr()[0..self.argumentsCount()]; + } pub fn arguments_old(self: *const CallFrame, comptime max: usize) Arguments(max) { const len = self.argumentsCount(); const ptr = self.argumentsPtr(); @@ -6661,6 +6665,24 @@ pub const CallFrame = opaque { } return value; } + + extern fn Bun__CallFrame__getCallerSrcLoc(*const CallFrame, *JSGlobalObject, *c_uint, *c_uint, *c_uint) void; + pub const CallerSrcLoc = struct { + source_file_id: c_uint, + line: c_uint, + column: c_uint, + }; + pub fn getCallerSrcLoc(call_frame: *const CallFrame, globalThis: *JSGlobalObject) CallerSrcLoc { + var source_id: c_uint = undefined; + var line: c_uint = undefined; + var column: c_uint = undefined; + Bun__CallFrame__getCallerSrcLoc(call_frame, globalThis, &source_id, &line, &column); + return .{ + .source_file_id = source_id, + .line = line, + .column = column, + }; + } }; pub const EncodedJSValue = extern union { diff --git a/src/bun.js/event_loop.zig b/src/bun.js/event_loop.zig index c80fe890ea8d05..514e8489d09d2d 100644 --- a/src/bun.js/event_loop.zig +++ b/src/bun.js/event_loop.zig @@ -1438,6 +1438,7 @@ pub const EventLoop = struct { } } + this.processGCTimer(); this.processGCTimer(); loop.tick(); diff --git a/src/bun.js/test/expect.zig b/src/bun.js/test/expect.zig index 1650e7dd262a01..a384c42aecf0dd 100644 --- a/src/bun.js/test/expect.zig +++ b/src/bun.js/test/expect.zig @@ -2506,6 +2506,136 @@ pub const Expect = struct { expected_value.getClassName(globalThis, &expected_class); return this.throw(globalThis, signature, expected_fmt, .{ expected_class, result.toFmt(&formatter) }); } + pub fn toMatchInlineSnapshot(this: *Expect, globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue { + // in jest, a failing inline snapshot does not block the rest from running + // not sure why - empty snapshots will autofill and with the `-u` flag none will fail + + defer this.postMatch(globalThis); + const thisValue = callFrame.this(); + const _arguments = callFrame.arguments_old(2); + const arguments: []const JSValue = _arguments.ptr[0.._arguments.len]; + + incrementExpectCallCounter(); + + const not = this.flags.not; + if (not) { + const signature = comptime getSignature("toMatchInlineSnapshot", "", true); + return this.throw(globalThis, signature, "\n\nMatcher error: Snapshot matchers cannot be used with not\n", .{}); + } + + var has_expected = false; + var expected_string: ZigString = ZigString.Empty; + var property_matchers: ?JSValue = null; + switch (arguments.len) { + 0 => {}, + 1 => { + if (arguments[0].isString()) { + has_expected = true; + arguments[0].toZigString(&expected_string, globalThis); + } else if (arguments[0].isObject()) { + property_matchers = arguments[0]; + } else { + return this.throw(globalThis, "", "\n\nMatcher error: Expected first argument to be a string or object\n", .{}); + } + }, + else => { + if (!arguments[0].isObject()) { + const signature = comptime getSignature("toMatchInlineSnapshot", "properties, hint", false); + return this.throw(globalThis, signature, "\n\nMatcher error: Expected properties must be an object\n", .{}); + } + + property_matchers = arguments[0]; + + if (arguments[1].isString()) { + has_expected = true; + arguments[1].toZigString(&expected_string, globalThis); + } + }, + } + + var expected = expected_string.toSlice(default_allocator); + defer expected.deinit(); + + const value: JSValue = try this.getValue(globalThis, thisValue, "toMatchInlineSnapshot", "properties, hint"); + + if (!value.isObject() and property_matchers != null) { + const signature = comptime getSignature("toMatchInlineSnapshot", "properties, hint", false); + return this.throw(globalThis, signature, "\n\nMatcher error: received values must be an object when the matcher has properties\n", .{}); + } + + if (property_matchers) |_prop_matchers| { + const prop_matchers = _prop_matchers; + + if (!value.jestDeepMatch(prop_matchers, globalThis, true)) { + // TODO: print diff with properties from propertyMatchers + const signature = comptime getSignature("toMatchInlineSnapshot", "propertyMatchers", false); + const fmt = signature ++ "\n\nExpected propertyMatchers to match properties from received object" ++ + "\n\nReceived: {any}\n"; + + var formatter = JSC.ConsoleObject.Formatter{ .globalThis = globalThis }; + return globalThis.throwPretty(fmt, .{value.toFmt(&formatter)}); + } + } + + const result: ?[]const u8 = if (has_expected) expected.byteSlice() else null; + + const update = Jest.runner.?.snapshots.update_snapshots; + var needs_write = false; + + var pretty_value: MutableString = MutableString.init(default_allocator, 0) catch unreachable; + value.jestSnapshotPrettyFormat(&pretty_value, globalThis) catch { + var formatter = JSC.ConsoleObject.Formatter{ .globalThis = globalThis }; + return globalThis.throw("Failed to pretty format value: {s}", .{value.toFmt(&formatter)}); + }; + defer pretty_value.deinit(); + + if (result) |saved_value| { + if (strings.eqlLong(pretty_value.slice(), saved_value, true)) { + Jest.runner.?.snapshots.passed += 1; + return .undefined; + } else if (update) { + needs_write = true; + } else { + Jest.runner.?.snapshots.failed += 1; + const signature = comptime getSignature("toMatchInlineSnapshot", "expected", false); + const fmt = signature ++ "\n\n{any}\n"; + const diff_format = DiffFormatter{ + .received_string = pretty_value.slice(), + .expected_string = saved_value, + .globalThis = globalThis, + }; + + return globalThis.throwPretty(fmt, .{diff_format}); + } + } else { + needs_write = true; + } + + if (needs_write) { + if (this.testScope() == null) { + const signature = comptime getSignature("toMatchSnapshot", "", true); + return this.throw(globalThis, signature, "\n\nMatcher error: Snapshot matchers cannot be used outside of a test\n", .{}); + } + + // 1. find the src loc of the snapshot + const srcloc = callFrame.getCallerSrcLoc(globalThis); + + if (srcloc.source_file_id != this.testScope().?.describe.file_id) { + const signature = comptime getSignature("toMatchSnapshot", "", true); + return this.throw(globalThis, signature, "\n\nMatcher error: Inline snapshot matchers must be called from the same file as the test\n", .{}); + } + + // 2. save to write later + try Jest.runner.?.snapshots.addInlineSnapshotToWrite(srcloc.source_file_id, .{ + .line = srcloc.line, + .col = srcloc.column, + .value = pretty_value.toOwnedSlice(), + .has_matchers = property_matchers != null, + }); + } + + return .undefined; + } pub fn toMatchSnapshot(this: *Expect, globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue { defer this.postMatch(globalThis); const thisValue = callFrame.this(); @@ -2534,6 +2664,8 @@ pub const Expect = struct { arguments[0].toZigString(&hint_string, globalThis); } else if (arguments[0].isObject()) { property_matchers = arguments[0]; + } else { + return this.throw(globalThis, "", "\n\nMatcher error: Expected first argument to be a string or object\n", .{}); } }, else => { @@ -2546,6 +2678,8 @@ pub const Expect = struct { if (arguments[1].isString()) { arguments[1].toZigString(&hint_string, globalThis); + } else { + return this.throw(globalThis, "", "\n\nMatcher error: Expected second argument to be a string\n", .{}); } }, } @@ -2574,7 +2708,14 @@ pub const Expect = struct { } } - const result = Jest.runner.?.snapshots.getOrPut(this, value, hint.slice(), globalThis) catch |err| { + var pretty_value: MutableString = MutableString.init(default_allocator, 0) catch unreachable; + value.jestSnapshotPrettyFormat(&pretty_value, globalThis) catch { + var formatter = JSC.ConsoleObject.Formatter{ .globalThis = globalThis }; + return globalThis.throw("Failed to pretty format value: {s}", .{value.toFmt(&formatter)}); + }; + defer pretty_value.deinit(); + + const existing_value = Jest.runner.?.snapshots.getOrPut(this, pretty_value.slice(), hint.slice()) catch |err| { var formatter = JSC.ConsoleObject.Formatter{ .globalThis = globalThis }; const test_file_path = Jest.runner.?.files.get(this.testScope().?.describe.file_id).source.path.text; return switch (err) { @@ -2586,14 +2727,7 @@ pub const Expect = struct { }; }; - if (result) |saved_value| { - var pretty_value: MutableString = MutableString.init(default_allocator, 0) catch unreachable; - value.jestSnapshotPrettyFormat(&pretty_value, globalThis) catch { - var formatter = JSC.ConsoleObject.Formatter{ .globalThis = globalThis }; - return globalThis.throw("Failed to pretty format value: {s}", .{value.toFmt(&formatter)}); - }; - defer pretty_value.deinit(); - + if (existing_value) |saved_value| { if (strings.eqlLong(pretty_value.slice(), saved_value, true)) { Jest.runner.?.snapshots.passed += 1; return .undefined; @@ -4201,7 +4335,6 @@ pub const Expect = struct { pub const toHaveReturnedWith = notImplementedJSCFn; pub const toHaveLastReturnedWith = notImplementedJSCFn; pub const toHaveNthReturnedWith = notImplementedJSCFn; - pub const toMatchInlineSnapshot = notImplementedJSCFn; pub const toThrowErrorMatchingSnapshot = notImplementedJSCFn; pub const toThrowErrorMatchingInlineSnapshot = notImplementedJSCFn; diff --git a/src/bun.js/test/snapshot.zig b/src/bun.js/test/snapshot.zig index 39536232a7049c..2042d360a0b50f 100644 --- a/src/bun.js/test/snapshot.zig +++ b/src/bun.js/test/snapshot.zig @@ -32,13 +32,28 @@ pub const Snapshots = struct { counts: *bun.StringHashMap(usize), _current_file: ?File = null, snapshot_dir_path: ?string = null, + inline_snapshots_to_write: *std.AutoArrayHashMap(TestRunner.File.ID, std.ArrayList(InlineSnapshotToWrite)), + + pub const InlineSnapshotToWrite = struct { + line: c_ulong, + col: c_ulong, + value: []const u8, + has_matchers: bool, + + fn lessThanFn(_: void, a: InlineSnapshotToWrite, b: InlineSnapshotToWrite) bool { + if (a.line < b.line) return true; + if (a.line > b.line) return false; + if (a.col < b.col) return true; + return false; + } + }; const File = struct { id: TestRunner.File.ID, file: std.fs.File, }; - pub fn getOrPut(this: *Snapshots, expect: *Expect, value: JSValue, hint: string, globalObject: *JSC.JSGlobalObject) !?string { + pub fn getOrPut(this: *Snapshots, expect: *Expect, target_value: []const u8, hint: string) !?string { switch (try this.getSnapshotFile(expect.testScope().?.describe.file_id)) { .result => {}, .err => |err| { @@ -81,21 +96,18 @@ pub const Snapshots = struct { } // doesn't exist. append to file bytes and add to hashmap. - var pretty_value = try MutableString.init(this.allocator, 0); - try value.jestSnapshotPrettyFormat(&pretty_value, globalObject); - - const estimated_length = "\nexports[`".len + name_with_counter.len + "`] = `".len + pretty_value.list.items.len + "`;\n".len; + const estimated_length = "\nexports[`".len + name_with_counter.len + "`] = `".len + target_value.len + "`;\n".len; try this.file_buf.ensureUnusedCapacity(estimated_length + 10); try this.file_buf.writer().print( "\nexports[`{}`] = `{}`;\n", .{ strings.formatEscapes(name_with_counter, .{ .quote_char = '`' }), - strings.formatEscapes(pretty_value.list.items, .{ .quote_char = '`' }), + strings.formatEscapes(target_value, .{ .quote_char = '`' }), }, ); this.added += 1; - try this.values.put(name_hash, pretty_value.toOwnedSlice()); + try this.values.put(name_hash, try this.allocator.dupe(u8, target_value)); return null; } @@ -196,6 +208,227 @@ pub const Snapshots = struct { } } + pub fn addInlineSnapshotToWrite(self: *Snapshots, file_id: TestRunner.File.ID, value: InlineSnapshotToWrite) !void { + const gpres = try self.inline_snapshots_to_write.getOrPut(file_id); + if (!gpres.found_existing) { + gpres.value_ptr.* = std.ArrayList(InlineSnapshotToWrite).init(self.allocator); + } + try gpres.value_ptr.append(value); + } + + const inline_snapshot_dbg = bun.Output.scoped(.inline_snapshot, false); + pub fn writeInlineSnapshots(this: *Snapshots) !bool { + var arena_backing = bun.ArenaAllocator.init(this.allocator); + defer arena_backing.deinit(); + const arena = arena_backing.allocator(); + + var success = true; + const vm = VirtualMachine.get(); + const opts = js_parser.Parser.Options.init(vm.bundler.options.jsx, .js); + + for (this.inline_snapshots_to_write.keys(), this.inline_snapshots_to_write.values()) |file_id, *ils_info| { + _ = arena_backing.reset(.retain_capacity); + + var log = bun.logger.Log.init(arena); + defer if (log.errors > 0) { + log.print(bun.Output.errorWriter()) catch {}; + success = false; + }; + + // 1. sort ils_info by row, col + std.mem.sort(InlineSnapshotToWrite, ils_info.items, {}, InlineSnapshotToWrite.lessThanFn); + + // 2. load file text + const test_file = Jest.runner.?.files.get(file_id); + const test_filename = try arena.dupeZ(u8, test_file.source.path.text); + + const fd = switch (bun.sys.open(test_filename, bun.O.RDWR, 0o644)) { + .result => |r| r, + .err => |e| { + try log.addErrorFmt(&bun.logger.Source.initEmptyFile(test_filename), .{ .start = 0 }, arena, "Failed to update inline snapshot: Failed to open file: {s}", .{e.name()}); + continue; + }, + }; + var file: File = .{ + .id = file_id, + .file = fd.asFile(), + }; + errdefer file.file.close(); + + const file_text = try file.file.readToEndAlloc(arena, std.math.maxInt(usize)); + + var source = bun.logger.Source.initPathString(test_filename, file_text); + + var result_text = std.ArrayList(u8).init(arena); + + // 3. start looping, finding bytes from line/col + + var uncommitted_segment_end: usize = 0; + var last_byte: usize = 0; + var last_line: c_ulong = 1; + var last_col: c_ulong = 1; + for (ils_info.items) |ils| { + if (ils.line == last_line and ils.col == last_col) { + try log.addErrorFmt(&source, .{ .start = @intCast(uncommitted_segment_end) }, arena, "Failed to update inline snapshot: Multiple inline snapshots for the same call are not supported", .{}); + continue; + } + + inline_snapshot_dbg("Finding byte for {}/{}", .{ ils.line, ils.col }); + const byte_offset_add = logger.Source.lineColToByteOffset(file_text[last_byte..], last_line, last_col, ils.line, ils.col) orelse { + inline_snapshot_dbg("-> Could not find byte", .{}); + try log.addErrorFmt(&source, .{ .start = @intCast(uncommitted_segment_end) }, arena, "Failed to update inline snapshot: Could not find byte for line/column: {d}/{d}", .{ ils.line, ils.col }); + continue; + }; + + // found + last_byte += byte_offset_add; + last_line = ils.line; + last_col = ils.col; + + var next_start = last_byte; + inline_snapshot_dbg("-> Found byte {}", .{next_start}); + + const final_start: i32, const final_end: i32, const needs_pre_comma: bool = blk: { + if (file_text[next_start..].len > 0) switch (file_text[next_start]) { + ' ', '.' => { + // work around off-by-1 error in `expect("§").toMatchInlineSnapshot()` + next_start += 1; + }, + else => {}, + }; + const fn_name = "toMatchInlineSnapshot"; + if (!bun.strings.startsWith(file_text[next_start..], fn_name)) { + try log.addErrorFmt(&source, .{ .start = @intCast(next_start) }, arena, "Failed to update inline snapshot: Could not find 'toMatchInlineSnapshot' here", .{}); + continue; + } + next_start += fn_name.len; + + var lexer = bun.js_lexer.Lexer.initWithoutReading(&log, source, arena); + if (next_start > 0) { + // equivalent to lexer.consumeRemainderBytes(next_start) + lexer.current += next_start - (lexer.current - lexer.end); + lexer.step(); + } + try lexer.next(); + var parser: bun.js_parser.TSXParser = undefined; + try bun.js_parser.TSXParser.init(arena, &log, &source, vm.bundler.options.define, lexer, opts, &parser); + + try parser.lexer.expect(.t_open_paren); + const after_open_paren_loc = parser.lexer.loc().start; + if (parser.lexer.token == .t_close_paren) { + // zero args + if (ils.has_matchers) { + try log.addErrorFmt(&source, parser.lexer.loc(), arena, "Failed to update inline snapshot: Snapshot has matchers and yet has no arguments", .{}); + continue; + } + const close_paren_loc = parser.lexer.loc().start; + try parser.lexer.expect(.t_close_paren); + break :blk .{ after_open_paren_loc, close_paren_loc, false }; + } + if (parser.lexer.token == .t_dot_dot_dot) { + try log.addErrorFmt(&source, parser.lexer.loc(), arena, "Failed to update inline snapshot: Spread is not allowed", .{}); + continue; + } + + const before_expr_loc = parser.lexer.loc().start; + const expr_1 = try parser.parseExpr(.comma); + const after_expr_loc = parser.lexer.loc().start; + + var is_one_arg = false; + if (parser.lexer.token == .t_comma) { + try parser.lexer.expect(.t_comma); + if (parser.lexer.token == .t_close_paren) is_one_arg = true; + } else is_one_arg = true; + const after_comma_loc = parser.lexer.loc().start; + + if (is_one_arg) { + try parser.lexer.expect(.t_close_paren); + if (ils.has_matchers) { + break :blk .{ after_expr_loc, after_comma_loc, true }; + } else { + if (expr_1.data != .e_string) { + try log.addErrorFmt(&source, expr_1.loc, arena, "Failed to update inline snapshot: Argument must be a string literal", .{}); + continue; + } + break :blk .{ before_expr_loc, after_expr_loc, false }; + } + } + + if (parser.lexer.token == .t_dot_dot_dot) { + try log.addErrorFmt(&source, parser.lexer.loc(), arena, "Failed to update inline snapshot: Spread is not allowed", .{}); + continue; + } + + const before_expr_2_loc = parser.lexer.loc().start; + const expr_2 = try parser.parseExpr(.comma); + const after_expr_2_loc = parser.lexer.loc().start; + + if (!ils.has_matchers) { + try log.addErrorFmt(&source, parser.lexer.loc(), arena, "Failed to update inline snapshot: Snapshot does not have matchers and yet has two arguments", .{}); + continue; + } + if (expr_2.data != .e_string) { + try log.addErrorFmt(&source, expr_2.loc, arena, "Failed to update inline snapshot: Argument must be a string literal", .{}); + continue; + } + + if (parser.lexer.token == .t_comma) { + try parser.lexer.expect(.t_comma); + } + if (parser.lexer.token != .t_close_paren) { + try log.addErrorFmt(&source, parser.lexer.loc(), arena, "Failed to update inline snapshot: Snapshot expects at most two arguments", .{}); + continue; + } + try parser.lexer.expect(.t_close_paren); + + break :blk .{ before_expr_2_loc, after_expr_2_loc, false }; + }; + const final_start_usize = std.math.cast(usize, final_start) orelse 0; + const final_end_usize = std.math.cast(usize, final_end) orelse 0; + inline_snapshot_dbg(" -> Found update range {}-{}", .{ final_start_usize, final_end_usize }); + + if (final_end_usize < final_start_usize or final_start_usize < uncommitted_segment_end) { + try log.addErrorFmt(&source, .{ .start = final_start }, arena, "Failed to update inline snapshot: Did not advance.", .{}); + continue; + } + + try result_text.appendSlice(file_text[uncommitted_segment_end..final_start_usize]); + uncommitted_segment_end = final_end_usize; + + if (needs_pre_comma) try result_text.appendSlice(", "); + const result_text_writer = result_text.writer(); + try result_text.appendSlice("`"); + try bun.js_printer.writePreQuotedString(ils.value, @TypeOf(result_text_writer), result_text_writer, '`', false, false, .utf8); + try result_text.appendSlice("`"); + } + + // commit the last segment + try result_text.appendSlice(file_text[uncommitted_segment_end..]); + + if (log.errors > 0) { + // skip writing the file if there were errors + continue; + } + + // 4. write out result_text to the file + file.file.seekTo(0) catch |e| { + try log.addErrorFmt(&source, .{ .start = 0 }, arena, "Failed to update inline snapshot: Seek file error: {s}", .{@errorName(e)}); + continue; + }; + + file.file.writeAll(result_text.items) catch |e| { + try log.addErrorFmt(&source, .{ .start = 0 }, arena, "Failed to update inline snapshot: Write file error: {s}", .{@errorName(e)}); + continue; + }; + if (result_text.items.len < file_text.len) { + file.file.setEndPos(result_text.items.len) catch { + @panic("Failed to update inline snapshot: File was left in an invalid state"); + }; + } + } + return success; + } + fn getSnapshotFile(this: *Snapshots, file_id: TestRunner.File.ID) !JSC.Maybe(void) { if (this._current_file == null or this._current_file.?.id != file_id) { try this.writeSnapshotFile(); diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index 405b1138800e43..a918be8adc27df 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -1200,6 +1200,7 @@ pub const TestCommand = struct { var snapshot_file_buf = std.ArrayList(u8).init(ctx.allocator); var snapshot_values = Snapshots.ValuesHashMap.init(ctx.allocator); var snapshot_counts = bun.StringHashMap(usize).init(ctx.allocator); + var inline_snapshots_to_write = std.AutoArrayHashMap(TestRunner.File.ID, std.ArrayList(Snapshots.InlineSnapshotToWrite)).init(ctx.allocator); JSC.isBunTest = true; var reporter = try ctx.allocator.create(CommandLineReporter); @@ -1220,6 +1221,7 @@ pub const TestCommand = struct { .file_buf = &snapshot_file_buf, .values = &snapshot_values, .counts = &snapshot_counts, + .inline_snapshots_to_write = &inline_snapshots_to_write, }, }, .callback = undefined, @@ -1381,6 +1383,7 @@ pub const TestCommand = struct { runAllTests(reporter, vm, test_files, ctx.allocator); } + const write_snapshots_success = try jest.Jest.runner.?.snapshots.writeInlineSnapshots(); try jest.Jest.runner.?.snapshots.writeSnapshotFile(); var coverage = ctx.test_options.coverage; @@ -1567,25 +1570,29 @@ pub const TestCommand = struct { } if (vm.hot_reload == .watch) { - vm.eventLoop().tickPossiblyForever(); - - while (true) { - while (vm.isEventLoopAlive()) { - vm.tick(); - vm.eventLoop().autoTickActive(); - } - - vm.eventLoop().tickPossiblyForever(); - } + vm.runWithAPILock(JSC.VirtualMachine, vm, runEventLoopForWatch); } - if (reporter.summary.fail > 0 or (coverage.enabled and coverage.fractions.failing and coverage.fail_on_low_coverage)) { + if (reporter.summary.fail > 0 or (coverage.enabled and coverage.fractions.failing and coverage.fail_on_low_coverage) or !write_snapshots_success) { Global.exit(1); } else if (reporter.jest.unhandled_errors_between_tests > 0) { Global.exit(reporter.jest.unhandled_errors_between_tests); } } + fn runEventLoopForWatch(vm: *JSC.VirtualMachine) void { + vm.eventLoop().tickPossiblyForever(); + + while (true) { + while (vm.isEventLoopAlive()) { + vm.tick(); + vm.eventLoop().autoTickActive(); + } + + vm.eventLoop().tickPossiblyForever(); + } + } + pub fn runAllTests( reporter_: *CommandLineReporter, vm_: *JSC.VirtualMachine, diff --git a/src/js_lexer.zig b/src/js_lexer.zig index bcb354401f3237..ca946482f3977b 100644 --- a/src/js_lexer.zig +++ b/src/js_lexer.zig @@ -830,7 +830,7 @@ fn NewLexer_( return code_point; } - fn step(lexer: *LexerType) void { + pub fn step(lexer: *LexerType) void { lexer.code_point = lexer.nextCodepoint(); // Track the approximate number of newlines in the file so we can preallocate diff --git a/src/logger.zig b/src/logger.zig index 77234eda667d78..516dd70ed84150 100644 --- a/src/logger.zig +++ b/src/logger.zig @@ -1505,6 +1505,48 @@ pub const Source = struct { .column_count = column_number, }; } + pub fn lineColToByteOffset(source_contents: []const u8, start_line: usize, start_col: usize, line: usize, col: usize) ?usize { + var iter_ = strings.CodepointIterator{ + .bytes = source_contents, + .i = 0, + }; + var iter = strings.CodepointIterator.Cursor{}; + + var line_count: usize = start_line; + var column_number: usize = start_col; + + _ = iter_.next(&iter); + while (true) { + const c = iter.c; + if (!iter_.next(&iter)) break; + switch (c) { + '\n' => { + column_number = 1; + line_count += 1; + }, + + '\r' => { + column_number = 1; + line_count += 1; + if (iter.c == '\n') { + _ = iter_.next(&iter); + } + }, + + 0x2028, 0x2029 => { + line_count += 1; + column_number = 1; + }, + else => { + column_number += 1; + }, + } + + if (line_count == line and column_number == col) return iter.i; + if (line_count > line) return null; + } + return null; + } }; pub fn rangeData(source: ?*const Source, r: Range, text: string) Data { diff --git a/test/harness.ts b/test/harness.ts index 8754c256ea17b0..6d1c6d36a49075 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -143,7 +143,7 @@ export function hideFromStackTrace(block: CallableFunction) { }); } -type DirectoryTree = { +export type DirectoryTree = { [name: string]: | string | Buffer diff --git a/test/js/bun/test/snapshot-tests/snapshots/__snapshots__/snapshot.test.ts.snap b/test/js/bun/test/snapshot-tests/snapshots/__snapshots__/snapshot.test.ts.snap index d8f002664683ee..f6c84aef6a8954 100644 --- a/test/js/bun/test/snapshot-tests/snapshots/__snapshots__/snapshot.test.ts.snap +++ b/test/js/bun/test/snapshot-tests/snapshots/__snapshots__/snapshot.test.ts.snap @@ -551,3 +551,39 @@ exports[\`t2 1\`] = \`"abc\\\`def"\`; exports[\`t3 1\`] = \`"abc def ghi"\`; " `; + +exports[`snapshots property matchers 1`] = ` +"// Bun Snapshot v1, https://goo.gl/fbAQLP + +exports[\`abc 1\`] = \` +{ + "createdAt": Any, + "id": Any, + "name": "LeBron James", +} +\`; +" +`; + +exports[`inline snapshots grow file for new snapshot 1`] = ` +" + test("abc", () => { expect("hello").toMatchInlineSnapshot(\`"hello"\`) }); + " +`; + +exports[`inline snapshots backtick in test name 1`] = `"test("\`", () => {expect("abc").toMatchInlineSnapshot(\`"abc"\`);})"`; + +exports[`inline snapshots dollars curly in test name 1`] = `"test("\${}", () => {expect("abc").toMatchInlineSnapshot(\`"abc"\`);})"`; + +exports[`inline snapshots #15283 1`] = ` +"it("Should work", () => { + expect(\`This is \\\`wrong\\\`\`).toMatchInlineSnapshot(\`"This is \\\`wrong\\\`"\`); + });" +`; + +exports[`snapshots unicode surrogate halves 1`] = ` +"// Bun Snapshot v1, https://goo.gl/fbAQLP + +exports[\`abc 1\`] = \`"😊abc\\\`\\\${def} �, � "\`; +" +`; diff --git a/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts b/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts index 8451d85370a414..abbb6cf96f1802 100644 --- a/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts +++ b/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts @@ -1,6 +1,7 @@ -import { $ } from "bun"; +import { $, spawnSync } from "bun"; +import { readFileSync, writeFileSync } from "fs"; import { describe, expect, it, test } from "bun:test"; -import { bunExe, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, DirectoryTree, tempDirWithFiles } from "harness"; function test1000000(arg1: any, arg218718132: any) {} @@ -167,15 +168,15 @@ it("should work with expect.anything()", () => { // expect({ a: 0 }).toMatchSnapshot({ a: expect.anything() }); }); -function defaultWrap(a: string): string { - return `test("abc", () => { expect(${a}).toMatchSnapshot() });`; +function defaultWrap(a: string, b: string = ""): string { + return `test("abc", () => { expect(${a}).toMatchSnapshot(${b}) });`; } class SnapshotTester { dir: string; targetSnapshotContents: string; isFirst: boolean = true; - constructor() { + constructor(public inlineSnapshot: boolean) { this.dir = tempDirWithFiles("snapshotTester", { "snapshot.test.ts": "" }); this.targetSnapshotContents = ""; } @@ -190,6 +191,11 @@ class SnapshotTester { contents: string, opts: { shouldNotError?: boolean; shouldGrow?: boolean; skipSnapshot?: boolean; forceUpdate?: boolean } = {}, ) { + if (this.inlineSnapshot) { + contents = contents.replaceAll("toMatchSnapshot()", "toMatchInlineSnapshot('bad')"); + this.targetSnapshotContents = contents; + } + const isFirst = this.isFirst; this.isFirst = false; await Bun.write(this.dir + "/snapshot.test.ts", contents); @@ -199,9 +205,7 @@ class SnapshotTester { // make sure it fails first: expect((await $`cd ${this.dir} && ${bunExe()} test ./snapshot.test.ts`.nothrow().quiet()).exitCode).not.toBe(0); // make sure the existing snapshot is unchanged: - expect(await Bun.file(this.dir + "/__snapshots__/snapshot.test.ts.snap").text()).toBe( - this.targetSnapshotContents, - ); + expect(await this.getSnapshotContents()).toBe(this.targetSnapshotContents); } // update snapshots now, using -u flag unless this is the first run await $`cd ${this.dir} && ${bunExe()} test ${isFirst && !opts.forceUpdate ? "" : "-u"} ./snapshot.test.ts`.quiet(); @@ -210,116 +214,518 @@ class SnapshotTester { if (!isFirst) { expect(newContents).not.toStartWith(this.targetSnapshotContents); } - if (!opts.skipSnapshot) expect(newContents).toMatchSnapshot(); + if (!opts.skipSnapshot && !this.inlineSnapshot) expect(newContents).toMatchSnapshot(); this.targetSnapshotContents = newContents; } // run, make sure snapshot does not change await $`cd ${this.dir} && ${bunExe()} test ./snapshot.test.ts`.quiet(); if (!opts.shouldGrow) { - expect(await Bun.file(this.dir + "/__snapshots__/snapshot.test.ts.snap").text()).toBe( - this.targetSnapshotContents, - ); + expect(await this.getSnapshotContents()).toBe(this.targetSnapshotContents); } else { this.targetSnapshotContents = await this.getSnapshotContents(); } } async setSnapshotFile(contents: string) { + if (this.inlineSnapshot) throw new Error("not allowed"); await Bun.write(this.dir + "/__snapshots__/snapshot.test.ts.snap", contents); this.isFirst = true; } + async getSrcContents(): Promise { + return await Bun.file(this.dir + "/snapshot.test.ts").text(); + } async getSnapshotContents(): Promise { + if (this.inlineSnapshot) return await this.getSrcContents(); return await Bun.file(this.dir + "/__snapshots__/snapshot.test.ts.snap").text(); } } -describe("snapshots", async () => { - const t = new SnapshotTester(); - await t.update(defaultWrap("''"), { skipSnapshot: true }); +for (const inlineSnapshot of [false, true]) { + describe(inlineSnapshot ? "inline snapshots" : "snapshots", async () => { + const t = new SnapshotTester(inlineSnapshot); + await t.update(defaultWrap("''", inlineSnapshot ? '`""`' : undefined), { skipSnapshot: true }); - t.test("dollars", defaultWrap("`\\$`")); - t.test("backslash", defaultWrap("`\\\\`")); - t.test("dollars curly", defaultWrap("`\\${}`")); - t.test("dollars curly 2", defaultWrap("`\\${`")); - t.test("stuff", defaultWrap(`\`æ™\n\r!!!!*5897yhduN\\"\\'\\\`Il\``)); - t.test("stuff 2", defaultWrap(`\`æ™\n\r!!!!*5897yh!uN\\"\\'\\\`Il\``)); + t.test("dollars", defaultWrap("`\\$`")); + t.test("backslash", defaultWrap("`\\\\`")); + t.test("dollars curly", defaultWrap("`\\${}`")); + t.test("dollars curly 2", defaultWrap("`\\${`")); + t.test("stuff", defaultWrap(`\`æ™\n\r!!!!*5897yhduN\\"\\'\\\`Il\``)); + t.test("stuff 2", defaultWrap(`\`æ™\n\r!!!!*5897yh!uN\\"\\'\\\`Il\``)); - t.test("regexp 1", defaultWrap("/${1..}/")); - t.test("regexp 2", defaultWrap("/${2..}/")); - t.test("string", defaultWrap('"abc"')); - t.test("string with newline", defaultWrap('"qwerty\\nioup"')); + t.test("regexp 1", defaultWrap("/${1..}/")); + t.test("regexp 2", defaultWrap("/${2..}/")); + t.test("string", defaultWrap('"abc"')); + t.test("string with newline", defaultWrap('"qwerty\\nioup"')); - t.test("null byte", defaultWrap('"1 \x00"')); - t.test("null byte 2", defaultWrap('"2 \\x00"')); + if (!inlineSnapshot) + // disabled for inline snapshot because of the bug in CodepointIterator; should be fixed by https://github.com/oven-sh/bun/pull/15163 + t.test("null byte", defaultWrap('"1 \x00"')); + t.test("null byte 2", defaultWrap('"2 \\x00"')); - t.test("backticks", defaultWrap("`This is \\`wrong\\``")); - t.test("unicode", defaultWrap("'😊abc`${def} " + "😊".substring(0, 1) + ", " + "😊".substring(1, 2) + " '")); + t.test("backticks", defaultWrap("`This is \\`wrong\\``")); + if (!inlineSnapshot) + // disabled for inline snapshot because reading the file will have U+FFFD in it rather than surrogate halves + t.test( + "unicode surrogate halves", + defaultWrap("'😊abc`${def} " + "😊".substring(0, 1) + ", " + "😊".substring(1, 2) + " '"), + ); - test("jest newline oddity", async () => { - await t.update(defaultWrap("'\\n'")); - await t.update(defaultWrap("'\\r'"), { shouldNotError: true }); - await t.update(defaultWrap("'\\r\\n'"), { shouldNotError: true }); - }); + if (!inlineSnapshot) + // disabled for inline snapshot because it needs to update the thing + t.test( + "property matchers", + defaultWrap( + '{createdAt: new Date(), id: Math.floor(Math.random() * 20), name: "LeBron James"}', + `{createdAt: expect.any(Date), id: expect.any(Number)}`, + ), + ); + + if (!inlineSnapshot) { + // these other ones are disabled in inline snapshots + + test("jest newline oddity", async () => { + await t.update(defaultWrap("'\\n'")); + await t.update(defaultWrap("'\\r'"), { shouldNotError: true }); + await t.update(defaultWrap("'\\r\\n'"), { shouldNotError: true }); + }); + + test("don't grow file on error", async () => { + await t.setSnapshotFile("exports[`snap 1`] = `hello`goodbye`;"); + try { + await t.update(/*js*/ ` + test("t1", () => {expect("abc def ghi jkl").toMatchSnapshot();}) + test("t2", () => {expect("abc\`def").toMatchSnapshot();}) + test("t3", () => {expect("abc def ghi").toMatchSnapshot();}) + `); + } catch (e) {} + expect(await t.getSnapshotContents()).toBe("exports[`snap 1`] = `hello`goodbye`;"); + }); + + test("replaces file that fails to parse when update flag is used", async () => { + await t.setSnapshotFile("exports[`snap 1`] = `hello`goodbye`;"); + await t.update( + /*js*/ ` + test("t1", () => {expect("abc def ghi jkl").toMatchSnapshot();}) + test("t2", () => {expect("abc\`def").toMatchSnapshot();}) + test("t3", () => {expect("abc def ghi").toMatchSnapshot();}) + `, + { forceUpdate: true }, + ); + expect(await t.getSnapshotContents()).toBe( + '// Bun Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`t1 1`] = `"abc def ghi jkl"`;\n\nexports[`t2 1`] = `"abc\\`def"`;\n\nexports[`t3 1`] = `"abc def ghi"`;\n', + ); + }); - test("don't grow file on error", async () => { - await t.setSnapshotFile("exports[`snap 1`] = `hello`goodbye`;"); - try { - await t.update(/*js*/ ` - test("t1", () => {expect("abc def ghi jkl").toMatchSnapshot();}) - test("t2", () => {expect("abc\`def").toMatchSnapshot();}) - test("t3", () => {expect("abc def ghi").toMatchSnapshot();}) - `); - } catch (e) {} - expect(await t.getSnapshotContents()).toBe("exports[`snap 1`] = `hello`goodbye`;"); + test("grow file for new snapshot", async () => { + const t4 = new SnapshotTester(inlineSnapshot); + await t4.update(/*js*/ ` + test("abc", () => { expect("hello").toMatchSnapshot() }); + `); + await t4.update( + /*js*/ ` + test("abc", () => { expect("hello").toMatchSnapshot() }); + test("def", () => { expect("goodbye").toMatchSnapshot() }); + `, + { shouldNotError: true, shouldGrow: true }, + ); + await t4.update(/*js*/ ` + test("abc", () => { expect("hello").toMatchSnapshot() }); + test("def", () => { expect("hello").toMatchSnapshot() }); + `); + await t4.update(/*js*/ ` + test("abc", () => { expect("goodbye").toMatchSnapshot() }); + test("def", () => { expect("hello").toMatchSnapshot() }); + `); + }); + + const t2 = new SnapshotTester(inlineSnapshot); + t2.test("backtick in test name", `test("\`", () => {expect("abc").toMatchSnapshot();})`); + const t3 = new SnapshotTester(inlineSnapshot); + t3.test("dollars curly in test name", `test("\${}", () => {expect("abc").toMatchSnapshot();})`); + + const t15283 = new SnapshotTester(inlineSnapshot); + t15283.test( + "#15283", + `it("Should work", () => { + expect(\`This is \\\`wrong\\\`\`).toMatchSnapshot(); + });`, + ); + t15283.test( + "#15283 unicode", + `it("Should work", () => {expect(\`😊This is \\\`wrong\\\`\`).toMatchSnapshot()});`, + ); + } }); +} - test("replaces file that fails to parse when update flag is used", async () => { - await t.setSnapshotFile("exports[`snap 1`] = `hello`goodbye`;"); - await t.update( +test("basic unchanging inline snapshot", () => { + expect("hello").toMatchInlineSnapshot('"hello"'); + expect({ v: new Date() }).toMatchInlineSnapshot( + { v: expect.any(Date) }, + ` +{ + "v": Any, +} +`, + ); +}); + +class InlineSnapshotTester { + tmpdir: string; + tmpid: number; + constructor(tmpfiles: DirectoryTree) { + this.tmpdir = tempDirWithFiles("InlineSnapshotTester", tmpfiles); + this.tmpid = 0; + } + tmpfile(content: string): string { + const filename = "_" + this.tmpid++ + ".test.ts"; + writeFileSync(this.tmpdir + "/" + filename, content); + return filename; + } + readfile(name: string): string { + return readFileSync(this.tmpdir + "/" + name, { encoding: "utf-8" }); + } + + testError(eopts: { update?: boolean; msg: string }, code: string): void { + const thefile = this.tmpfile(code); + + const spawnres = Bun.spawnSync({ + cmd: [bunExe(), "test", ...(eopts.update ? ["-u"] : []), thefile], + env: bunEnv, + cwd: this.tmpdir, + stdio: ["pipe", "pipe", "pipe"], + }); + expect(spawnres.stderr.toString()).toInclude(eopts.msg); + expect(spawnres.exitCode).not.toBe(0); + expect(this.readfile(thefile)).toEqual(code); + } + test(cb: (v: (a: string, b: string, c: string) => string) => string): void { + this.testInternal( + false, + cb((a, b, c) => a), + cb((a, b, c) => c), + ); + this.testInternal( + true, + cb((a, b, c) => b), + cb((a, b, c) => c), + ); + } + testInternal(use_update: boolean, before_value: string, after_value: string): void { + const thefile = this.tmpfile(before_value); + + if (use_update) { + // run without update, expect error + const spawnres = Bun.spawnSync({ + cmd: [bunExe(), "test", thefile], + env: bunEnv, + cwd: this.tmpdir, + stdio: ["pipe", "pipe", "pipe"], + }); + expect(spawnres.stderr.toString()).toInclude("error:"); + expect(spawnres.exitCode).not.toBe(0); + expect(this.readfile(thefile)).toEqual(before_value); + } + + { + const spawnres = Bun.spawnSync({ + cmd: [bunExe(), "test", ...(use_update ? ["-u"] : []), thefile], + env: bunEnv, + cwd: this.tmpdir, + stdio: ["pipe", "pipe", "pipe"], + }); + expect(spawnres.stderr.toString()).not.toInclude("error:"); + expect({ + exitCode: spawnres.exitCode, + content: this.readfile(thefile), + }).toEqual({ + exitCode: 0, + content: after_value, + }); + } + + // run without update, expect pass with no change + { + const spawnres = Bun.spawnSync({ + cmd: [bunExe(), "test", thefile], + env: bunEnv, + cwd: this.tmpdir, + stdio: ["pipe", "pipe", "pipe"], + }); + expect(spawnres.stderr.toString()).not.toInclude("error:"); + expect({ + exitCode: spawnres.exitCode, + content: this.readfile(thefile), + }).toEqual({ + exitCode: 0, + content: after_value, + }); + } + + // update again, expect pass with no change + { + const spawnres = Bun.spawnSync({ + cmd: [bunExe(), "test", "-u", thefile], + env: bunEnv, + cwd: this.tmpdir, + stdio: ["pipe", "pipe", "pipe"], + }); + expect(spawnres.stderr.toString()).not.toInclude("error:"); + expect({ + exitCode: spawnres.exitCode, + content: this.readfile(thefile), + }).toEqual({ + exitCode: 0, + content: after_value, + }); + } + } +} + +describe("inline snapshots", () => { + const bad = '"bad"'; + const tester = new InlineSnapshotTester({ + "helper.js": /*js*/ ` + import {expect} from "bun:test"; + export function wrongFile(value) { + expect(value).toMatchInlineSnapshot(); + } + `, + }); + test("changing inline snapshot", () => { + tester.test( + v => /*js*/ ` + test("inline snapshots", () => { + expect("1").toMatchInlineSnapshot(${v("", bad, '`"1"`')}); + expect("2").toMatchInlineSnapshot( ${v("", bad, '`"2"`')}); + expect("3").toMatchInlineSnapshot( ${v("", bad, '`"3"`')}); + }); + test("m1", () => { + expect("a").toMatchInlineSnapshot(${v("", bad, '`"a"`')}); + expect("b").toMatchInlineSnapshot(${v("", bad, '`"b"`')}); + expect("§<-1l").toMatchInlineSnapshot(${v("", bad, '`"§<-1l"`')}); + expect("𐀁").toMatchInlineSnapshot(${v("", bad, '`"𐀁"`')}); + expect( "m ") . toMatchInlineSnapshot ( ${v("", bad, '`"m "`')}) ; + expect("§§§"). toMatchInlineSnapshot(${v("", bad, '`"§§§"`')}) ; + }); + `, + ); + }); + test("inline snapshot update cases", () => { + tester.test( + v => /*js*/ ` + test("cases", () => { + expect("1").toMatchInlineSnapshot(${v("", bad, '`"1"`')}); + expect("2").toMatchInlineSnapshot( ${v("", bad, '`"2"`')}); + expect("3"). toMatchInlineSnapshot( ${v("", bad, '`"3"`')}); + expect("4") . toMatchInlineSnapshot( ${v("", bad, '`"4"`')}); + expect("5" ) . toMatchInlineSnapshot( ${v("", bad, '`"5"`')}); + expect("6" ) . toMatchInlineSnapshot ( ${v("", bad, '`"6"`')}); + expect("7" ) . toMatchInlineSnapshot ( ${v("", bad, '`"7"`')}); + expect("8" ) . toMatchInlineSnapshot ( ${v("", bad, '`"8"`')}) ; + expect("9" ) . toMatchInlineSnapshot ( \n${v("", bad, '`"9"`')}) ; + expect("10" ) .\ntoMatchInlineSnapshot ( \n${v("", bad, '`"10"`')}) ; + expect("11") + .toMatchInlineSnapshot(${v("", bad, '`"11"`')}) ; + expect("12")\r + .\r + toMatchInlineSnapshot\r + (\r + ${v("", bad, '`"12"`')})\r + ; + expect("13").toMatchInlineSnapshot(${v("", bad, '`"13"`')}); expect("14").toMatchInlineSnapshot(${v("", bad, '`"14"`')}); expect("15").toMatchInlineSnapshot(${v("", bad, '`"15"`')}); + expect({a: new Date()}).toMatchInlineSnapshot({a: expect.any(Date)}${v("", ', "bad"', ', `\n{\n "a": Any,\n}\n`')}); + expect({a: new Date()}).toMatchInlineSnapshot({a: expect.any(Date)}${v(",", ', "bad"', ', `\n{\n "a": Any,\n}\n`')}); + expect({a: new Date()}).toMatchInlineSnapshot({a: expect.any(Date)\n}${v("", ', "bad"', ', `\n{\n "a": Any,\n}\n`')}); + expect({a: new Date()}).\ntoMatchInlineSnapshot({a: expect.any(Date)\n}${v("", ', "bad"', ', `\n{\n "a": Any,\n}\n`')}); + expect({a: new Date()})\n.\ntoMatchInlineSnapshot({a: expect.any(Date)\n}${v("", ', "bad"', ', `\n{\n "a": Any,\n}\n`')}); + expect({a: new Date()})\n.\ntoMatchInlineSnapshot({a: \nexpect.any(Date)\n}${v("", ', "bad"', ', `\n{\n "a": Any,\n}\n`')}); + expect({a: new Date()})\n.\ntoMatchInlineSnapshot({a: \nexpect.any(\nDate)\n}${v("", ', "bad"', ', `\n{\n "a": Any,\n}\n`')}); + expect({a: new Date()}).toMatchInlineSnapshot( {a: expect.any(Date)} ${v("", ', "bad"', ', `\n{\n "a": Any,\n}\n`')}); + expect({a: new Date()}).toMatchInlineSnapshot( {a: expect.any(Date)} ${v(",", ', "bad"', ', `\n{\n "a": Any,\n}\n`')}); + expect("😊").toMatchInlineSnapshot(${v("", bad, '`"😊"`')}); + expect("\\r").toMatchInlineSnapshot(${v("", bad, '`\n"\n"\n`')}); + expect("\\r\\n").toMatchInlineSnapshot(${v("", bad, '`\n"\n"\n`')}); + expect("\\n").toMatchInlineSnapshot(${v("", bad, '`\n"\n"\n`')}); + }); + `, + ); + }); + it("should error trying to update outside of a test", () => { + tester.testError( + { msg: "error: Snapshot matchers cannot be used outside of a test" }, /*js*/ ` - test("t1", () => {expect("abc def ghi jkl").toMatchSnapshot();}) - test("t2", () => {expect("abc\`def").toMatchSnapshot();}) - test("t3", () => {expect("abc def ghi").toMatchSnapshot();}) + expect("1").toMatchInlineSnapshot(); `, - { forceUpdate: true }, ); - expect(await t.getSnapshotContents()).toBe( - '// Bun Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`t1 1`] = `"abc def ghi jkl"`;\n\nexports[`t2 1`] = `"abc\\`def"`;\n\nexports[`t3 1`] = `"abc def ghi"`;\n', + }); + it.skip("should pass not needing update outside of a test", () => { + // todo write the test right + tester.test( + v => /*js*/ ` + expect("1").toMatchInlineSnapshot('"1"'); + `, ); }); - - test("grow file for new snapshot", async () => { - const t4 = new SnapshotTester(); - await t4.update(/*js*/ ` - test("abc", () => { expect("hello").toMatchSnapshot() }); - `); - await t4.update( + it("should error trying to update the same line twice", () => { + tester.testError( + { msg: "error: Failed to update inline snapshot: Multiple inline snapshots for the same call are not supported" }, /*js*/ ` - test("abc", () => { expect("hello").toMatchSnapshot() }); - test("def", () => { expect("goodbye").toMatchSnapshot() }); + function oops(a) {expect(a).toMatchInlineSnapshot()} + test("whoops", () => { + oops(1); + oops(2); + }); `, - { shouldNotError: true, shouldGrow: true }, ); - await t4.update(/*js*/ ` - test("abc", () => { expect("hello").toMatchSnapshot() }); - test("def", () => { expect("hello").toMatchSnapshot() }); - `); - await t4.update(/*js*/ ` - test("abc", () => { expect("goodbye").toMatchSnapshot() }); - test("def", () => { expect("hello").toMatchSnapshot() }); - `); + + // fun trick: + // function oops(a) {expect(a).toMatchInlineSnapshot('1')} + // now do oops(1); oops(2); + // with `-u` it will toggle between '1' and '2' but won't error + // jest has the same bug so it's fine }); - const t2 = new SnapshotTester(); - t2.test("backtick in test name", `test("\`", () => {expect("abc").toMatchSnapshot();})`); - const t3 = new SnapshotTester(); - t3.test("dollars curly in test name", `test("\${}", () => {expect("abc").toMatchSnapshot();})`); - - const t15283 = new SnapshotTester(); - t15283.test( - "#15283", - `it("Should work", () => { - expect(\`This is \\\`wrong\\\`\`).toMatchSnapshot(); - });`, - ); - t15283.test("#15283 unicode", `it("Should work", () => {expect(\`😊This is \\\`wrong\\\`\`).toMatchSnapshot()});`); + // snapshot in a snapshot + it("should not allow a snapshot in a snapshot", () => { + // this is possible to support, but is not supported + tester.testError( + { msg: "error: Failed to update inline snapshot: Did not advance." }, + ((v: (a: string, b: string, c: string) => string) => /*js*/ ` + test("cases", () => { + expect({a: new Date()}).toMatchInlineSnapshot( + ( expect(2).toMatchInlineSnapshot(${v("", bad, "`2`")}) , {a: expect.any(Date)}) + ${v(",", ', "bad"', ', `\n{\n "a": Any,\n}\n`')} + ); + }); + `)((a, b, c) => a), + ); + }); + + it("requires exactly 'toMatchInlineSnapshot' 1", () => { + tester.testError( + { msg: "error: Failed to update inline snapshot: Could not find 'toMatchInlineSnapshot' here" }, + /*js*/ ` + test("cases", () => { + expect(1)["toMatchInlineSnapshot"](); + }); + `, + ); + }); + it("requires exactly 'toMatchInlineSnapshot' 2", () => { + tester.testError( + { msg: "error: Failed to update inline snapshot: Could not find 'toMatchInlineSnapshot' here" }, + /*js*/ ` + test("cases", () => { + expect(1).t\\u{6f}MatchInlineSnapshot(); + }); + `, + ); + }); + it("only replaces when the argument is a literal string 1", () => { + tester.testError( + { + update: true, + msg: "error: Failed to update inline snapshot: Argument must be a string literal", + }, + /*js*/ ` + test("cases", () => { + const value = "25"; + expect({}).toMatchInlineSnapshot(value); + }); + `, + ); + }); + it("only replaces when the argument is a literal string 2", () => { + tester.testError( + { + update: true, + msg: "error: Failed to update inline snapshot: Argument must be a string literal", + }, + /*js*/ ` + test("cases", () => { + const value = "25"; + expect({}).toMatchInlineSnapshot({}, value); + }); + `, + ); + }); + it("only replaces when the argument is a literal string 3", () => { + tester.testError( + { + update: true, + msg: "error: Failed to update inline snapshot: Argument must be a string literal", + }, + /*js*/ ` + test("cases", () => { + expect({}).toMatchInlineSnapshot({}, {}); + }); + `, + ); + }); + it("only replaces when the argument is a literal string 4", () => { + tester.testError( + { + update: true, + msg: "Matcher error: Expected properties must be an object", + }, + /*js*/ ` + test("cases", () => { + expect({}).toMatchInlineSnapshot("1", {}); + }); + `, + ); + }); + it("does not allow spread 1", () => { + tester.testError( + { + update: true, + msg: "error: Failed to update inline snapshot: Spread is not allowed", + }, + /*js*/ ` + test("cases", () => { + expect({}).toMatchInlineSnapshot(...["1"]); + }); + `, + ); + }); + it("does not allow spread 2", () => { + tester.testError( + { + update: true, + msg: "error: Failed to update inline snapshot: Spread is not allowed", + }, + /*js*/ ` + test("cases", () => { + expect({}).toMatchInlineSnapshot({}, ...["1"]); + }); + `, + ); + }); + it("limit two arguments", () => { + tester.testError( + { + update: true, + msg: "error: Failed to update inline snapshot: Snapshot expects at most two arguments", + }, + /*js*/ ` + test("cases", () => { + expect({}).toMatchInlineSnapshot({}, "1", "hello"); + }); + `, + ); + }); + it("must be in test file", () => { + tester.testError( + { + update: true, + msg: "Matcher error: Inline snapshot matchers must be called from the same file as the test", + }, + /*js*/ ` + import {wrongFile} from "./helper"; + test("cases", () => { + wrongFile("interesting"); + }); + `, + ); + }); });