Skip to content

bun:test: store the value of a Temporal object in a snapshot instead of PlainDate {} - #48106

Open
robobun wants to merge 3 commits into
robobun/c1db105e/temporal-failure-messagesfrom
robobun/c1db105e/snapshot-temporal
Open

robobun wants to merge 3 commits into
robobun/c1db105e/temporal-failure-messagesfrom
robobun/c1db105e/snapshot-temporal

Conversation

@robobun

@robobun robobun commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Before merge (Notes): a yes on the stored text, #48105, the release with #44833, #46729.

Problem

Fix

  • The branch now runs for a snapshot too. The entry is Temporal.PlainDate 2020-01-02, read from the slots with no toJSON call.
  • An entry that an older Bun stored as PlainDate {} still passes, for any value. bun test -u rewrites it.
  • Self-reviewed: 25 concerns raised, 23 addressed. Not taken: a table test of every class, a WebKit fix for PlainDateTime.toJSON().
  • Verified: test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts (7 new tests, 6 fail on bun:test: print the value of a Temporal object in failure messages instead of PlainDate {} #48105). Also the snapshot suites.

Background

Downsides

  • A snapshot of a Temporal.Now value fails on each run after -u. The docs name two remedies.
  • An entry drops the own properties and the subclass name of a Temporal object. Bun.deepEquals ignores both.
  • Each native object in a snapshot costs +98 instructions, a ZonedDateTime +1,735. A stored PlainDate {} entry prints twice.
Notes

Before merge

Report and reach

import { test, expect } from "bun:test";
const day = process.env.DAY ?? "01";
test("PlainDate", () => {
  expect(Temporal.PlainDate.from("2020-01-" + day)).toMatchSnapshot();
});

What a stored PlainDate {} entry does

  • 12 entries that Bun 1.4.3 stored (the 8 classes alone, all in an object, in an array with a Map and a Set, a subclass, an own property): 12 of 12 pass for a changed value, with CI=true and with CI=false. The .snap keeps its sha256.
  • bun test -u rewrites the 12 entries once. A second -u changes 0 bytes. After that a changed value fails 12 of 12.
  • On main the 12 entries pass for a changed value.
  • 168 of 168 entries of the mixed fixture that Bun 1.4.3 wrote pass.
  • A file that this PR wrote fails on main and on Bun 1.4.3: 12 of 12.

Cost (release builds: main b1305dd, #48105, this PR)

Printed value main #48105 this PR
{ a: "x", b: [1, 2], c: { d: "y" } } 25,683 25,762 25,744
new Intl.Collator() 7,034 7,042 7,132
PlainDate 7,040 7,048 6,781
Duration 7,033 7,041 7,433
ZonedDateTime 7,055 7,063 8,790
PlainDate, stored as PlainDate {} 7,040 7,048 9,912
{ id: 1, when: PlainDate } 15,566 15,546 15,587
the same, stored with PlainDate {} 15,566 15,546 25,857
  • The five runs of one row differ by up to 3 for the single values and by up to 170 for the two objects. The rows of a failing toEqual are those of bun:test: print the value of a Temporal object in failure messages instead of PlainDate {} #48105 within that spread.
  • The call into C++ runs for each printed cell of the generic object type of the engine. A plain object, an array and a class instance are not of that type.
  • The frame of the Tag::Object instantiation of print_as stays 152 bytes. The deepest nested object that prints before the RangeError: 5,299 to 5,304 levels, against 5,302 to 5,303 on main.
  • Calls of SnapshotFormat__temporalText for 100 prints (lldb hit counts, debug build): 0 for the object literal, 100 for new Intl.Collator(), 100 for the sample of expect.objectContaining() in a failing toEqual.
  • One snapshot of 100 PlainDate values (lldb hit counts, debug builds, main against this PR): property walks 100 to 0, class-name lookups 100 to 0, calls of the new function 100.
  • malloc calls for each printed value (ASAN statistics with Malloc=1, 4,000 prints, main against this PR): PlainDate 3.2 to 4.2, Duration 3.2 to 3.0, ZonedDateTime 3.5 to 7.2, a PlainDate with a calendar 3.0 to 7.3. The same count for a plain number differs by 0.2 between the two builds.
  • did_older_bun_print calls (lldb hit counts): 0 in a run of 8 entries that match, 8 in a run where each of the 8 values changed, 8 in a run of 8 entries that Bun 1.4.3 stored.
  • bun test -u over the snapshot and printing suites of this repo (10 files, 430 tests): the .snap files are byte-identical after a build of main and after this PR.

Tests

  • snapshot.test.ts: 7 new tests in one describe. 6 fail on bun:test: print the value of a Temporal object in failure messages instead of PlainDate {} #48105 and on main. The seventh holds entries that an older Bun wrote, in a test of "vitest", and passes on both.
  • Without the like_older_bun condition, or without same_as_older_bun = false, the 3 tests of older entries fail.
  • Each snapshot that must not match is in a spawned run. bun test -u on the file changes no line of the new tests.
  • One test runs a fixture with 16 spies on toJSON and toString of the 8 classes (0 calls), an own toJSON and toString that throw, the two remedies for Temporal.Now, a class shaped like a polyfill, a serializer, and a .snap with a Jest header.
  • deep-equals-temporal.test.ts: the test that pinned PlainDate {} for a snapshot now pins the value.
  • snapshot.test.ts on the debug build with --timeout 120000: 186 pass, 1 skip, 1 fail. The one is error snapshots, which expects colour and fails on main in this container too. With FORCE_COLOR=1, as in CI, 187 pass and 1 skip. With the default timeout, three runs of main failed 11, 8 and 7 tests and three runs of this branch failed 7, 8 and 7: the colour test and tests that need more than their 5 seconds on this container's debug build.
  • Also run on the debug build: deep-equals-temporal.test.ts (126 pass), expect.test.js (464), mock-fn.test.js (848), dom-nodes.test.ts (79), dom-nodes-jest.test.js (140), diffexample.test.ts (6), bun-snapshots.test.ts, existing-snapshots.test.ts, new-snapshot.test.ts, toml.test.ts (104), toml-test-suite.test.ts (708).

Downsides in detail

  • Temporal.Now: a snapshot that holds such a value passed for any value. After -u it fails on each run. The docs name the two remedies: a property matcher on the property that holds the value, expect({ at: Temporal.Now.instant() }).toMatchSnapshot({ at: expect.any(Temporal.Instant) }), or setSystemTime().
  • A property matcher for one part of a Temporal value, { year: expect.any(Number) }, is checked and not stored, as for every object that is not plain.
  • A serializer's printer argument prints in the format of Jest. It stores "2020-01-02" for a Temporal value inside the serialized value, as on main.
  • A value from a Temporal polyfill still stores PlainDate {}. The docs point to a serializer.
  • A Temporal value with an own $$typeof of a React element stores <unknown />, as on main. Tag::get gives the JSX tag to a value of any class that has that property.

Self-review

25 concerns, 23 addressed. The two largest: split the failure messages (#48105) from the stored text (this PR), and build the port on #46729. Not taken:

A snapshot in Bun's format stored a Temporal value as its class name
alone, `PlainDate {}`, so the snapshot passed for any value.

`Formatter::print_as` now prints the text of
`SnapshotFormat__temporalText` in a snapshot too, not only in a
failure message: `Temporal.PlainDate 2020-01-02`.

An entry that an older Bun stored still passes. The replay of the
earlier text does not take the new branch, and the new print clears
`same_as_older_bun`.

Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
@robobun

robobun commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:18 AM PT - Nov 7th, 2026

❌ @robobun, your commit 1ebf2df has 39 failures in Build #138289 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 48106

That installs a local version of the PR into your bun-48106 executable, so you can run:

bun-48106 --bun

@robobun

robobun commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main (b1305dd) and on Bun 1.4.3 with this test:

import { test, expect } from "bun:test";
const day = process.env.DAY ?? "01";
test("PlainDate", () => {
  expect(Temporal.PlainDate.from("2020-01-" + day)).toMatchSnapshot();
});
  1. bun test ./a.test.js stores PlainDate {}.
  2. DAY=02 CI=true bun test ./a.test.js exits 0.

With this branch, step 1 stores Temporal.PlainDate 2020-01-01 and step 2 exits 1. An entry that Bun 1.4.3 stored as PlainDate {} still passes in step 2.

PR: #48106, stacked on #48105.

CI (build 138289): no failure of the build is in a test of this PR, and the tests of this PR pass on the lanes that ran them. 28 test files are red on main too: 27 under test/aot/, and test/bundler/compile-aot-size.test.ts. 39 more tests failed once and passed on a retry. A new run does not change the 28, so I did not start one. The first build failed one new test that looked for plain text in a message with colours, and 0b6d714 fixed that test. The PR waits for a maintainer's decision on the stored text (see "Before merge").

@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.

The test "and fails for another value, and for the string of the value"
looked for `Received: Temporal.PlainDate 2020-01-02` in the thrown
message. A run with colours puts escape codes inside that text.

@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.

…only

The test "and fails for another value, and for the string of the value"
held three inline snapshots that must not match. `bun test -u` on this
file rewrites such a snapshot, and the test fails after that.

The spawned tests of the same block already compare a value with the
entry of another value and with the entry of its string. The check of
the diff line of an object moves to one of them.

@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.

I re-reviewed the latest push and found no bugs; the last commit moves the deliberately mismatching inline snapshots into the spawned fixture, which settles the nit I left earlier. A maintainer should still look, since the stored text for a Temporal value (Temporal.PlainDate 2020-01-02 versus the quoted toJSON() string Jest and Vitest store) is a user-facing format choice the PR itself leaves open.

What was reviewed:

  • The new gate in Formatter::print_as (src/runtime/test_runner/pretty_format.rs:1088-1095): it mirrors the DOM-node branch in format (skipped when like_older_bun is set, clears same_as_older_bun otherwise), so did_older_bun_print still replays PlainDate {} and expect.rs:1355-1357 runs that replay on a mismatch.
  • Which callers now reach the branch: only failure messages (which already printed Temporal values via output_length) and Bun-format print_snapshot; Jest/Vitest formats and serializer plugins go through print_like_pretty_format, and format checks serializers before print_as.
  • print_temporal reads internal slots through SnapshotFormat__temporalText with no toJSON/toString call; the spawned fixture asserts 0 of 16 spies fire.
Extended reasoning...

The production change is five lines in src/runtime/test_runner/pretty_format.rs that widen the Temporal fast path from failure messages to Bun-format snapshots and mark such output as differing from older Bun so legacy PlainDate {} entries keep passing; the rest is ~290 lines of tests and two docs paragraphs. It touches no security-sensitive surface. The code mirrors an existing pattern and the tests cover each class, nesting, older-Bun entries, --update-snapshots, polyfills and serializers, but the snapshot text format is a user-visible decision the author explicitly says no maintainer has made, and my two earlier optional findings were closed by the author without code changes, so a human decision is still needed rather than an approval.

This branch has not been deployed

No deployments
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.

2 participants