Skip to content

Commit 3af4f17

Browse files
committed
fix(core): apply rs.mock to non-literal dynamic import()
A dynamic `import()` with a non-literal specifier is routed to the worker's native import hook and loaded by Node outside the webpack runtime, so `rs.mock` was bypassed (#1454). - Route a non-literal `import(request)` whose request names a mocked module to the bundle's mock instance via a shared-global resolver (all Node), fixing `const s = 'node:os'; import(s)`. - Install in-thread `module.registerHooks` (Node >= 22.15 / >= 23.5, feature-detected with silent fallback) so a natively-loaded module's inner import of a mocked module is redirected to a worker-realm mock registry — fixing the reported repro (a local module reached via a variable `import()`) and externalized deps that import a mocked module. Limitations documented in the mock guide: Node version floor, TS/JSX native targets, function-factory instance divergence, and isolate:false ESM cache.
1 parent 5e79dfb commit 3af4f17

14 files changed

Lines changed: 537 additions & 2 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { hostname } from 'node:os';
2+
3+
export const probe = (): string => hostname();
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Imported ONLY via a non-literal `import(variable)` (see
2+
// mockNonLiteralDynamicImport.test.ts) so it stays out of the bundle and is
3+
// loaded by Node's loader — the #1454 off-graph case. Kept separate from
4+
// `target.mjs` (which a literal import compiles into the bundle) so the two
5+
// tests exercise distinct paths.
6+
import { hostname } from 'node:os';
7+
8+
export const probe = () => hostname();
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Imported ONLY via a non-literal `import(variable)`, so Node loads it natively.
2+
// Its bare `strip-ansi` import exercises the registerHooks NON-builtin branch
3+
// (real resolution via `nextResolve`, keyed by the resolved URL) — distinct from
4+
// the builtin (`node:os`) path.
5+
import stripAnsi from 'strip-ansi';
6+
7+
export const probe = () => stripAnsi('x');
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import nodeModule from 'node:module';
2+
import { describe, expect, it, rs } from '@rstest/core';
3+
4+
// Regression for #1454: rs.mock must reach the inner imports of a module loaded
5+
// via a NON-LITERAL dynamic `import(variable)`. rspack cannot statically include
6+
// such a target, so Node loads it outside the bundle; only the in-thread
7+
// `module.registerHooks` path (Node >= 22.15 / >= 23.5) can redirect the
8+
// target's `import 'node:os'` to the mock. The literal-import control compiles
9+
// the target into the bundle and is mock-aware on all Node.
10+
const HAS_REGISTER_HOOKS =
11+
typeof (nodeModule as { registerHooks?: unknown }).registerHooks ===
12+
'function';
13+
14+
rs.mock('node:os', () => ({ hostname: () => 'MOCKED' }));
15+
rs.mock('strip-ansi', () => ({ default: () => 'MOCKED_STRIP' }));
16+
17+
it('dynamic import with a string LITERAL applies the mock (control)', async () => {
18+
const mod = await import('../fixtures/nonLiteralDynamic/target.mts');
19+
expect(mod.probe()).toBe('MOCKED');
20+
});
21+
22+
it('non-literal import where the variable IS a mocked builtin applies the mock (all Node)', async () => {
23+
// The specifier resolves to the mocked module itself — fixed by the request-key
24+
// bridge in the runtime router (no registerHooks needed), so this is ungated.
25+
const spec = ['node', 'os'].join(':');
26+
const os = (await import(spec)) as unknown as { hostname: () => string };
27+
expect(os.hostname()).toBe('MOCKED');
28+
});
29+
30+
describe.skipIf(!HAS_REGISTER_HOOKS)('non-literal specifier (#1454)', () => {
31+
it('dynamic import with a VARIABLE applies the mock (builtin)', async () => {
32+
const specifier = '../fixtures/nonLiteralDynamic/targetVar.mjs';
33+
const mod = (await import(specifier)) as { probe: () => string };
34+
expect(mod.probe()).toBe('MOCKED');
35+
});
36+
37+
it('dynamic import of a module that imports a mocked npm package sees the mock', async () => {
38+
const specifier = '../fixtures/nonLiteralDynamic/targetVarNpm.mjs';
39+
const mod = (await import(specifier)) as { probe: () => string };
40+
expect(mod.probe()).toBe('MOCKED_STRIP');
41+
});
42+
});

packages/core/src/core/plugins/mockRuntimeCode.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,73 @@ const createMockedModule = (originalModule, isSpy) => {
9696
);
9797
};
9898

99+
// #1454: publish a mock's settled exports to the worker-realm native-mock
100+
// registry so a Node `registerHooks` load hook can serve them to a module loaded
101+
// NATIVELY by Node — a true-external `A` (a node_modules package), or a local
102+
// module reached via a non-literal `import(variable)` (loaded outside the
103+
// bundle) — that internally imports this mocked module. Routed over the existing
104+
// RSTEST_API channel (same as `mockObject`). `request` is `undefined` under an
105+
// older @rspack/core that omits the request literal — skip then.
106+
//
107+
// `produce` is the source of the mock's exports: the already-built mocked module
108+
// for the spy/mock-option path (the SAME instance bundle consumers see, via its
109+
// getters), or the raw factory for a function mock. It is run on a MICROTASK,
110+
// never eagerly at install time: a factory may reference module-level imports
111+
// not yet evaluated when the hoisted `rs.mock` runs (notably under circular
112+
// deps — see mockHoist/barrel), so eager evaluation throws `Cannot read
113+
// properties of undefined`. The microtask runs after the module body settles its
114+
// imports but before the test body's `await import(...)`, so the registry is
115+
// populated in time. Errors are swallowed: a factory that only runs inside the
116+
// bundle context simply won't back a native mock, leaving the webpack path
117+
// untouched.
118+
//
119+
// LIMITATION (function factory only): the factory is re-run here, so the
120+
// native-realm mock is a SEPARATE instance from the bundle's. A spy created
121+
// inside the factory (`rs.fn()`) is therefore a different object on each side, so
122+
// asserting — through the bundle handle — calls made via the natively-loaded
123+
// module will not see them. Sync object-returning factories (the #1454 repro)
124+
// and the spy/mock-option path are unaffected. Sharing the bundle instance was
125+
// tried (`() => __webpack_require__(id)`) but it eagerly evaluates the mock
126+
// module on the microtask, corrupting state for an async-factory error and
127+
// running factory side effects early; deferring that eval is a future change.
128+
const publishNativeMock = (request, produce) => {
129+
if (request === undefined) {
130+
return;
131+
}
132+
const api = globalThis.RSTEST_API?.rstest;
133+
if (!api || typeof api.__setNativeMock !== 'function') {
134+
return;
135+
}
136+
queueMicrotask(() => {
137+
try {
138+
const res = produce();
139+
if (isPromise(res)) {
140+
res.then((mod) => api.__setNativeMock(request, mod)).catch(() => {});
141+
} else {
142+
api.__setNativeMock(request, res);
143+
}
144+
} catch {
145+
// factory is not standalone-runnable — skip native mocking for it
146+
}
147+
});
148+
};
149+
150+
// Deferred on a microtask like `publishNativeMock` so install→uninstall order is
151+
// preserved: a synchronous unpublish would run BEFORE the deferred publish
152+
// microtask, which would then re-add a stale entry. Queuing both keeps the last
153+
// operation winning (`rs.mock` then `rs.unmock` ⇒ removed).
154+
const unpublishNativeMock = (request) => {
155+
if (request === undefined) {
156+
return;
157+
}
158+
queueMicrotask(() => {
159+
const api = globalThis.RSTEST_API?.rstest;
160+
if (api && typeof api.__unsetNativeMock === 'function') {
161+
api.__unsetNativeMock(request);
162+
}
163+
});
164+
};
165+
99166
//#region rs.unmock
100167
__webpack_require__.rstest_unmock = (id, request) => {
101168
restoreOriginalFactory(id);
@@ -105,6 +172,7 @@ __webpack_require__.rstest_unmock = (id, request) => {
105172
if (request !== undefined) {
106173
delete __webpack_require__.rstest_mocked_ids_by_request[request];
107174
}
175+
unpublishNativeMock(request);
108176
};
109177
//#endregion
110178

@@ -239,6 +307,7 @@ const getMockImplementation = (mockType = 'mock') => {
239307
};
240308

241309
installFactory(finalModFactory);
310+
publishNativeMock(request, () => mockedModule);
242311
return;
243312
}
244313

@@ -282,6 +351,7 @@ const getMockImplementation = (mockType = 'mock') => {
282351
};
283352

284353
installFactory(finalModFactory);
354+
publishNativeMock(request, modFactory);
285355
}
286356
};
287357
};
@@ -318,6 +388,22 @@ __webpack_require__.rstest_dynamic_require = (id, request) => {
318388
};
319389
//#endregion
320390

391+
//#region non-literal dynamic import() mock resolution
392+
/**
393+
* Expose the mocked instance for a clean `request` to the worker's native import
394+
* hook (loadEsModule/loadModule `defineRstestDynamicImport`), which handles a
395+
* non-literal `import(variable)` outside the webpack runtime. `const s='node:os';
396+
* import(s)` then resolves `s` to the mocked module here instead of natively
397+
* loading the real one. Published on the shared `globalThis` because the worker
398+
* hook has no `__webpack_require__`; returns `undefined` when the request isn't
399+
* mocked, so the hook performs its normal native import.
400+
*/
401+
globalThis.__rstest_resolve_mocked_dynamic_request__ = (request) => {
402+
const mockedId = __webpack_require__.rstest_mocked_ids_by_request[request];
403+
return mockedId !== undefined ? __webpack_require__(mockedId) : undefined;
404+
};
405+
//#endregion
406+
321407
//#region rs.reset_modules
322408
__webpack_require__.rstest_reset_modules = () => {
323409
const mockedIds = Object.keys(__webpack_require__.rstest_original_modules);

packages/core/src/runtime/api/utilities.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,16 @@ import { RSTEST_ENV_SYMBOL_KEY } from '../../utils/constants';
1010
import { fileContext } from '../fileContext';
1111
import { getRealTimers } from '../util';
1212
import type { FakeTimerInstallOpts, FakeTimersSnapshot } from './fakeTimers';
13+
import {
14+
getNativeMock,
15+
setNativeMock,
16+
unsetNativeMock,
17+
} from '../worker/mockRegistry';
1318
import { mockObject as mockObjectImpl } from './mockObject';
1419
import { initSpy } from './spy';
20+
// Browser-safe path/URL helpers (no `node:url`/`node:path`); keeps this shared
21+
// module linkable in the browser runtime build.
22+
import { pathToFileURL } from 'url-extras';
1523

1624
const DEFAULT_WAIT_TIMEOUT = 1000;
1725
const DEFAULT_WAIT_INTERVAL = 50;
@@ -124,6 +132,34 @@ let utilitiesPromise:
124132
* per-file `useRealTimers`, unchanged.
125133
* See https://github.com/web-infra-dev/rstest/issues/1376.
126134
*/
135+
// #1454: browser-safe key computation for the native-mock bridge.
136+
// Mirrors the Node-only hook key (nativeMockHooks.ts) but imports nothing from
137+
// `node:url` / `node:module`, so this shared module stays linkable in the
138+
// browser runtime build. `import.meta.resolve` is undefined in browser mode, so
139+
// only the trivial `node:` short-circuit survives there (native mocks are
140+
// meaningless without Node's loader anyway).
141+
const nativeImportMetaResolve = import.meta.resolve?.bind(import.meta);
142+
143+
const resolveNativeMockKey = (
144+
request: string,
145+
base: string,
146+
): string | undefined => {
147+
// `import.meta.resolve` returns the `node:`-prefixed form for bare builtins
148+
// ('os' → 'node:os'), matching `toNodeBuiltin` on the hook side; an explicit
149+
// `node:` specifier needs no resolution.
150+
if (request.startsWith('node:')) {
151+
return request;
152+
}
153+
if (!nativeImportMetaResolve) {
154+
return undefined;
155+
}
156+
try {
157+
return nativeImportMetaResolve(request, pathToFileURL(base).href);
158+
} catch {
159+
return undefined;
160+
}
161+
};
162+
127163
export const createRstestUtilities = async (): Promise<RstestUtilities> => {
128164
utilitiesPromise ??= buildRstestUtilities();
129165
const bound = await utilitiesPromise;
@@ -633,5 +669,52 @@ const buildRstestUtilities = async (): Promise<{
633669
currentFakeTimersConfig = undefined;
634670
};
635671

672+
// #1454: bridge the bundle's mock runtime to the worker-realm `mockRegistry`
673+
// that the Node `registerHooks` load hook reads. Called from
674+
// `mockRuntimeCode.js` at rs.mock/unmock time over the existing RSTEST_API
675+
// channel (the same one `mockObject` uses). Keyed by the specifier resolved
676+
// against the test file so a natively-loaded module's internal `import 'B'` —
677+
// resolved by Node against that module's own URL — lands on the same key for
678+
// builtins and same-realpath npm deps.
679+
const bridge = rstest as RstestUtilities & {
680+
__setNativeMock?: (
681+
request: string,
682+
settledExports: Record<string, unknown>,
683+
) => void;
684+
__unsetNativeMock?: (request: string) => void;
685+
// Bundler retention anchor, not runtime API — see the assignment below.
686+
__getNativeMock?: typeof getNativeMock;
687+
};
688+
// Bundler anchor (never invoked): the synthetic mock module the Node load hook
689+
// generates does `import { getNativeMock } from <REGISTRY_URL>` at RUNTIME, so
690+
// the registry chunk must keep `getNativeMock` as a NAMED export. mockRegistry
691+
// and nativeMockHooks share one chunk, so their internal use is inlined and
692+
// does NOT retain the export; this assignment from the api chunk is a real
693+
// cross-chunk consumer, which forces the bundler to keep it. (Verified: drop
694+
// it and the synthetic import fails to link — `does not provide an export`.)
695+
bridge.__getNativeMock = getNativeMock;
696+
bridge.__setNativeMock = (request, settledExports) => {
697+
const key = resolveNativeMockKey(
698+
request,
699+
fileContext().workerState.testPath,
700+
);
701+
if (
702+
key !== undefined &&
703+
settledExports &&
704+
typeof settledExports === 'object'
705+
) {
706+
setNativeMock(key, settledExports);
707+
}
708+
};
709+
bridge.__unsetNativeMock = (request) => {
710+
const key = resolveNativeMockKey(
711+
request,
712+
fileContext().workerState.testPath,
713+
);
714+
if (key !== undefined) {
715+
unsetNativeMock(key);
716+
}
717+
};
718+
636719
return { rstest, resetForFile };
637720
};

packages/core/src/runtime/worker/loadEsModule.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
finalizeDynamicImport,
99
loadWasmFromContent,
1010
resolveImportSpecifier,
11+
maybeResolveMockedDynamicImport,
1112
} from './resolveDynamicImport';
1213
import {
1314
RSTEST_DYNAMIC_IMPORT_HOOK,
@@ -104,6 +105,13 @@ const defineRstestDynamicImport =
104105
importAttributes: ImportCallOptions,
105106
origin?: string,
106107
) => {
108+
// #1454: route `import(variable)` of a mocked module to the bundle's mock
109+
// instance before any native load (policy in resolveDynamicImport.ts).
110+
const mocked = maybeResolveMockedDynamicImport(specifier, returnModule);
111+
if (mocked !== undefined) {
112+
return mocked;
113+
}
114+
107115
const currentDirectory = path.dirname(distPath);
108116

109117
const joinedPath = isRelativePath(specifier)

packages/core/src/runtime/worker/loadModule.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
finalizeDynamicImport,
99
loadWasmFromContent,
1010
resolveImportSpecifier,
11+
maybeResolveMockedDynamicImport,
1112
} from './resolveDynamicImport';
1213
import {
1314
RSTEST_DYNAMIC_IMPORT_HOOK,
@@ -127,6 +128,13 @@ const defineRstestDynamicImport =
127128
importAttributes: ImportCallOptions,
128129
origin?: string,
129130
) => {
131+
// #1454: route `import(variable)` of a mocked module to the bundle's mock
132+
// instance before any native load (policy in resolveDynamicImport.ts).
133+
const mocked = maybeResolveMockedDynamicImport(specifier, returnModule);
134+
if (mocked !== undefined) {
135+
return mocked;
136+
}
137+
130138
const modulePath = resolveImportSpecifier({ specifier, origin, testPath });
131139

132140
// Bundled `.wasm` is emitted as an in-memory asset file and must be

0 commit comments

Comments
 (0)