Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Bot
2a3c85e1d4 perf: only track snapshot names when using filter with update
Optimize normal snapshot usage by only storing snapshot names
and tracking accessed snapshots when actually needed (filter + update mode).

Changes:
- Check should_track before allocating snapshot_names hashmap entries
- Pass should_store_names flag to parseFile to avoid unnecessary allocations
- Zero overhead for normal snapshot usage (no filter)
- Only allocates tracking data when using -t with -u
2025-10-04 12:42:29 +00:00
Claude Bot
ef079eb55c feat: preserve unfiltered snapshots when using --test-name-pattern
When using --test-name-pattern (-t) with --update-snapshots (-u),
the snapshot file now only updates snapshots for tests that actually
ran, preserving snapshots for tests that were filtered out.

This prevents accidentally deleting snapshots when iterating on a
subset of tests during development.

Implementation:
- Track which snapshots are accessed during test execution
- When test filter is active with update mode, read existing snapshots
- Reconstruct snapshot file from hashmap, including both accessed
  (potentially updated) and unaccessed (preserved) snapshots
- Sort snapshots by name for deterministic output
- No behavior change for normal (non-filtered) usage

Includes regression test to verify the feature works correctly.
2025-10-04 12:03:26 +00:00
4 changed files with 211 additions and 16 deletions

View File

@@ -16,6 +16,10 @@ pub const Snapshots = struct {
_current_file: ?File = null,
snapshot_dir_path: ?string = null,
inline_snapshots_to_write: *std.AutoArrayHashMap(TestRunner.File.ID, std.ArrayList(InlineSnapshotToWrite)),
// Track which snapshot names were accessed during this run (for incremental updates with --test-name-pattern)
accessed_snapshots: *std.AutoHashMap(usize, void),
// Map from hash to snapshot name (for reconstructing file with filter)
snapshot_names: *std.AutoHashMap(usize, string),
pub const InlineSnapshotToWrite = struct {
line: c_ulong,
@@ -77,7 +81,28 @@ pub const Snapshots = struct {
bun.copy(u8, name_with_counter[name.len + 1 ..], counter_string);
const name_hash = bun.hash(name_with_counter);
// Only track snapshots when using filter with update mode
const has_test_filter = if (Jest.runner) |r| r.hasTestFilter() else false;
const should_track = this.update_snapshots and has_test_filter;
if (should_track) {
// Track that this snapshot was accessed during this test run
try this.accessed_snapshots.put(name_hash, {});
// Store the snapshot name if not already stored
if (!this.snapshot_names.contains(name_hash)) {
try this.snapshot_names.put(name_hash, try this.allocator.dupe(u8, name_with_counter));
}
}
if (this.values.get(name_hash)) |expected| {
// When updating snapshots with a filter, update the value in the hashmap
if (should_track) {
this.allocator.free(expected);
try this.values.put(name_hash, try this.allocator.dupe(u8, target_value));
return null;
}
return expected;
}
@@ -91,22 +116,26 @@ pub const Snapshots = struct {
}
}
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(target_value, .{ .quote_char = '`' }),
},
);
// When using filter with update mode, only update hashmap
// Otherwise, write to file_buf like normal
if (!should_track) {
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(target_value, .{ .quote_char = '`' }),
},
);
}
this.added += 1;
try this.values.put(name_hash, try this.allocator.dupe(u8, target_value));
return null;
}
pub fn parseFile(this: *Snapshots, file: File) !void {
pub fn parseFile(this: *Snapshots, file: File, should_store_names: bool) !void {
if (this.file_buf.items.len == 0) return;
const vm = VirtualMachine.get();
@@ -170,6 +199,11 @@ pub const Snapshots = struct {
bun.copy(u8, value_clone, value);
const name_hash = bun.hash(key);
try this.values.put(name_hash, value_clone);
// Only store snapshot names when needed (for filter+update mode)
if (should_store_names) {
const key_clone = try this.allocator.dupe(u8, key);
try this.snapshot_names.put(name_hash, key_clone);
}
}
}
}
@@ -183,9 +217,68 @@ pub const Snapshots = struct {
pub fn writeSnapshotFile(this: *Snapshots) !void {
if (this._current_file) |_file| {
var file = _file;
file.file.writeAll(this.file_buf.items) catch {
return error.FailedToWriteSnapshotFile;
};
// When using --test-name-pattern with update mode, reconstruct file from hashmap
const has_test_filter = if (Jest.runner) |r| r.hasTestFilter() else false;
const should_preserve_unaccessed = this.update_snapshots and has_test_filter;
// Only reconstruct from hashmap when preserving unaccessed snapshots
const should_reconstruct = should_preserve_unaccessed;
if (should_reconstruct) {
// Reconstruct the file from the values hashmap
var reconstruct_buf = std.ArrayList(u8).init(this.allocator);
defer reconstruct_buf.deinit();
try reconstruct_buf.appendSlice(file_header);
// Sort snapshot names for consistent output
var sorted_entries = std.ArrayList(struct { hash: usize, name: string, value: string }).init(this.allocator);
defer sorted_entries.deinit();
var name_iter = this.snapshot_names.iterator();
while (name_iter.next()) |entry| {
const hash = entry.key_ptr.*;
const name = entry.value_ptr.*;
if (this.values.get(hash)) |value| {
try sorted_entries.append(.{ .hash = hash, .name = name, .value = value });
}
}
// Sort by name for deterministic output
std.mem.sort(@TypeOf(sorted_entries.items[0]), sorted_entries.items, {}, struct {
fn lessThan(_: void, a: @TypeOf(sorted_entries.items[0]), b: @TypeOf(sorted_entries.items[0])) bool {
return std.mem.lessThan(u8, a.name, b.name);
}
}.lessThan);
for (sorted_entries.items) |entry| {
try reconstruct_buf.writer().print(
"\nexports[`{}`] = `{}`;\n",
.{
strings.formatEscapes(entry.name, .{ .quote_char = '`' }),
strings.formatEscapes(entry.value, .{ .quote_char = '`' }),
},
);
}
// Truncate and write the reconstructed file
file.file.seekTo(0) catch {
return error.FailedToWriteSnapshotFile;
};
file.file.setEndPos(0) catch {
return error.FailedToWriteSnapshotFile;
};
file.file.writeAll(reconstruct_buf.items) catch {
return error.FailedToWriteSnapshotFile;
};
} else {
// Original behavior: write file_buf directly
file.file.writeAll(this.file_buf.items) catch {
return error.FailedToWriteSnapshotFile;
};
}
file.file.close();
this.file_buf.clearAndFree();
@@ -200,6 +293,14 @@ pub const Snapshots = struct {
this.allocator.free(key.*);
}
this.counts.clearAndFree();
var name_itr = this.snapshot_names.valueIterator();
while (name_itr.next()) |name| {
this.allocator.free(name.*);
}
this.snapshot_names.clearAndFree();
this.accessed_snapshots.clearAndFree();
}
}
@@ -510,8 +611,13 @@ pub const Snapshots = struct {
remain[0] = 0;
const snapshot_file_path = snapshot_file_path_buf[0 .. snapshot_file_path_buf.len - remain.len :0];
// When using --test-name-pattern with update mode, don't truncate the file
// Instead, read existing snapshots and only update the ones that were accessed
const has_test_filter = if (Jest.runner) |r| r.hasTestFilter() else false;
const should_preserve_unaccessed = this.update_snapshots and has_test_filter;
var flags: i32 = bun.O.CREAT | bun.O.RDWR;
if (this.update_snapshots) flags |= bun.O.TRUNC;
if (this.update_snapshots and !should_preserve_unaccessed) flags |= bun.O.TRUNC;
const fd = switch (bun.sys.open(snapshot_file_path, flags, 0o644)) {
.result => |_fd| _fd,
.err => |err| return .initErr(err),
@@ -523,7 +629,7 @@ pub const Snapshots = struct {
};
errdefer file.file.close();
if (this.update_snapshots) {
if (this.update_snapshots and !should_preserve_unaccessed) {
try this.file_buf.appendSlice(file_header);
} else {
const length = try file.file.getEndPos();
@@ -540,7 +646,7 @@ pub const Snapshots = struct {
}
}
try this.parseFile(file);
try this.parseFile(file, should_preserve_unaccessed);
this._current_file = file;
}

View File

@@ -1289,6 +1289,8 @@ pub const TestCommand = struct {
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);
var accessed_snapshots = std.AutoHashMap(usize, void).init(ctx.allocator);
var snapshot_names = std.AutoHashMap(usize, string).init(ctx.allocator);
jsc.VirtualMachine.isBunTest = true;
var reporter = try ctx.allocator.create(CommandLineReporter);
@@ -1320,6 +1322,8 @@ pub const TestCommand = struct {
.values = &snapshot_values,
.counts = &snapshot_counts,
.inline_snapshots_to_write = &inline_snapshots_to_write,
.accessed_snapshots = &accessed_snapshots,
.snapshot_names = &snapshot_names,
},
.bun_test_root = .init(ctx.allocator),
},

View File

@@ -0,0 +1,78 @@
import { test, expect, describe } from "bun:test";
import { bunExe, bunEnv, tempDirWithFiles } from "harness";
import { join } from "path";
describe("snapshot filter preserves unfiltered snapshots", () => {
test("using -t with -u should preserve other snapshots", async () => {
const dir = tempDirWithFiles("snapshot-filter", {
"test.test.ts": `
import { test, expect } from "bun:test";
test("snapshot A", () => {
expect("value A").toMatchSnapshot();
});
test("snapshot B", () => {
expect("value B").toMatchSnapshot();
});
test("snapshot C", () => {
expect("value C").toMatchSnapshot();
});
`,
});
// Create initial snapshots
await Bun.spawn({
cmd: [bunExe(), "test", "test.test.ts", "--update-snapshots"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
}).exited;
const snapshotPath = join(dir, "__snapshots__", "test.test.ts.snap");
const initialSnapshot = await Bun.file(snapshotPath).text();
expect(initialSnapshot).toContain('exports[`snapshot A 1`]');
expect(initialSnapshot).toContain('exports[`snapshot B 1`]');
expect(initialSnapshot).toContain('exports[`snapshot C 1`]');
// Update test B
await Bun.write(join(dir, "test.test.ts"), `
import { test, expect } from "bun:test";
test("snapshot A", () => {
expect("value A").toMatchSnapshot();
});
test("snapshot B", () => {
expect("UPDATED value B").toMatchSnapshot();
});
test("snapshot C", () => {
expect("value C").toMatchSnapshot();
});
`);
// Update only snapshot B using filter
await Bun.spawn({
cmd: [bunExe(), "test", "test.test.ts", "-t", "snapshot B", "--update-snapshots"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
}).exited;
const updatedSnapshot = await Bun.file(snapshotPath).text();
// All three snapshots should still exist
expect(updatedSnapshot).toContain('exports[`snapshot A 1`] = `"value A"`');
expect(updatedSnapshot).toContain('exports[`snapshot B 1`] = `"UPDATED value B"`');
expect(updatedSnapshot).toContain('exports[`snapshot C 1`] = `"value C"`');
// Verify snapshot A and C were NOT updated
expect(updatedSnapshot).toContain('"value A"');
expect(updatedSnapshot).toContain('"value C"');
});
});

View File

@@ -607,3 +607,10 @@ exports[`snapshot numbering 4`] = `"snap"`;
exports[`snapshot numbering 6`] = `"hello"`;
exports[`snapshot numbering: hinted 1`] = `"hello"`;
exports[`snapshots backtick in test name 2`] = `
"// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[\`\\\` 1\`] = \`"abc"\`;
"
`;