Skip to content

bun test: require() returns the ES module mock.module() patched - #47819

Open
dylan-conway wants to merge 3 commits into
mainfrom
claude/bun-module-mock-regression-b1vdq7
Open

dylan-conway wants to merge 3 commits into
mainfrom
claude/bun-module-mock-regression-b1vdq7

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

require() of an ES module that mock.module() patched in place returns that module again, like import() and like 1.4.4. Since #44833 it returned the factory's object, so exports that a partial mock leaves out were undefined. No release is affected.

// dep.ts: export const a = "real-a"; export const b = "real-b"; export function c() { return "real-c"; }
import { mock } from "bun:test";
import * as dep from "./dep.ts";
mock.module("./dep.ts", () => ({ a: "mocked-a" }));

(await import("./dep.ts")).b; // "real-b"
require("./dep.ts").b; // undefined. "real-b" in 1.4.4
require("./dep.ts") === dep; // false. true in 1.4.4

How did you verify your code works?

  • The 12 new tests under "require() of an ES module that mock.module() has patched in place" in mock-module.test.ts fail on a debug build of main (6cdb383) and pass here, with no flag, --isolate and --isolate=modules. Whole file: 319 pass, 1 todo.
  • USE_SYSTEM_BUN=1 is no baseline for them. The system Bun is 1.4.4, which predates the regression, so 2 of the 12 pass there.
  • Removing any one condition of the fix fails at least one of them.
  • CI: I compared build #137140 (88bfd23) with main's #137093, which has the same base commit. Of the 40 test files that fail on main, none has fewer passing tests here. Two test/aot files have more: 28 to 34 of 54, and 1 to 4 of 6.
  • --aot itself answers the same on both builds. For bun:jsc that is the factory's object, so 12 of 81 orders now differ from plain bun test (0 on main).

Generated by Claude Code

…s that module again

mock.module() of an ES module that is already loaded patches it in place, so the exports the factory leaves out keep their values.
Since #44833, require() of such a module returned the factory's object, which lacks those exports and is not what import() returns.
require() now takes the module from the ES module registry, as it did in 1.4.4.

A module that is loaded as the mock, jest.mock(), jest.doMock() and vi.mock() still give require() the factory's object.
@robobun

robobun commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 12:02 AM PT - Nov 3rd, 2026

❌ @claude, your commit 55289e3 has 40 failures in Build #137153 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47819

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

bun-47819 --bun

@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Walkthrough

The loader detects when mock.module() patched an evaluated ES module in place. In that case, require() preserves the existing namespace. Other mock results use CommonJS export selection. Tests cover both result paths and related module states.

Changes

Mocked ESM require handling

Layer / File(s) Summary
Detect patched modules and resolve require results
src/jsc/bindings/BunPlugin.h, src/jsc/bindings/BunPlugin.cpp, src/jsc/bindings/ModuleLoader.cpp
A new helper checks whether the mock’s patched namespace matches the evaluated module record in the loader. The loader uses this result to preserve the namespace or use the mock result as CommonJS exports.
Verify namespace and factory results
test/js/bun/test/mock/mock-module.test.ts
Tests cover in-place patches, retained exports, preload and async factory cases, and scenarios where require() returns a factory object or its default export.

Possibly related PRs

  • oven-sh/bun#46600 — This PR and #46600 both use the loader registry entry and patched namespace state. #47819 checks that state when require() resolves a mocked module.

Suggested reviewers: jarred-sumner, alii, robobun

Merge Risk: Minimal · up to 55289

No actionable merge-blocking risk is established for the mocked-module require behavior. Merge readiness remains subject to normal build and test checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly identifies the main change: require() now returns an ES module patched by mock.module().
Description check Passed The description includes both required sections. It explains the change, cause, scope, limitations, conflicts, and verification results.
Linked Issues check Passed The only directly linked issue is #9874. The issue is closed and supplies historical context only. No active directly linked issue supplies coding requirements for this pull request.
Out of Scope Changes check Passed The changes stay within the pull request scope. The BunPlugin changes identify an ES module patched in place by mock.module(). ModuleLoader uses that result so require() can return the registered name…
Full details: Out of Scope Changes check

Explanation

The changes stay within the pull request scope. The BunPlugin changes identify an ES module patched in place by mock.module(). ModuleLoader uses that result so require() can return the registered namespace while preserving the existing mock-object behavior for other cases. The added tests cover the changed behavior and the documented unchanged cases, including pending patches, failed evaluation, require.cache, Bun.ModuleGraph, and Jest or Vitest mocks. No unrelated change is established.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Some tools did not complete. Review the errors below.

ast-grep (0.51.1)

ast-grep could not parse rule config: /ast-grep-rules/ast-grep-essentials/rules/html/security/malformed-erb-end-modifier-html.yml

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.
Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 88bfd23 and 55289e3.

Files selected for processing: 2
  • src/jsc/bindings/BunPlugin.cpp
  • test/js/bun/test/mock/mock-module.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.

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

  • Fix comments: Fix CodeRabbit comments on this PR

Comment thread src/jsc/bindings/ModuleLoader.cpp Resolved

@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 new early return jsNumber(-1) in handleVirtualModuleResult against the exception-scope rules: isESModulePatchedByMockModule only does downcasts and a registry lookup (no JS entry), the preceding moduleLoaderOf has its RETURN_IF_EXCEPTION, and leaving res->success unset matches the existing hasAlreadyLoadedESMVersionSoWeShouldntTranspileItTwice -1 path. The { __esModule: true, default } interop shape and the per-call Identifier::fromString (same as the neighboring helper) were also examined and ruled out.

Extended reasoning...

The change adds a mock-state predicate in src/jsc/bindings/BunPlugin.cpp and a single early-return branch in handleVirtualModuleResult in src/jsc/bindings/ModuleLoader.cpp so require() of a mock.module()-patched ES module returns the registry namespace, plus new fixture tests; it touches no security-sensitive surface. The inline findings already flag behavior gaps (re-mock in beforeEach, node: builtins, --aot divergence), so this note only records what else was examined and ruled out.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/jsc/bindings/ModuleLoader.cpp — pre-existing: tests that partially mock a node: builtin still get the factory's object from require(), missing every export the factory left out, while bun: builtins and files now get the patched namespace. require("node:fs") goes through $requireNativeModule, which reaches the mock branch of fetchBuiltinModuleWithoutResolution at src/jsc/bindings/ModuleLoader.cpp:674, and that branch never consults isESModulePatchedByMockModule. Fix: apply the same patched-namespace rule on every require() route to a mocked ES module, including the node: builtin route, so require() of a module mock.module() patched in place yields the namespace there too. The PR text names this route as not touched. [also at: src/jsc/bindings/ModuleLoader.cpp:532 - pre-existing: after merging, require("bun:jsc") of a module mock.module() patched returns its namespace, but require("node:fs") patched the same way still returns the factory's object, so exports a partial mock leaves out stay undefined and require !== import.]

    Why this was flagged

    A test file does import * as fs from "node:fs", then mock.module("node:fs", () => ({ readFileSync: fake })), then require("node:fs"). src/js/builtins/CommonJS.ts:30-44 sends any id starting with node: to $requireNativeModule, never to fetchCommonJSModule, so requireVirtualModule and the new check in handleVirtualModuleResult (src/jsc/bindings/ModuleLoader.cpp:527-533) are not reached. jsFunctionRequireNativeModule (src/jsc/bindings/JSCommonJSModule.cpp:1566) calls fetchBuiltinModuleWithoutResolution, whose mock branch at src/jsc/bindings/ModuleLoader.cpp:666-676 returns commonJSExportsOfObjectModule(result, true), i.e. the factory's object. So require("node:fs").existsSync is undefined and require("node:fs") !== fs, exactly as on the base branch, whereas after this PR require("bun:jsc") and require("./dep") return the patched namespace. The patched namespace exists for node: builtins too: patchedNamespace is set at src/jsc/bindings/BunPlugin.cpp:2128 whenever the loader holds a namespace object for the key. The PR description lists node: builtins as not touched.

    Verification: pre-existing; acknowledged in diff: the PR description lists node: builtins as not touched. After mock.module("node:fs", () => ({ readFileSync: fake })), require("node:fs") goes via src/js/builtins/CommonJS.ts:30-44 to $requireNativeModule, never reaching the new check at src/jsc/bindings/ModuleLoader.cpp:527-533; the mock branch at ModuleLoader.cpp:666-676 returns the factory's object, as on the base.

Comment thread src/jsc/bindings/ModuleLoader.cpp Resolved
…xport

A factory's object marked __esModule whose default is the object itself was taken for one with no default.
So require() of an ES module that mock.module() patched returned the namespace object, not that default as on main and in 1.4.4.
commonJSExportsOfObjectModule() now returns empty when the object names no module.exports, and its two callers choose what require() gets then.

dylan-conway commented Nov 3, 2026 •

Copy link
Copy Markdown
Member Author

buildkite/bun is red, and none of it comes from this PR. There are two separate causes.

Edited: an earlier version said #47605 fixes cause 1 and that no PR existed for cause 2. Both were wrong, see below.

1. 40 test files that also fail on main

They are 39 files under test/aot/, and test/bundler/compile-aot-size.test.ts. Builds #137134 (9990f26), #137140 (88bfd23) and #137153 (55289e3, the current head) had exactly these 40.

Build Commit Failing test files
#137093 6cdb383, this PR's base the same 40
#137130 01ec085 the same 40
#137005 a4c3a5e the same 40
  • test/js/bun/test/mock/mock-module.test.ts is not among the failing or flaky tests of any of them.
  • #137153 ran to the end on every platform. Its 4 macOS x64 test jobs first expired without an agent and were rescheduled. Their only failure is test/aot/engine/native-6.test.ts, one of the 40.
  • None of the 40 has fewer passing tests in #137140 than in #137093. Two have more: 28 to 34 of 54, and 1 to 4 of 6.
  • AOT: hangs, wrong answers, failed builds; bun test --aot follows #44833's mocks #47605 is the open follow-up for --aot, but it is not shown to fix these. Its description calls its latest push a compile check, and its last build, #136666, ran only the files on its test/aot/every-build.txt. Of the 39 test/aot files, 8 ran there and passed, 25 did not run, and 6 no longer exist on that branch.
  • I have not ported it. It changes 7,093 files and needs a preview WebKit build.
  • It also rewrites two of those tests to what main does now, which this PR reverts. See "Conflicts with AOT: hangs, wrong answers, failed builds; bun test --aot follows #44833's mocks #47605" in the description.

2. test/js/web/request/request-clone-leak.test.ts on 13 x64-asan

It failed in build #137140. In #137153 it read 64, 64 and then 63 MB, so it passed on the third attempt.

  • new Request(test #1) measured an RSS delta of 64 MB in all 4 attempts. Line 83 expects < 64 under ASAN. At 9990f26 (#137134) it measured 63 MB and passed.
  • The test uses no mocks, plugins or require(), so it runs none of the code in this diff.
  • Other branches measure the same 63 to 64 MB. 10 other PR builds from #137121 to #137139 ran it:
Outcome Builds
64 MB in all 4 attempts, failed #137139, #137138, #137132, #137128
64 MB, then 63 MB on a retry #137137, #137129, #137127, #137126, #137121
63 MB on the first attempt #137135

Generated by Claude Code

@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 second commit's change to commonJSExportsOfObjectModule returning empty: both callers handle it (fetchBuiltinModuleWithoutResolution restores the raw-object fallback, handleVirtualModuleResult assigns exportsObject), a mock whose result is a JSCommonJSModule never reaches the new !exports branch because exportsObject() yields undefined rather than an empty value, and the new isESModulePatchedByMockModule helper makes no JS-entering calls, so the only throw point in the new block (moduleLoaderOf) is the one that is checked.

Extended reasoning...

The second push changed the shared helper's contract for its two callers and added a non-throwing registry lookup; those paths were traced and found consistent, and the open inline findings plus the unaddressed concerns from the earlier review still warrant a human look.

Comment thread src/jsc/bindings/ModuleLoader.cpp Resolved
…e patched ES module

A second mock.module() of a module starts with the namespace object that the first one patched.
While the patch of its async factory was pending, require() took that for its own patch and returned the first mock's values.
It now returns what the factory returned until the patch lands, as on main.

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

Comment thread src/jsc/bindings/BunPlugin.cpp Resolved
@grantjoy

grantjoy commented Nov 4, 2026

Copy link
Copy Markdown

Tested this PR's build (1.4.4-canary.1+55289e3fb, darwin-aarch64, Buildkite #137153) against canary (b9f2ec363) and 1.4.4.

The regression is real on canary, and this PR fixes it. With the example from the description:

require("./dep.ts").b require(...) === dep
1.4.4 "real-b" true
canary b9f2ec363 undefined false
this PR 55289e3fb "real-b" true

Same result whether import() or require() runs first.

No regressions on a real suite. Our backend unit suite is 1,410 files / ~21.7k tests, with 32 files using mock.module() (several partial mocks). It runs as bun test --parallel=4 --no-isolate batches, so files share a module cache. On all three builds it finishes with 0 failures after reruns. The first-pass failures under the shared cache are our own leaks, not mock-related, and the PR build's are a subset of 1.4.4's.

We came here from #31316: #44833's per-file mock.module() scoping is the fix we've been waiting for. As far as I can tell, if the next release is cut from main before this lands, it ships #44833 together with this require() regression. That makes this PR worth landing before the release.

@dylan-conway @Jarred-Sumner: this and #47605 both change the same two test/aot files, so whichever merges second has to adjust to the other. Could you pick an order? Happy to re-run against whichever rebases.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bun:test] mocking mutable exports (let) with mock.module is broken

4 participants