Skip to content

Honour inspect.custom in expect diff output - #46125

Closed
robobun wants to merge 4 commits into
mainfrom
robobun/80464fcf/expect-diff-inspect-custom
Closed

robobun wants to merge 4 commits into
mainfrom
robobun/80464fcf/expect-diff-inspect-custom

Conversation

@robobun

@robobun robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

Fixes #44880

Problem

  • toEqual, toStrictEqual, toMatchObject, toHaveProperty, toHaveBeenCalledWith (and the Last / Nth variants) ignore Symbol.for("nodejs.util.inspect.custom") and Bun.inspect.custom when they print the failure diff. They walk every own property of the value. The issue's repro gives a 1.3 MB message for toEqual while toBe prints <Node> in 65 chars. A DOM node that reaches window can exhaust memory.
  • The cause is Tag::get in src/runtime/test_runner/pretty_format.rs. It classifies by JSType only and never probes the hook. The console formatter (src/jsc/ConsoleObject.rs:2135) that toBe uses does probe it.

Fix

  • Formatter::tag_of wraps Tag::get and, when FormatOptions.inspect_custom is set, probes the hook for Object, Array, Map, Set, Error, Function and Class tags. A callable hook gives the new Tag::CustomFormatted. print_custom_formatted calls the hook with (depth, options, inspect), writes a string result verbatim, formats any other result, and falls back to the default printer when the hook returns this.
  • The diff path (diff_format.rs) sets inspect_custom: true. The snapshot path (mod.rs) sets it to false, so .snap output does not change and matches Jest.
  • The hook call is shared with the console formatter through the new bun_jsc::console_object::call_custom_inspect. When both sides print alike, DiffFormatter now reports Received: serializes to the same string as toBe and Jest do.
  • Verified: test/js/bun/test/expect-inspect-custom.test.ts (20 tests, 16 fail on bun 1.4.3). Also test/js/bun/test/snapshot-tests, printing, expect.test.js, expect/, mock-fn.test.js, spyMatchers.test.ts, expect-extend*, test/cli/test/bun-test.test.ts, test/js/bun/util/inspect.test.js. No .snap file changed.

Background

  • JestPrettyFormat is a port of Jest's pretty-format. It prints both sides of a matcher diff and every snapshot. Tag is its value classification, computed once per value before the printer dispatches on it.
  • The console formatter in ConsoleObject.rs is a separate printer used by console.log, Bun.inspect and the non-diff matchers (toBe, toBeNull). It gained the inspect.custom probe in 2024. The snapshot formatter never followed.
  • Considered a probe inside the Tag::Object print arm only (about 60 lines). It runs after Tag::get classified the value by JSType, so a hooked class A extends Array or extends Error would still be walked while toBe prints <A>. The classifier-level probe covers those for the same per-value cost.
  • Considered a byte cap on the diff text and a callToJSON port. The cap lets the walk happen and throws the text away. toJSON changes snapshot-adjacent output. Both left out.

Downsides

  • A hook that prints a DOM node short form must exist. happy-dom 20 defines no inspect.custom, so a happy-dom element with no hook still prints its full graph. Users can define the hook on Node.prototype in a preload.
  • Failing diff matchers now run user code (the hook). A throwing hook surfaces as a catchable error, same as toBe today. Passing matchers never call it (tested).
  • Cost with the flag off (every toMatchSnapshot): one bool load and one tag compare per value, no allocation, no host call. Interleaved A/B of 2000 toMatchSnapshot calls on a 50-key nested object, debug build: main 60.5 s, PR 60.6 s, spread 2 s. Diff path on a hook-less 1000-key object (one extra fast_get per object): main 31.1 s, PR 31.5 s, spread 1.3 s. Debug binary text +25,688 bytes (size). No release toolchain, bloaty, valgrind or perf in this environment.
Notes

Repro message lengths (repro.test.ts from the issue), bun 1.4.3 vs this PR:

matcher before after
toBeNull 46 46
toBe 65 65
toEqual 1,337,893 68
toStrictEqual 1,497,935 121
toMatchObject 1,497,935 121
toHaveBeenCalledWith 1,497,929 115

Which values are probed: the probe runs after the JSType match and only for tags whose printer walks the value (Object, Array, Map, Set, Error, Function, Class), never for ProxyObject or a cell can_get() rejects. So TypedArray and Buffer keep the per-byte {"data": [...]} diff (Buffer.prototype has a hook), DOM wrappers stay on Tag::Private, Proxy and Date stay on the JSON arm.

Depth argument: indent already counts nesting levels, so the hook receives (8 - indent, {depth: 8}), the same pair the console formatter passes from its max_depth of 8. A nested hook sees a smaller depth than the root (tested).

Stash: tag_of stores (this, hook) on the formatter and the matching format call consumes it. If a peek site tagged a different value in between, print_custom_formatted looks the hook up again instead of calling a stale one.

Formatter grows by one bool (fits in padding) and one Option<(JSValue, JSValue)> (24 bytes). TagResult stays 2 bytes.

Pre-existing failures in this environment, reproduced on a main build too: snapshot-tests/snapshots/snapshot.test.ts "error snapshots" (expects ANSI colors, passes with FORCE_COLOR=1), the nested too deeply cases in bun-test.test.ts and pretty-format-overflow.test.ts (5 s timeouts under the debug ASAN build).

Left as is: expect.addSnapshotSerializer still throws "Not implemented". No depth or size cap on the diff printer. toJSON is still only called for FormData.

The diff matchers (toEqual, toStrictEqual, toMatchObject, toHaveProperty,
toHaveBeenCalledWith and friends) print both sides through
JestPrettyFormat, which walked every own property of a value and ignored
the nodejs.util.inspect.custom / Bun.inspect.custom hook that toBe and
console.log already honour. A value with a large reachable graph (a DOM
node) produced a multi-megabyte failure message.

JestPrettyFormat now probes the hook in its classifier, behind a
FormatOptions flag that the diff path sets and the snapshot path does
not, so .snap output does not change. The console formatter's custom
inspect call is shared through a new bun_jsc wrapper. When both sides
print alike, the diff reports 'serializes to the same string'.
@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:23 PM PT - Oct 19th, 2026

@robobun, your commit 69534fa is building: #129172

Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/runtime/test_runner/diff_format.rs Resolved
Comment thread src/runtime/test_runner/mod.rs Resolved
@robobun

robobun commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

Closing after a self-review. The formatter rewrite in #44268 (dylan-conway) deletes pretty_format.rs and sets disable_inspect_custom = true for the diff and snapshot formatters, which is also what Jest's pretty-format does. Merging this would flip toEqual failure text and then conflict with that rewrite. The happy-dom case that the reporters hit (nodes with no hook) is handled by #44066, #44833 and #44268, not by this change. The test file on this branch (test/js/bun/test/expect-inspect-custom.test.ts) stays available as a spec if honouring inspect.custom in diffs is wanted later.

@robobun robobun closed this Oct 20, 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.

expect diff matchers ignore util.inspect.custom and serialise the whole object graph on failure

1 participant