Repository navigation
Conversation
…its name to A package whose `exports` names one file for `import` and another for `require` resolves to two paths. A module mock was registered under the first one only, so require() of the mocked name loaded the real file. The mock now has both paths and the table of the mocks holds it under each. One resolver entry returns both, and each mock API takes the one that its kind loads: jest.requireActual() the file of require(), vi.importActual() the file of an import.
… files, and document it
…import = require() and a library beside a wrapper package
…t require() loads
…eep the new table functions small
… rules of a mock with two paths out of line
|
Updated 2:58 PM PT - Nov 10th, 2026
✅ @robobun, your commit 657708987f3fab3c7d7d952806796725424fc21f passed in 🧪 To try this PR locally: bunx bun-pr 48467That installs a local version of the PR into your bun-48467 --bun |
|
Status: reproduced on main 89452d2 (release build) with a package whose The PR stays a draft. A review of the branch found that it keeps an ended mock in |
main's #48120 rewrote large parts of src/jsc/bindings/BunPlugin.cpp. The code that it added reads a mock's own path as `importKey`, lists the mocks with listModuleMocks() where it went through the table, and ends a mock with unmockModule(mock, ...), which takes the path from the mock. test/aot/runner-suites.known.txt and runner-suites.runs.txt keep the lines of the new tests.
main's #48436 renamed fields and functions of src/jsc/bindings/BunPlugin.cpp on lines that read a mock's own path. Those lines take main's names and read `importKey`. docs/compiler/compatibility.mdx no longer has a section for `bun test --aot`. The sentence about a name with two files is now the third difference under "Module mocks" in docs/compiler/debugging.mdx.
Problem
importand another forrequirereachesimportonly:require=real cjs import()=factory. uuid and axios are such packages.resolveModuleMockSpecifier()(src/jsc/bindings/BunPlugin.cpp:3885) resolves the name once, as an import.require()resolves to the other path and finds no mock.jest.requireActual("pkg")returns the ES module. Jest returns the CommonJS file.Fix
Bun__resolveForImportAndRequireresolves a name both ways. A mock keeps both paths, andaddModuleMock()/removeModuleMock()keep its two table entries together.require(),importand the loaders do not change: their exact-key lookups now find the one mock.jest.requireActualtherequirefile,vi.importActualtheimportfile.test/js/bun/test/mock/mock-module.test.tsfail on main. Its 314 older tests andplugins.test.tspass.Background
importandrequire()use differentexportsconditions and extension orders, so one name can be two files. A module mock stands for a resolved path.require()resolves: it covers one of four exits and leavesjest.requireActualwrong. Node added a second map key (test_runner: mock dual-package with conditional exports nodejs/node#62943).Downsides
.text: +8,576 bytes.jest.requireActual("pkg")of such a package now returns the CommonJS exports.bun test --aotstill loads the real file where the build resolvedrequire("pkg"): 18 lines on its known list.Notes
Origin. There is no GitHub issue. The repro comes from a comparison of
bun testwith Jest 30.5.2 and Vitest 5.1.0 on one small package.Repro.
node_modules/zz-dual/package.jsonhas"exports": { ".": { "import": "./esm.mjs", "require": "./cjs.cjs" } }.esm.mjsexportswhich = "real esm"andcjs.cjsexportswhich: "real cjs".Main and this PR, release builds of main 89452d2 and of this branch at 6577089:
mock.module,jest.mock,jest.doMock,vi.mock,vi.doMockofzz-dualrequire=real cjs import()=factoryrequire=factory import()=factoryzz-sub/feature), nested conditions,#depofimports,./libbesidelib.mjsandlib.cjs, a folder withindex.mjsandindex.cjsrequire()require(pkg).fn === (await import(pkg)).fnopenatofzz-dual/cjs.cjswhile the mock standsjest.requireActual("zz-dual"){"default":{"which":"real esm"},"which":"real esm"}{"which":"real cjs"}vi.importActual("zz-dual")mock.module("uuid", f)(uuid 9.0.1), thenrequire("uuid").v4()importresolvesrequire()throwsCannot find moduleReferences, run here. Jest 30.5.2:
jest.mock("zz-dual", factory)givesrequire("zz-dual")the factory's object, also in a CommonJS helper.jest.requireActual("zz-dual")is{"which":"real cjs"}. Jest resolves the name with therequireconditions. Vitest 5.1.0:vi.mocknever reachesrequire(), also not for a package with one file. Bun already givesrequire()thevi.mockmock of a one-file package (docs/test/mocks.mdx, "Mocking a Package"), so a package with two files now gets the same.Which names resolve to two paths.
exportsconditions (root, subpath, nested),imports(#name), and a name without an extension beside a.mjsand a.cjsfile. A package withmainandmoduledoes not: both resolve tomainat run time. AnonResolveplugin is asked once and is not told who asks, so its answer is the one path.Rules of the table.
import. A later mock with the same own path replaces it, with both entries.require()is a claim. The own path of another mock goes before it. Somock.module(require.resolve("pkg"), f)besidemock.module("pkg", g)still givesrequire()the object offandimportthe object ofg, in both orders. When the mock of the path ends, the claim answers again.import. Animportof the path ofrequire()gets that module.importOriginal()then loads both real files.Which path each place uses.
addModuleMock,removeModuleMock,replaceModuleMocklistModuleMocks(jest.resetModules, pending patches, the end of a test file,Bun.plugin.clearAll)findLoadedModule(mock)importpath, the module object ofrequire.cacheunder therequirepathappendMockedModule(evictions),setLoadedModuleAside,packagesToBringBackimportpathrequirepathmoduleMocksBeingLoaded,settle,importOriginalModuleimportpathrequireOriginalModule,jest.requireActual,jest.requireMock,jest.createMockFromModulerequirepathdependencyKeyOfModuleMock,findManualModuleMockrequirepath forjest.mockandjest.doMock, elseimportpathcanRequireOriginalOfModuleMockkeyOfImportWhileModuleMocksRununmock,vi.importActual,vi.importMockimportpathbun test --aot(formThatMockFinds,runCompiledMock)importpath, as beforeThe older tests with two files for each module. A shim of
tempDirgave every ES modulex.tsof the fixtures a twinx.mts, so./xresolves to one file forimportand to the other forrequire(). The suite then ran on both builds: main passes 288 of 307, this PR 316 of 325. The 9 that still fail are made by the twins, not by the mocks: 6 count the loads ofmod.tsandmod.mtsas one module, 2 comparerequire("./mod")withawait import("./mod"), which are two real files, and 1 has a plugin filter fordependency.tsonly.Real packages. uuid 9.0.1 (an ES wrapper around the CommonJS file), nanoid 3.3.7, date-fns 3.6.0 and axios 1.7.7 each resolve to two files. On main
mock.module(pkg, factory)leavesrequire(pkg)real for all four. With this PRrequire()andimport()get one object from one factory call,jest.unmock()brings both back, and for uuidimportOriginal(),jest.requireActual()in the factory andjest.doMock("uuid")without a factory work for both.Measurements. Release builds of main 89452d2 and of this branch at 6577089, from one build directory, on Linux x64. Instructions are user instructions from
perf_event_open, as a slope over two loop lengths, the lowest of 5 runs.perf,straceandbloatyare not installed here..text+8,576 bytes (size -A: 112,978,537 to 112,987,113)..rodata,.dataand.bsshave the same size.HOST_EXPORTmarkers: one replaced, 130 on both.mock.module./rel.jsmock.modulemock.modulejest.doMock./rel.jsjest.doMockjest.doMockvi.doMock./rel.jsvi.doMockvi.doMockjest.requireActual./rel.jsjest.requireActualjest.requireActualjest.unmock./rel.jsjest.unmockjest.unmockrequire()andimport()of a module that has no mock (slope over 300,000 and 600,000 calls, and over 1,000 and 2,000 new.cjsfiles):require()of a loaded moduleawait import()of a loaded modulerequire()of a new.cjsfilerequire()while a mock exists: 8.08 and 8.10 syscalls per call on main, 8.10 and 8.09 here (ptrace count over 1,000 calls, with a mock of a one-file name and of a two-file name).require()resolves once, as before.onResolvecallback for one call:mock.module1,jest.requireActual2,jest.doMock()without a factory 2,jest.unmock1, on both builds.ModuleMockcell is 272 bytes, was 256 (heap snapshot): one more pointer, which takes the next size class. The new counter ofBunPlugin::OnLoadlies in padding, between a pointer and twoboolfields. 100 mocks of names with two paths are 100ModuleMockcells. Afterjest.resetModules()there are 200, as on main for a one-file package that bothrequire()andimport()loaded.->specifierreads inBunPlugin.cpp: 112 on main 89452d2, 0 here. Each is nowimportKey,keyForRequire()or a helper that names both. Callers of the resolver entry with a bool: 3 on main, 0 here.Not changed.
bun test --aot: arequire("pkg")whose name the build resolved still loads the real file, asdocs/compiler/debugging.mdxnow says under "Module mocks". Arequire(name)that is resolved at run time gets the mock. The 18 new tests use the first form, so they are intest/aot/runner-suites.known.txt, and 16 of them inrunner-suites.runs.txt.importresolves:require()of it still throws. Node does not retry there either.require()before the mock is not patched. Animportof that path after the mock gets the mock..nodeaddon and thevi.mockpackage rule at theModule._resolveFilenameexit are as on main.Open pull requests that touch the same functions. #48375 turns the table entry into
{ provider, mock }. This PR keeps the entry of main and hides the table behind methods, with the names of #48375 where they agree, so either can land first. #47819, #48178, #48179 and #48305 change therequire()answer of a mock, the load in flight and the builtin originals. None of them changes which path a mock answers for.