From bd8fe27f4e088ae17e79921c7444f1ecff96f094 Mon Sep 17 00:00:00 2001 From: pfg Date: Wed, 4 Dec 2024 18:29:04 -0800 Subject: [PATCH] implement inline snapshots & add tests --- src/bun.js/test/expect.zig | 3 + src/bun.js/test/snapshot.zig | 218 +++++++- src/cli/test_command.zig | 6 +- src/js_lexer.zig | 2 +- src/logger.zig | 42 ++ test/harness.ts | 2 +- .../__snapshots__/snapshot.test.ts.snap | 23 + .../snapshot-tests/snapshots/snapshot.test.ts | 514 +++++++++++++++--- 8 files changed, 718 insertions(+), 92 deletions(-) diff --git a/src/bun.js/test/expect.zig b/src/bun.js/test/expect.zig index c949b46489834d..a384c42aecf0dd 100644 --- a/src/bun.js/test/expect.zig +++ b/src/bun.js/test/expect.zig @@ -2507,6 +2507,9 @@ pub const Expect = struct { 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); diff --git a/src/bun.js/test/snapshot.zig b/src/bun.js/test/snapshot.zig index 738fb396d0bd54..a8d66b5f5151f5 100644 --- a/src/bun.js/test/snapshot.zig +++ b/src/bun.js/test/snapshot.zig @@ -42,6 +42,7 @@ pub const Snapshots = struct { 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; } @@ -215,36 +216,64 @@ pub const Snapshots = struct { try gpres.value_ptr.append(value); } - pub fn writeInlineSnapshots(this: *Snapshots) !void { + 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 each item // sort the array by lyn,col 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 this.allocator.dupeZ(u8, test_file.source.path.name.filename); - defer this.allocator.free(test_filename); + const test_filename = try arena.dupeZ(u8, test_file.source.path.text); - const file = switch (bun.sys.open(test_filename, bun.O.RDWR, 0o644)) { + const fd = switch (bun.sys.open(test_filename, bun.O.RDWR, 0o644)) { .result => |r| r, .err => |e| { - _ = e; - // TODO: print error - return error.WriteInlineSnapshotsFail; + 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; }, }; - const file_text = try file.asFile().readToEndAlloc(this.allocator, std.math.maxInt(usize)); - defer this.allocator.free(file_text); + 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 result_text = std.ArrayList(u8).init(this.allocator); - defer result_text.deinit(); + 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; + } // items are in order from start to end // advance and find the byte from the line/col // - make sure this works right with invalid utf-8, eg 0b11110_000 'a', 0b11110_000 0b10_000000 'a', ... @@ -257,18 +286,173 @@ pub const Snapshots = struct { // uncommitted_segment_end = this end // continue - _ = ils; - _ = &result_text; - _ = &uncommitted_segment_end; - @panic("TODO find byte & append to al"); + // RangeData + 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; + + // lexer init + // lexer seek (next_start) + + 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 - @panic("TODO write 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"); + }; + } } - @panic("TODO writeInlineSnapshots"); + return success; + + // make sure to test: + // toMatchSnapshot() + // toMatchSnapshot(a) + // toMatchSnapshot(a,) + // toMatchSnapshot(a,b) + // toMatchSnapshot(a,b,) + // toMatchSnapshot(a,b,c) + // toMatchSnapshot(() => toMatchSnapshot()) } fn getSnapshotFile(this: *Snapshots, file_id: TestRunner.File.ID) !JSC.Maybe(void) { diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index 0c89a3826f2d77..d24756dc4df474 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -1383,8 +1383,12 @@ pub const TestCommand = struct { runAllTests(reporter, vm, test_files, ctx.allocator); } + if (!try jest.Jest.runner.?.snapshots.writeInlineSnapshots()) { + Output.flush(); + Global.exit(1); + } + try jest.Jest.runner.?.snapshots.writeSnapshotFile(); - try jest.Jest.runner.?.snapshots.writeInlineSnapshots(); var coverage = ctx.test_options.coverage; if (reporter.summary.pass > 20) { 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 c2245f5722fd60..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 @@ -564,3 +564,26 @@ exports[\`abc 1\`] = \` \`; " `; + +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 a912df97b2aa5c..3b912f55482f57 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) {} @@ -191,7 +192,7 @@ class SnapshotTester { opts: { shouldNotError?: boolean; shouldGrow?: boolean; skipSnapshot?: boolean; forceUpdate?: boolean } = {}, ) { if (this.inlineSnapshot) { - contents = contents.replaceAll("toMatchSnapshot", "toMatchInlineSnapshot"); + contents = contents.replaceAll("toMatchSnapshot()", "toMatchInlineSnapshot('bad')"); this.targetSnapshotContents = contents; } @@ -213,7 +214,7 @@ 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 @@ -255,48 +256,58 @@ for (const inlineSnapshot of [false, true]) { t.test("string", defaultWrap('"abc"')); t.test("string with newline", defaultWrap('"qwerty\\nioup"')); - t.test("null byte", defaultWrap('"1 \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( - "property matchers", - defaultWrap( - '{createdAt: new Date(), id: Math.floor(Math.random() * 20), name: "LeBron James"}', - `{createdAt: expect.any(Date), id: expect.any(Number)}`, - ), - ); - - 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 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) + " '"), + ); 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();}) - `); + 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`;"); }); - if (!inlineSnapshot) 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();}) - `, + 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( @@ -304,41 +315,45 @@ for (const inlineSnapshot of [false, true]) { ); }); - 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() }); - `); - }); + 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()});`); + 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()});`, + ); + } }); } @@ -354,13 +369,368 @@ test("basic unchanging inline snapshot", () => { ); }); -test("inline snapshot twice", () => { - // function match(a: string) {expect(a).toMatchInlineSnapshot('"1"')} - // test("demo", () => { match("1"); match("2"); }) - // uh oh! with the `-u` flag: - // - the first match will pass - // - the second match will update and pass - // - tests pass! great! - // it's okay though because jest has the same bug. - // calling match() 3 times with different values or using toMatchInlineSnapshot() will show an error as expected +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*/ ` + expect("1").toMatchInlineSnapshot(); + `, + ); + }); + it.skip("should pass not needing update outside of a test", () => { + // todo write the test right + tester.test( + v => /*js*/ ` + expect("1").toMatchInlineSnapshot('"1"'); + `, + ); + }); + 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*/ ` + function oops(a) {expect(a).toMatchInlineSnapshot()} + test("whoops", () => { + oops(1); + oops(2); + }); + `, + ); + + // 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 + }); + + // 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"); + }); + `, + ); + }); }); + +// test cases: +// - all errors in writeInlineSnapshots +// - all errors in pub fn toMatchInlineSnapshot (eg different file, ...) +// - same snapshot multiple times in the same location