Skip to content

bun test: keep the .snap file order under --update-snapshots - #42980

Closed
robobun wants to merge 1 commit into
mainfrom
robobun/b9fb194c/update-snapshots-keep-order
Closed

robobun wants to merge 1 commit into
mainfrom
robobun/b9fb194c/update-snapshots-keep-order

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #42969

Problem

  • bun test --update-snapshots rewrites the .snap file in test execution order. When the tests are not declared in the order of the file, every unchanged entry moves. The reporter saw a 2900 line diff for one changed value.
  • The cause is in src/runtime/test_runner/snapshot.rs. get_snapshot_file opens the file with O_TRUNC under --update-snapshots and never reads it. get_or_put then appends every entry to file_buf in call order.

Fix

  • Under --update-snapshots the existing file is read and parsed first. parse_file records one slot per key, in file order. When a test reaches a key that exists, get_or_put fills its slot with the new entry. A new key is appended to file_buf as before.
  • write_snapshot_file emits the header, the filled slots in file order, then the new keys. Empty slots (keys no test reached) are dropped, as the truncate did before. The file is written at offset 0 and truncated to the new length.
  • A file that does not parse is rewritten from scratch, which is what the old truncate did. A new file is still written in execution order. The summary still counts every regenerated entry as added, as the docs show.
  • Verified: test/js/bun/test/snapshot-tests/update-snapshots.test.ts (4 cases, stock bun fails 2). Also test/js/bun/test/snapshot-tests/, ci-restrictions.test.ts and the --parallel snapshot test in test/cli/test/parallel.test.ts.

Background

  • A .snap file is a CommonJS module of exports[\name N`] = `value`lines.parse_file` parses it with bun's JS parser and keys each value by the hash of its name.
  • Without --update-snapshots the runner already keeps the file as is and appends new keys. This PR gives --update-snapshots the same shape. Jest sorts keys on every write instead. Keeping file order causes no one time reorder in existing repos.
  • The open, read and write hunk (no O_TRUNC, pwrite_all at 0 plus ftruncate) is the same as in bun test: stop truncating .snap files on open, write them per file and on --bail #38974. This fix needs it because it must read the file before it writes. Whichever PR lands first, the other rebases cleanly.
Notes
  • Keys that no test reaches under --update-snapshots are still dropped. This includes a run with -t, .only or .skip. That is the old behavior and is out of scope here.
  • The Windows only seek_to(0) after the read is gone. The write is positional now, so the file offset does not matter.
  • test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts has one pre-existing failure (error snapshots) that depends on terminal colors. It fails the same way on main.
  • Self-reviewed: 3 concerns raised, 1 addressed (the summary counter change was dropped). Rejected: stacking on bun test: stop truncating .snap files on open, write them per file and on --bail #38974, because that PR has not landed and the hunk is required here. Noted: -t dropping unreached keys, pre-existing.

[human-review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/test/snapshot-tests/update-snapshots.test.ts
bun test v1.4.3 (c6b7fcb5b)

test/js/bun/test/snapshot-tests/update-snapshots.test.ts:
(pass) --update-snapshots > rewrites a file it cannot parse from scratch [310.65ms]
(pass) --update-snapshots > writes a new file in execution order [296.31ms]
36 |     using dir = tempDir("update-snapshots-order", {
37 |       "order.test.ts": orderTest,
38 |       "__snapshots__": { "order.test.ts.snap": header + alphaEntry + zetaEntry },
39 |     });
40 |     const { stderr, exitCode } = await runUpdate(String(dir));
41 |     expect(await Bun.file(`${dir}/__snapshots__/order.test.ts.snap`).text()).toBe(
                                                                                  ^
error: expect(received).toBe(expected)

  "// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
  
- exports[`suite alpha 1`] = `
- {
-   "a": 1,
- }
- `;
- 
  exports[`suite zeta 1`] = `
  {
    "z": 1,
  }
  `;
  
  exports[`suite mid 1`] = `
  {
    "m": 1,
+ }
+ `;
+ 
+ exports[`suite alpha 1`] = `
+ {
+ 
... (truncated)

release without fix: 2 FAILED
bun test v1.4.3-canary.1 (8793b6abe)

test/js/bun/test/snapshot-tests/update-snapshots.test.ts:
40 |     const { stderr, exitCode } = await runUpdate(String(dir));
41 |     expect(await Bun.file(`${dir}/__snapshots__/order.test.ts.snap`).text()).toBe(
42 |       header + alphaEntry + zetaEntry + midEntry,
43 |     );
44 |     expect(stderr).toContain("3 pass");
45 |     expect(stderr).toContain("snapshots: +3 added");
                        ^
error: expect(received).toContain(expected)

Expected to contain: "snapshots: +3 added"
Received: "\norder.test.ts:\n(pass) suite > zeta [0.21ms]\n(pass) suite > mid [0.03ms]\n(pass) suite > alpha [0.01ms]\n\n 3 pass\n 0 fail\nsnapshots: 2 passed, 1 added\n 3 expect() calls\nRan 3 tests across 1 file. [3.00ms]\n"

      at <anonymous> (/workspace/bun/test/js/bun/test/snapshot-tests/update-snapshots.test.ts:45:20)
(fail) --update-snapshots > keeps the order of the existing file and appends new keys [7.16ms]
(pass) --update-snapshots > rewrites a file it cannot parse from scratch [5.67ms]
57 |     const { stderr, exitCode } = await runUpdate(String(dir));
58 |     expect(await Bun.file(`${dir}/__snapshots__/order.test.ts.snap`).
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/test/snapshot-tests/update-snapshots.test.ts
bun test v1.4.3 (c6b7fcb5b)

test/js/bun/test/snapshot-tests/update-snapshots.test.ts:
(pass) --update-snapshots > keeps the order of the existing file and appends new keys [329.79ms]
(pass) --update-snapshots > rewrites a file it cannot parse from scratch [273.89ms]
(pass) --update-snapshots > rewrites a changed value in place and drops keys no test uses [322.62ms]
(pass) --update-snapshots > writes a new file in execution order [418.22ms]

 4 pass
 0 fail
 16 expect() calls
Ran 4 tests across 1 file. [2.47s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 744ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/8] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 242 extern-C blocks audited
[1/8] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�[0m
   �[1m�[94m= �[0m�[1mnote�[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
�[1m�[96mhelp�[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
  �[1m�[94m--> �[0msrc/install/windows-shim/Cargo.toml:41:8
   �[1m�[94m|�[0m
�[1m�[94m41�[0m �[91m- �[0mname = �[91m"bun_shim_impl"�[0m
�[1m�[94m41�[0m �[92m+ �[0mname = �[92m"bun-shim-impl"�[0m
   �[1m�[94m|�[0m
�[1m�[33mwarning�[0m: `bun_shim_impl` (manifest) generated 1 warning
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[
... (truncated)
diff hotspot
src/runtime/test_runner/snapshot.rs                | 86 +++++++++++++++------
 .../test/snapshot-tests/update-snapshots.test.ts   | 90 ++++++++++++++++++++++
 2 files changed, 152 insertions(+), 24 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/runtime/test_runner/snapshot.rs                           2     17      9
test/js/bun/test/snapshot-tests/update-snapshots.test.ts      0      2      9

root cause · written by the author bot

Under --update-snapshots, the snapshot writer opened the .snap file with truncation and never read the existing contents, so every entry was emitted in test execution order and a single changed value reordered the whole file. The fix reads and parses the existing file before writing, keeps each reached key at its original position with the regenerated value, appends keys that are new, and drops keys no test reached, then writes the buffer at offset zero and truncates to its length. A file that fails to parse is still rewritten from scratch, preserving the previous fallback behaviour.

With --update-snapshots the .snap file was opened with O_TRUNC and
every entry was written in test execution order. One changed value
reordered the whole file when the tests were not declared in file
order.

Now the existing file is read and parsed first. A key that a test
reaches keeps its position and gets the new value. New keys are
appended. Keys no test reaches are dropped. The file is written at
offset 0 and truncated to the new length. A file that does not parse
is rewritten from scratch, as before.
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 4 days. After that, they cost $0.25 per reviewed file.

Or wait 25 seconds for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 90a99bb4-a4a5-40ad-b2cf-a574e369ee2d

📥 Commits

Reviewing files that changed from the base of the PR and between b8eacea and 625cc4c.

📒 Files selected for processing (2)
  • src/runtime/test_runner/snapshot.rs
  • test/js/bun/test/snapshot-tests/update-snapshots.test.ts

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the non-update (plain bun test append) path, which shares the new pwrite_all(0) + ftruncate write: there file_buf always starts as the full old file contents (read via pread_all in get_snapshot_file), so the new buffer is never shorter than the old file and the result matches the previous sequential write_all — the removed Windows seek_to(0) was only needed for that sequential write. The splice at FILE_HEADER.len() is also safe in update mode since file_buf is reset to exactly the header before any entry is appended.

Extended reasoning...

The inline findings cover the non-atomic pwrite/ftruncate ordering, the silent parse-failure fallback, the dropped-unreached-keys behavior, and the test-file placement. Separately I traced the non-update append path through the shared write hunk in src/runtime/test_runner/snapshot.rs (write_snapshot_file, get_snapshot_file): in that mode file_buf is seeded with the entire old file via pread_all, then only grows, so pwrite_all at offset 0 followed by ftruncate to the buffer length yields the same bytes the old write_all (after the Windows seek_to(0)) produced. I also checked that the header-offset splice in update mode cannot land inside an entry, because file_buf is cleared and set to exactly FILE_HEADER before get_snapshot_value appends anything, and that duplicate keys in an old file leave one None slot that is dropped rather than emitting a stale entry.

Comment thread src/runtime/test_runner/snapshot.rs
Comment thread test/js/bun/test/snapshot-tests/update-snapshots.test.ts
Comment thread src/runtime/test_runner/snapshot.rs
Comment thread src/runtime/test_runner/snapshot.rs
@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Replied in each thread. No code change: one concern is a test file placement choice made to avoid conflicts with #38974, the other three are pre-existing behaviors of the old O_TRUNC path and are out of scope for the ordering fix.

@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #43043. It fixes the same root for #42969 (the O_TRUNC open under --update-snapshots) and also keeps the entries that this PR drops: the entries of tests that -t, .only or .skip leave out, and of a test that throws before its snapshot. It keeps the bytes of unchanged entries, so it keeps the file order too. The two tests of this PR that pin existing behavior (a new file is written in run order, a file with a syntax error is written again from the start) are in #43043. Reopen this PR if #43043 is not wanted.

@robobun robobun closed this Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bun test --update-snapshots rewrites the .snap file in execution order, reordering unchanged entries

1 participant