Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow indented inline snapshots #16685

Merged
merged 3 commits into from
Jan 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 203 additions & 6 deletions src/bun.js/test/expect.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2546,14 +2546,17 @@ pub const Expect = struct {
var hint = hint_string.toSlice(default_allocator);
defer hint.deinit();

const value: JSValue = try this.fnToErrStringOrUndefined(globalThis, try this.getValue(globalThis, thisValue, "toThrowErrorMatchingSnapshot", "<green>properties<r><d>, <r>hint"));
const value: JSValue = (try this.fnToErrStringOrUndefined(globalThis, try this.getValue(globalThis, thisValue, "toThrowErrorMatchingSnapshot", "<green>properties<r><d>, <r>hint"))) orelse {
const signature = comptime getSignature("toThrowErrorMatchingSnapshot", "", false);
return this.throw(globalThis, signature, "\n\n<b>Matcher error<r>: Received function did not throw\n", .{});
};

return this.snapshot(globalThis, value, null, hint.slice(), "toThrowErrorMatchingSnapshot");
}
fn fnToErrStringOrUndefined(this: *Expect, globalThis: *JSGlobalObject, value: JSValue) !JSValue {
fn fnToErrStringOrUndefined(this: *Expect, globalThis: *JSGlobalObject, value: JSValue) !?JSValue {
const err_value, _ = try this.getValueAsToThrow(globalThis, value);

var err_value_res = err_value orelse JSValue.undefined;
var err_value_res = err_value orelse return null;
if (err_value_res.isAnyError()) {
const message = try err_value_res.getTruthyComptime(globalThis, "message") orelse JSValue.undefined;
err_value_res = message;
Expand Down Expand Up @@ -2596,7 +2599,10 @@ pub const Expect = struct {

const expected_slice: ?[]const u8 = if (has_expected) expected.slice() else null;

const value: JSValue = try this.fnToErrStringOrUndefined(globalThis, try this.getValue(globalThis, thisValue, "toThrowErrorMatchingInlineSnapshot", "<green>properties<r><d>, <r>hint"));
const value: JSValue = (try this.fnToErrStringOrUndefined(globalThis, try this.getValue(globalThis, thisValue, "toThrowErrorMatchingInlineSnapshot", "<green>properties<r><d>, <r>hint"))) orelse {
const signature = comptime getSignature("toThrowErrorMatchingInlineSnapshot", "", false);
return this.throw(globalThis, signature, "\n\n<b>Matcher error<r>: Received function did not throw\n", .{});
};

return this.inlineSnapshot(globalThis, callFrame, value, null, expected_slice, "toThrowErrorMatchingInlineSnapshot");
}
Expand Down Expand Up @@ -2652,6 +2658,77 @@ pub const Expect = struct {
const value = try this.getValue(globalThis, thisValue, "toMatchInlineSnapshot", "<green>properties<r><d>, <r>hint");
return this.inlineSnapshot(globalThis, callFrame, value, property_matchers, expected_slice, "toMatchInlineSnapshot");
}
const TrimResult = struct { trimmed: []const u8, start_indent: ?[]const u8, end_indent: ?[]const u8 };
fn trimLeadingWhitespaceForInlineSnapshot(str_in: []const u8, trimmed_buf: []u8) TrimResult {
std.debug.assert(trimmed_buf.len == str_in.len);
var src = str_in;
var dst = trimmed_buf[0..];
const give_up: TrimResult = .{ .trimmed = str_in, .start_indent = null, .end_indent = null };
// if the line is all whitespace, trim fully
// the first line containing a character determines the max trim count

// read first line (should be all-whitespace)
const first_newline = std.mem.indexOf(u8, src, "\n") orelse return give_up;
for (src[0..first_newline]) |char| if (char != ' ' and char != '\t') return give_up;
src = src[first_newline + 1 ..];

// read first real line and get indent
const indent_len = for (src, 0..) |char, i| {
if (char != ' ' and char != '\t') break i;
} else src.len;
if (indent_len == 0) return give_up; // no indent to trim; save time
const indent_str = src[0..indent_len];
// we're committed now
dst[0] = '\n';
dst = dst[1..];
src = src[indent_len..];
const second_newline = (std.mem.indexOf(u8, src, "\n") orelse return give_up) + 1;
@memcpy(dst[0..second_newline], src[0..second_newline]);
src = src[second_newline..];
dst = dst[second_newline..];

while (src.len > 0) {
// try read indent
const max_indent_len = @min(src.len, indent_len);
const line_indent_len = for (src[0..max_indent_len], 0..) |char, i| {
if (char != ' ' and char != '\t') break i;
} else max_indent_len;
src = src[line_indent_len..];

if (line_indent_len < max_indent_len) {
if (src.len == 0) {
// perfect; done
break;
}
if (src[0] == '\n') {
// this line has less indentation than the first line, but it's empty so that's okay.
dst[0] = '\n';
src = src[1..];
dst = dst[1..];
continue;
}
// this line had less indentation than the first line, but wasn't empty. give up.
return give_up;
} else {
// this line has the same or more indentation than the first line. copy it.
const line_newline = (std.mem.indexOf(u8, src, "\n") orelse {
// this is the last line. if it's not all whitespace, give up
for (src) |char| {
if (char != ' ' and char != '\t') return give_up;
}
break;
}) + 1;
@memcpy(dst[0..line_newline], src[0..line_newline]);
src = src[line_newline..];
dst = dst[line_newline..];
}
}
const end_indent = if (std.mem.lastIndexOfScalar(u8, str_in, '\n')) |c| c + 1 else return give_up; // there has to have been at least a single newline to get here
for (str_in[end_indent..]) |c| if (c != ' ' and c != 't') return give_up; // we already checked, but the last line is not all whitespace again

// done
return .{ .trimmed = trimmed_buf[0 .. trimmed_buf.len - dst.len], .start_indent = indent_str, .end_indent = str_in[end_indent..] };
}
fn inlineSnapshot(
this: *Expect,
globalThis: *JSGlobalObject,
Expand All @@ -2674,20 +2751,28 @@ pub const Expect = struct {
defer pretty_value.deinit();
try this.matchAndFmtSnapshot(globalThis, value, property_matchers, &pretty_value, fn_name);

var start_indent: ?[]const u8 = null;
var end_indent: ?[]const u8 = null;
if (result) |saved_value| {
if (strings.eqlLong(pretty_value.slice(), saved_value, true)) {
const buf = try Jest.runner.?.snapshots.allocator.alloc(u8, saved_value.len);
defer Jest.runner.?.snapshots.allocator.free(buf);
const trim_res = trimLeadingWhitespaceForInlineSnapshot(saved_value, buf);

if (strings.eqlLong(pretty_value.slice(), trim_res.trimmed, true)) {
Jest.runner.?.snapshots.passed += 1;
return .undefined;
} else if (update) {
Jest.runner.?.snapshots.passed += 1;
needs_write = true;
start_indent = trim_res.start_indent;
end_indent = trim_res.end_indent;
} else {
Jest.runner.?.snapshots.failed += 1;
const signature = comptime getSignature(fn_name, "<green>expected<r>", false);
const fmt = signature ++ "\n\n{any}\n";
const diff_format = DiffFormatter{
.received_string = pretty_value.slice(),
.expected_string = saved_value,
.expected_string = trim_res.trimmed,
.globalThis = globalThis,
};

Expand Down Expand Up @@ -2733,6 +2818,8 @@ pub const Expect = struct {
.has_matchers = property_matchers != null,
.is_added = result == null,
.kind = fn_name,
.start_indent = if (start_indent) |ind| try Jest.runner.?.snapshots.allocator.dupe(u8, ind) else null,
.end_indent = if (end_indent) |ind| try Jest.runner.?.snapshots.allocator.dupe(u8, ind) else null,
});
}

Expand Down Expand Up @@ -5542,3 +5629,113 @@ fn incrementExpectCallCounter() void {
}

const assert = bun.assert;

fn testTrimLeadingWhitespaceForSnapshot(src: []const u8, expected: []const u8) !void {
const cpy = try std.testing.allocator.alloc(u8, src.len);
defer std.testing.allocator.free(cpy);

const res = Expect.trimLeadingWhitespaceForInlineSnapshot(src, cpy);
sanityCheck(src, res);

try std.testing.expectEqualStrings(expected, res.trimmed);
}
fn sanityCheck(input: []const u8, res: Expect.TrimResult) void {
// sanity check: output has same number of lines & all input lines endWith output lines
var input_iter = std.mem.splitScalar(u8, input, '\n');
var output_iter = std.mem.splitScalar(u8, res.trimmed, '\n');
while (true) {
const next_input = input_iter.next();
const next_output = output_iter.next();
if (next_input == null) {
std.debug.assert(next_output == null);
break;
}
std.debug.assert(next_output != null);
std.debug.assert(std.mem.endsWith(u8, next_input.?, next_output.?));
}
}
fn testOne(input: []const u8) anyerror!void {
const cpy = try std.testing.allocator.alloc(u8, input.len);
defer std.testing.allocator.free(cpy);
const res = Expect.trimLeadingWhitespaceForInlineSnapshot(input, cpy);
sanityCheck(input, res);
}

test "Expect.trimLeadingWhitespaceForInlineSnapshot" {
try testTrimLeadingWhitespaceForSnapshot(
\\
\\Hello, world!
\\
,
\\
\\Hello, world!
\\
);
try testTrimLeadingWhitespaceForSnapshot(
\\
\\ Hello, world!
\\
,
\\
\\Hello, world!
\\
);
try testTrimLeadingWhitespaceForSnapshot(
\\
\\ Object{
\\ key: value
\\ }
\\
,
\\
\\Object{
\\ key: value
\\}
\\
);
try testTrimLeadingWhitespaceForSnapshot(
\\
\\ Object{
\\ key: value
\\
\\ }
\\
,
\\
\\Object{
\\key: value
\\
\\}
\\
);
try testTrimLeadingWhitespaceForSnapshot(
\\
\\ Object{
\\ key: value
\\ }
\\
,
\\
\\ Object{
\\ key: value
\\ }
\\
);
try testTrimLeadingWhitespaceForSnapshot(
\\
\\ "æ™
\\
\\ !!!!*5897yhduN"'\`Il"
\\
,
\\
\\"æ™
\\
\\!!!!*5897yhduN"'\`Il"
\\
);
}

test "fuzz Expect.trimLeadingWhitespaceForInlineSnapshot" {
try std.testing.fuzz(testOne, .{});
}
45 changes: 43 additions & 2 deletions src/bun.js/test/snapshot.zig
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ pub const Snapshots = struct {
has_matchers: bool,
is_added: bool,
kind: []const u8, // static lifetime
start_indent: ?[]const u8, // owned by Snapshots.allocator
end_indent: ?[]const u8, // owned by Snapshots.allocator

fn lessThanFn(_: void, a: InlineSnapshotToWrite, b: InlineSnapshotToWrite) bool {
if (a.line < b.line) return true;
Expand Down Expand Up @@ -298,7 +300,7 @@ pub const Snapshots = struct {
};
const fn_name = ils.kind;
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", .{});
try log.addErrorFmt(&source, .{ .start = @intCast(next_start) }, arena, "Failed to update inline snapshot: Could not find '{s}' here", .{fn_name});
continue;
}
next_start += fn_name.len;
Expand Down Expand Up @@ -395,10 +397,49 @@ pub const Snapshots = struct {
try result_text.appendSlice(file_text[uncommitted_segment_end..final_start_usize]);
uncommitted_segment_end = final_end_usize;

// preserve existing indentation level, otherwise indent the same as the start position plus two spaces
var needs_more_spaces = false;
const start_indent = ils.start_indent orelse D: {
const source_until_final_start = source.contents[0..final_start_usize];
const line_start = if (std.mem.lastIndexOfScalar(u8, source_until_final_start, '\n')) |newline_loc| newline_loc + 1 else 0;
const indent_count = for (source_until_final_start[line_start..], 0..) |char, j| {
if (char != ' ' and char != '\t') break j;
} else source_until_final_start[line_start..].len;
needs_more_spaces = true;
break :D source_until_final_start[line_start..][0..indent_count];
};

var re_indented_string = std.ArrayList(u8).init(arena);
defer re_indented_string.deinit();
const re_indented = if (ils.value.len > 0 and ils.value[0] == '\n') blk: {
// append starting newline
try re_indented_string.appendSlice("\n");
var re_indented_source = ils.value[1..];
while (re_indented_source.len > 0) {
const next_newline = if (std.mem.indexOfScalar(u8, re_indented_source, '\n')) |a| a + 1 else re_indented_source.len;
const segment = re_indented_source[0..next_newline];
if (segment.len == 0) {
// last line; loop already exited
unreachable;
} else if (bun.strings.eqlComptime(segment, "\n")) {
// zero length line. no indent.
} else {
// regular line. indent.
try re_indented_string.appendSlice(start_indent);
if (needs_more_spaces) try re_indented_string.appendSlice(" ");
}
try re_indented_string.appendSlice(segment);
re_indented_source = re_indented_source[next_newline..];
}
// indent before backtick
try re_indented_string.appendSlice(ils.end_indent orelse start_indent);
break :blk re_indented_string.items;
} else ils.value;

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 bun.js_printer.writePreQuotedString(re_indented, @TypeOf(result_text_writer), result_text_writer, '`', false, false, .utf8);
try result_text.appendSlice("`");

if (ils.is_added) Jest.runner.?.snapshots.added += 1;
Expand Down
6 changes: 5 additions & 1 deletion src/js_printer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,11 @@ pub fn writePreQuotedString(text_in: []const u8, comptime Writer: type, writer:
},

'\t' => {
try writer.writeAll("\\t");
if (quote_char == '`') {
try writer.writeAll("\t");
} else {
try writer.writeAll("\\t");
}
i += 1;
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -607,3 +607,19 @@ exports[`snapshot numbering 4`] = `"snap"`;
exports[`snapshot numbering 6`] = `"hello"`;

exports[`snapshot numbering: hinted 1`] = `"hello"`;

exports[`indented inline snapshots 4`] = `
"\x1B[2mexpect(\x1B[0m\x1B[31mreceived\x1B[0m\x1B[2m).\x1B[0mtoMatchInlineSnapshot\x1B[2m(\x1B[0m\x1B[32mexpected\x1B[0m\x1B[2m)\x1B[0m

Expected: \x1B[2m
\x1B[0m\x1B[32m \x1B[0m\x1B[2m{
\x1B[0m\x1B[32m \x1B[0m\x1B[2m"a": 2,
\x1B[0m\x1B[32m \x1B[0m\x1B[2m}
\x1B[0m
Received: \x1B[2m
\x1B[0m\x1B[2m{
\x1B[0m\x1B[2m"a": 2,
\x1B[0m\x1B[2m}
\x1B[0m
"
`;
Loading
Loading