Skip to content

node:assert: match Node 26 message handling, add Assert class and diff, drop CallTracker - #35181

Closed
robobun wants to merge 8 commits into
mainfrom
farm/aca05b55/assert-node26-surface
Closed

robobun wants to merge 8 commits into
mainfrom
farm/aca05b55/assert-node26-surface

Conversation

@robobun

@robobun robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

What

Node 26 reworked node:assert around an Assert class and a richer message argument. Bun still implemented the older surface, so four observable behaviors diverged:

  • A function passed as message was stringified (assert.strictEqual(1, 2, () => "lazy") produced AssertionError: () => "lazy"); Node evaluates it lazily on failure and uses the returned string.
  • A non-string/function/Error message (e.g. 42, or an explicit undefined) was accepted and stringified; Node rejects it with ERR_INVALID_ARG_TYPE.
  • assert.Assert and the AssertionError#diff field were absent.
  • assert.CallTracker (removed in Node 26, DEP0173 end-of-life) was still exported.

Repro

import assert from "node:assert/strict";
const first = (f) => { try { f(); return "NOTHROW"; } catch (e) { return e.name + ": " + String(e.message).split("\n")[0]; } };
console.log("message=function :", first(() => assert.strictEqual(1, 2, () => "lazy-computed-message")));
console.log("message=42       :", first(() => assert.strictEqual(1, 2, 42)));
console.log("typeof assert.Assert:", typeof assert.Assert);
try { assert.deepStrictEqual({ a: 1 }, { a: 2 }); } catch (e) { console.log("typeof err.diff:", typeof e.diff); }
console.log("typeof assert.CallTracker:", typeof assert.CallTracker);

Node v26.3.0:

message=function : AssertionError: lazy-computed-message
message=42       : TypeError: The "message" argument must be one of type string or function. Received type number (42)
typeof assert.Assert: function
typeof err.diff: string
typeof assert.CallTracker: undefined

Bun before:

message=function : AssertionError: () => "lazy-computed-message"
message=42       : AssertionError: 42
typeof assert.Assert: undefined
typeof err.diff: undefined
typeof assert.CallTracker: function

Fix

  • internal/assert/utils.ts: port Node 26's innerFail (the message argument is always the caller's trailing rest array: [string, ...formatArgs] is formatted, [Error] is thrown, [function] is called with (actual, expected) and its string result used, trailing args after an Error/function throw ERR_AMBIGUOUS_ARGUMENT, anything else throws ERR_INVALID_ARG_TYPE) and update innerOk to forward through it.
  • node/assert.ts: every comparison method now takes ...message and forwards to innerFail; add the Assert(options) constructor (validates options.diff, aliases strict methods when options.strict, and stores options behind a symbol read via getDiff(this) so destructured methods fall back to defaults); attach every method to Assert.prototype and to the module export; drop the CallTracker accessor; replace fail() with Node 26's single-argument form. Because functions defined in this builtin context do not automatically get a .prototype, one is assigned explicitly.
  • internal/assert/assertion_error.ts: record options.diff (default 'simple') on the error.
  • node/test.ts: exclude Assert instead of CallTracker from t.assert, and update the innerOk signature.

The upstream parallel tests that cover removed behavior (test-assert-calltracker-*.js, test-assert-fail-deprecation.js) are dropped to match Node 26's test tree, and test-assert.js / test-runner-assert.js are synced to their Node 26 expectations.

This subsumes #33567 (the fail() signature change is part of the same Node 26 restructure).

Verification

test/js/node/assert/assert-node26-surface.test.ts exercises lazy/typed message handling across every comparison method plus ok/assert()/fail(), the Assert class shape and diff propagation, the AssertionError#diff field, and CallTracker removal. 42/47 cases fail on the released build; all 47 pass with this change, as do the existing 360 test/js/node/assert tests and Node's test-assert*.js / test-runner-assert.js.


[review] gate passed · iteration 2 · 15 files touched

fails on main (without fix)
ASAN without fix: 43 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/node/assert/assert-node26-surface.test.ts"
bun test v1.4.0 (bf9d39778)

test/js/node/assert/assert-node26-surface.test.ts:
16 | };
17 | 
18 | describe("assert message argument", () => {
19 |   test("function message is called lazily on failure and its string result is used", () => {
20 |     const e = capture(() => assert.strictEqual(1, 2, () => "lazy-computed-message"));
21 |     expect({ code: e.code, message: e.message.split("\n")[0], generatedMessage: e.generatedMessage }).toEqual({
                                                                                                           ^
error: expect(received).toEqual(expected)

  {
    "code": "ERR_ASSERTION",
    "generatedMessage": false,
-   "message": "lazy-computed-message",
+   "message": "() => "lazy-computed-message"",
  }

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/node/assert/assert-node26-surface.test.ts:21:103)
(fail) assert message argument > function message is called lazily on failure and its string result is used [55.5
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (7cb7f5b9e)

test/js/node/assert/assert-node26-surface.test.ts:
(pass) assert message argument > function message is called lazily on failure and its string result is used [1.02ms]
(pass) assert message argument > function message receives (actual, expected) [0.08ms]
(pass) assert message argument > function message is not called when the assertion passes [0.03ms]
(pass) assert message argument > function message returning a non-string falls back to the generated message [0.21ms]
(pass) assert message argument > function message that throws falls back to the generated message [0.13ms]
(pass) assert message argument > Error message is thrown directly [0.04ms]
(pass) assert message argument > explicit non-string/function/Error message is rejected with ERR_INVALID_ARG_TYPE [0.11ms]
(pass) assert message argument > omitting message generates one instead of rejecting [0.06ms]
(pass) assert message argument > string message with trailing args is formatted printf-style [0.26ms]
(pass) assert message argument > Error message with trailing args throws ERR_AMBIGUOUS_ARGUMENT [0.04ms]
(pass) assert message argument > function message with trailing args
... (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/mechgate.xml" "test/js/node/assert/assert-node26-surface.test.ts"
bun test v1.4.0 (bf9d39778)

test/js/node/assert/assert-node26-surface.test.ts:
(pass) assert message argument > function message is called lazily on failure and its string result is used [56.33ms]
(pass) assert message argument > function message receives (actual, expected) [6.46ms]
(pass) assert message argument > function message is not called when the assertion passes [1.93ms]
(pass) assert message argument > function message returning a non-string falls back to the generated message [18.32ms]
(pass) assert message argument > function message that throws falls back to the generated message [9.66ms]
(pass) assert message argument > Error message is thrown directly [3.21ms]
(pass) assert message argument > explicit non-string/function/Error message is rejected with ERR_INVALID_ARG_TYPE [9.67ms]
(pass) assert message argument > omitting message generates one instead of rejecting [5.23ms]
(pass) assert message argument > string message with trailing args is formatted printf-style [15.
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 701ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/39] gen cpp.rs (cppbind)
[2/39] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 237 extern-C blocks audited
[3/39] gen ProcessBindingNatives.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingNatives.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingNatives.cpp
[4/39] gen JS modules (bundle-modules)
Preprocess modules (7370ms)
Bundle modules (36ms)
Postprocesss modules (221ms)
Bundle Functions (778ms)
Generate Code (34ms)

[8.45s] Bundled "src/js" for production
  2043 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[4/29] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/b
... (truncated)
diff hotspot
src/js/internal/assert/assertion_error.ts          |   3 +
 src/js/internal/assert/calltracker.ts              | 136 -----------
 src/js/internal/assert/utils.ts                    |  82 +++++--
 src/js/node/assert.ts                              | 230 ++++++++++--------
 src/js/node/test.ts                                |   8 +-
 src/jsc/bindings/ProcessBindingNatives.cpp         |   1 -
 test/js/node/assert/assert-node26-surface.test.ts  | 270 +++++++++++++++++++++
 .../test/parallel/test-assert-calltracker-calls.js | 128 ----------
 .../parallel/test-assert-calltracker-getCalls.js   |  74 ------
 .../parallel/test-assert-calltracker-report.js     |  26 --
 .../parallel/test-assert-calltracker-verify.js     |  53 ----
 .../test/parallel/test-assert-fail-deprecation.js  |  70 ------
 test/js/node/test/parallel/test-assert.js          |   7 +-
 test/js/node/test/parallel/test-runner-assert.js   |   3 +-
 test/js/node/test_runner/fixtures/01-harness.js    |   2 +-
 15 files changed, 473 insertions(+), 620 deletions(-)

gate history · 4 passed · 0 rejected · iteration 2

evidence per changed file
file                                                      reads  edits  tests
src/js/internal/assert/assertion_error.ts                     1      2      0
src/js/internal/assert/calltracker.ts                         0      0      0
src/js/internal/assert/utils.ts                               3      6      0
src/js/node/assert.ts                                        11     20      0
src/js/node/test.ts                                           2      3      0
src/jsc/bindings/ProcessBindingNatives.cpp                    1      1      0
test/js/node/assert/assert-node26-surface.test.ts             1      4      0
…/js/node/test/parallel/test-assert-calltracker-calls.js      0      0      0
…/node/test/parallel/test-assert-calltracker-getCalls.js      0      0      0
…js/node/test/parallel/test-assert-calltracker-report.js      0      0      0
…js/node/test/parallel/test-assert-calltracker-verify.js      0      0      0
…t/js/node/test/parallel/test-assert-fail-deprecation.js      1      0      0
test/js/node/test/parallel/test-assert.js                     1      1      0
test/js/node/test/parallel/test-runner-assert.js              1      1      0
test/js/node/test_runner/fixtures/01-harness.js               1      1      0

…f, drop CallTracker

Node 26 reworked the assert module around an Assert class whose prototype
carries every assertion method, a richer message argument (lazy function
messages, printf-style format args, type validation), and an AssertionError
`diff` field, while removing CallTracker (DEP0173) and the legacy
multi-argument fail (DEP0094). Bun's assert still implemented the older
surface.

- internal/assert/utils: add Node 26's innerFail (message is the caller's
  trailing rest array: string + format args, Error, lazy function, or
  ERR_INVALID_ARG_TYPE for anything else) and update innerOk to match.
- node/assert: every comparison method now takes `...message` and forwards
  it to innerFail; add the Assert(options) constructor (validates
  options.diff, stores options behind a symbol, aliases strict methods
  when options.strict) and expose it on assert and assert.strict; thread
  the per-instance diff through to AssertionError; replace the legacy fail
  with Node 26's single-argument form; remove the CallTracker accessor.
- AssertionError: record options.diff (default 'simple') on the error.
- node/test: stop excluding CallTracker from t.assert, exclude Assert,
  and update the innerOk call signature.

Sync the affected upstream tests to their Node 26 versions: drop the
CallTracker and fail-deprecation parallel tests (removed upstream), update
test-runner-assert's uncopied key list, and update the Symbol-message case
in test-assert.js to the new ERR_INVALID_ARG_TYPE expectation.
@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 6 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 95b74e39-1e63-4a0f-a12c-d4fad6fd55a5

📥 Commits

Reviewing files that changed from the base of the PR and between 47597ab and bf9d397.

📒 Files selected for processing (15)
  • src/js/internal/assert/assertion_error.ts
  • src/js/internal/assert/calltracker.ts
  • src/js/internal/assert/utils.ts
  • src/js/node/assert.ts
  • src/js/node/test.ts
  • src/jsc/bindings/ProcessBindingNatives.cpp
  • test/js/node/assert/assert-node26-surface.test.ts
  • test/js/node/test/parallel/test-assert-calltracker-calls.js
  • test/js/node/test/parallel/test-assert-calltracker-getCalls.js
  • test/js/node/test/parallel/test-assert-calltracker-report.js
  • test/js/node/test/parallel/test-assert-calltracker-verify.js
  • test/js/node/test/parallel/test-assert-fail-deprecation.js
  • test/js/node/test/parallel/test-assert.js
  • test/js/node/test/parallel/test-runner-assert.js
  • test/js/node/test_runner/fixtures/01-harness.js

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

@robobun

robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:06 PM PT - Jul 22nd, 2026

❌ @robobun, your commit bf9d397 has 1 failures in Build #78172 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35181

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

bun-35181 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. assert.fail: match Node 26 single-argument behavior #33567 - Implements the same assert.fail single-argument Node 26 behavior that this PR subsumes
  2. node compat batch: callback-throw dispatch, Assert class + native deep-equality parity, Intl gate + URL/buffer fallout, compile cache, watch kill-signal, profilers (+98 tests) #34660 - Partially overlapping: implements the Assert class with { strict, diff, skipPrototype } options and adds diff parameter to assertion_error.ts

🤖 Generated with Claude Code

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

On the duplicate flags:

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js/node/assert.ts:1013 — Removing the assert.CallTracker accessor makes src/js/internal/assert/calltracker.ts unreachable, but the file is left in the tree and src/jsc/bindings/ProcessBindingNatives.cpp:100 still lists internal/assert/calltracker in the natives table. Per REVIEW.md ("Delete dead code in the same PR that makes it dead — required scope"), the orphaned module and its natives-table entry should be removed here as well.

    Extended reasoning...

    What was left behind

    This PR deletes the lazy accessor in src/js/node/assert.ts that was the sole runtime consumer of internal/assert/calltracker:

    // removed by this PR
    Object.defineProperty(assert, "CallTracker", {
      get() {
        ...
        CallTracker = deprecate(require("internal/assert/calltracker"), ...);
        ...
      },
    });

    With that gone, two pieces of infrastructure are now dead but still present:

    1. src/js/internal/assert/calltracker.ts (3337 bytes) — the CallTracker class implementation. rg confirms no remaining require("internal/assert/calltracker") anywhere in src/, so this module is bundled into the binary but can never be loaded.
    2. src/jsc/bindings/ProcessBindingNatives.cpp:100 — the internal/assert/calltracker entry in the process.binding('natives') lookup table, which now points at a module nothing requires.

    (src/node-fallbacks/assert.js:5 also destructures/re-exports CallTracker, but that file imports from the npm assert browser-polyfill package rather than Bun's runtime, so it isn't strictly "made dead" by this change — worth a look but a weaker point.)

    Why this matters

    REVIEW.md's code-style section is explicit:

    Delete dead code in the same PR that makes it dead (required scope — name the deletions in the description): superseded implementations, helpers whose last caller you rewired, ...

    calltracker.ts is exactly "a helper whose last caller you rewired" — its only require() was the accessor this PR removes. Leaving it behind ships an unused module in the bundled builtins and leaves a stale row in the hand-maintained natives table that the next person to touch either file has to reason about.

    Step-by-step proof

    1. Before this PR, grep -rn "internal/assert/calltracker" src/js returns exactly one hit: the require() inside the CallTracker accessor in src/js/node/assert.ts.
    2. This PR's diff removes that accessor block entirely (the -var CallTracker; -Object.defineProperty(assert, "CallTracker", { ... }) hunk).
    3. After applying the PR, rg calltracker src/ yields only ProcessBindingNatives.cpp:100 — no JS-side consumer remains.
    4. ls src/js/internal/assert/calltracker.ts still succeeds: the implementation file is untouched by the diff.
    5. Therefore the module is compiled into the builtins bundle but is unreachable at runtime, and the natives-table row references an orphaned module.

    Impact

    None at runtime — nothing breaks, this is purely dead weight in the binary and a stale registration. Hence nit severity.

    Fix

    • git rm src/js/internal/assert/calltracker.ts
    • Remove the internal/assert/calltracker line from the lookup table in src/jsc/bindings/ProcessBindingNatives.cpp
    • Optionally tidy src/node-fallbacks/assert.js to stop re-exporting CallTracker from the browser polyfill
    • Mention the deletions in the PR description per the REVIEW.md rule

Comment thread src/js/internal/assert/utils.ts Outdated
Comment thread src/js/node/assert.ts
@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review points in 7fc7f28:

  • Removed src/js/internal/assert/calltracker.ts and its internal/assert/calltracker entry in ProcessBindingNatives.cpp. Left src/node-fallbacks/assert.js alone since it re-exports from the npm assert polyfill, not Bun's runtime module.
  • innerOk now uses a captured ArrayPrototypeSlice.$call(args, 1) instead of args.slice(1).
  • The message[0] ||= in internalMatch is kept as-is; it's byte-identical to Node 26's lib/assert.js and Node v26.3.0 exhibits the same falsy-message behavior (see the thread reply).

Comment thread src/js/node/assert.ts Outdated
Comment thread src/js/node/assert.ts
robobun added 2 commits July 22, 2026 20:42
Node 26 threads this into its deep-equal comparison; Bun's Bun.deepEquals-
backed implementation does not consume it yet. Accept the option but do not
default it, so the stored options object carries only what is read.
@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

CI at 7cb7f5b is green on the diff. Remaining reds are all unrelated flakes (complex-workspace.test.ts, no-orphans.test.ts, bun-upgrade.test.ts, bake/css.test.ts, webview-chrome.test.ts, test-fs-promises-file-handle-readFile.js, s3.test.ts, elysia stream.test.ts) plus test-gc-http-client-connaborted.js which is also red on main. None touch node:assert/node:test.

Ready for review.

Comment thread src/js/node/assert.ts
Comment thread src/js/node/assert.ts
Comment thread src/js/node/assert.ts
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-22, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 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.

2 participants