Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
c58ed3ea52 fix(test): register placeholder virtual modules for mock.module() before ESM linking
When `mock.module()` is called at the top level of a test file alongside
static imports of the same module, ESM import hoisting causes the real
module to be linked before `mock.module()` executes. If the real module
doesn't export the expected names, JSC's linker throws a SyntaxError.

This fix detects top-level `mock.module("specifier", fn)` calls during
parsing and registers placeholder virtual modules with the expected
export names before the module graph is built. When `mock.module()`
later runs at evaluation time, it replaces the placeholder with the
real mock via `overrideExportValue`.

Closes #18358

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 11:33:07 +00:00
9 changed files with 230 additions and 0 deletions

View File

@@ -71,6 +71,11 @@ has_commonjs_export_names: bool = false,
has_import_meta: bool = false,
import_meta_ref: Ref = Ref.None,
/// Specifiers from mock.module("specifier", fn) calls detected during parsing
/// in test mode. Used to register placeholder virtual modules before the module
/// graph is built, preventing link errors for mocked modules.
mock_module_specifiers: bun.StringArrayHashMapUnmanaged(void) = .{},
pub const CommonJSNamedExport = struct {
loc_ref: LocRef,
needs_decl: bool = true,

View File

@@ -304,6 +304,11 @@ pub fn NewParser_(
jest: Jest = .{},
/// When inject_jest_globals is enabled, this collects specifiers from
/// mock.module("specifier", fn) calls, so that placeholder virtual modules
/// can be registered before the module graph is built.
mock_module_specifiers: bun.StringArrayHashMapUnmanaged(void) = .{},
// Imports (both ES6 and CommonJS) are tracked at the top level
import_records: ImportRecordList,
import_records_for_current_part: List(u32) = .{},
@@ -6616,6 +6621,7 @@ pub fn NewParser_(
.ts_enums = try p.computeTsEnumsMap(allocator),
.import_meta_ref = p.import_meta_ref,
.mock_module_specifiers = p.mock_module_specifiers,
.symbols = js_ast.Symbol.List.moveFromList(&p.symbols),
.parts = bun.BabyList(js_ast.Part).moveFromList(parts),
.import_records = ImportRecord.List.moveFromList(&p.import_records),

View File

@@ -1539,6 +1539,56 @@ pub fn VisitExpr(
}
};
// Detect mock.module("specifier", fn) calls in test mode.
// When the test file calls mock.module() and also has static imports
// from the same specifier, we need to register a placeholder virtual
// module before the module graph is built to prevent link errors.
if (p.options.features.inject_jest_globals and
p.fn_or_arrow_data_visit.is_outside_fn_or_arrow)
detect_mock_module: {
const dot = e_.target.data.as(.e_dot) orelse break :detect_mock_module;
if (!strings.eqlComptime(dot.name, "module")) break :detect_mock_module;
if (e_.args.len < 2) break :detect_mock_module;
// Check if the target is `mock.module` where `mock` is from bun:test
const mock_ref: Ref = switch (dot.target.data) {
.e_import_identifier => |id| id.ref,
.e_identifier => |id| id.ref,
else => break :detect_mock_module,
};
const original_name = p.symbols.items[mock_ref.innerIndex()].original_name;
if (!strings.eqlComptime(original_name, "mock")) break :detect_mock_module;
// Verify `mock` comes from bun:test (or jest/vitest compat modules).
// We check if mock_ref is a known import item and then verify
// its source module by looking at the import record associated
// with the symbol's namespace ref.
const is_from_test_module = is_test_import: {
if (!p.is_import_item.contains(mock_ref)) break :is_test_import false;
// Look through import records to find the one that imported `mock`
for (p.import_records.items) |record| {
if (strings.eqlComptime(record.path.text, "bun:test") or
strings.eqlComptime(record.path.text, "@jest/globals") or
strings.eqlComptime(record.path.text, "vitest"))
{
break :is_test_import true;
}
}
break :is_test_import false;
};
if (!is_from_test_module) break :detect_mock_module;
// Extract the specifier string from the first argument
const first_arg = e_.args.at(0);
if (first_arg.data.as(.e_string)) |str| {
if (str.isUTF8()) {
p.mock_module_specifiers.put(p.allocator, str.data, {}) catch {};
}
}
}
return expr;
}
pub fn e_new(p: *P, expr: Expr, _: ExprIn) Expr {

View File

@@ -536,6 +536,13 @@ pub fn transpileSourceCode(
dumpSource(jsc_vm, specifier, &printer);
}
// Register placeholder virtual modules for mock.module() targets.
// This must happen after transpilation (we have the AST) but before
// JSC builds the module graph (which happens after we return the source).
if (parse_result.ast.mock_module_specifiers.count() > 0) {
registerPendingMockModules(jsc_vm, &parse_result, path);
}
defer {
if (is_main) {
jsc_vm.has_loaded = true;
@@ -820,6 +827,93 @@ pub fn transpileSourceCode(
}
}
/// Register placeholder virtual modules for mock.module() targets detected
/// during parsing. For each mock specifier that also has static imports in
/// the same file, we create a placeholder virtual module with the expected
/// export names. This prevents JSC's linker from erroring when the real
/// module doesn't have those exports.
pub fn registerPendingMockModules(
jsc_vm: *VirtualMachine,
parse_result: *const ParseResult,
source_path: bun.fs.Path,
) void {
const globalObject = jsc_vm.global;
const mock_specifiers = &parse_result.ast.mock_module_specifiers;
const import_records = parse_result.ast.import_records.slice();
for (mock_specifiers.keys()) |mock_spec| {
// Resolve the mock specifier to an absolute path, same as mock.module() does.
var resolved_spec_str: []const u8 = mock_spec;
// Try to resolve using the same resolver the module loader uses
const source_dir = source_path.name.dir;
var resolve_result = jsc_vm.transpiler.resolver.resolveAndAutoInstall(
source_dir,
mock_spec,
.stmt,
.read_only,
);
switch (resolve_result) {
.success => |*r| {
if (r.path()) |p| {
resolved_spec_str = p.text;
}
},
else => {},
}
var resolved_specifier = ZigString.init(resolved_spec_str);
// Collect export names from static imports of this mock specifier.
var export_names_buf: [64]ZigString = undefined;
var export_count: usize = 0;
for (import_records) |record| {
if (!strings.eql(record.path.text, mock_spec)) continue;
const named_imports = &parse_result.ast.named_imports;
var iter = named_imports.iterator();
while (iter.next()) |entry| {
const named_import = entry.value_ptr;
if (named_import.import_record_index < import_records.len) {
const import_rec = &import_records[named_import.import_record_index];
if (strings.eql(import_rec.path.text, mock_spec)) {
if (named_import.alias) |alias| {
if (export_count < export_names_buf.len) {
export_names_buf[export_count] = ZigString.init(alias);
export_count += 1;
}
}
}
}
}
if (record.flags.contains_default_alias) {
if (export_count < export_names_buf.len) {
export_names_buf[export_count] = ZigString.init("default");
export_count += 1;
}
}
break;
}
Bun__registerPendingMockModule(
globalObject,
&resolved_specifier,
&export_names_buf,
export_count,
);
}
}
extern fn Bun__registerPendingMockModule(
globalObject: *JSGlobalObject,
resolvedSpecifier: *const ZigString,
exportNames: [*]const ZigString,
exportNamesCount: usize,
) void;
pub export fn Bun__resolveAndFetchBuiltinModule(
jsc_vm: *VirtualMachine,
specifier: *bun.String,

View File

@@ -574,6 +574,11 @@ pub const RuntimeTranspilerStore = struct {
dumpSource(this.vm, specifier, &printer);
}
// Register placeholder virtual modules for mock.module() targets.
if (parse_result.ast.mock_module_specifiers.count() > 0) {
@import("./ModuleLoader.zig").registerPendingMockModules(vm, &parse_result, path);
}
const source_code = brk: {
const written = printer.ctx.getWritten();

View File

@@ -700,6 +700,44 @@ extern "C" JSC_DEFINE_HOST_FUNCTION(JSMock__jsModuleMock, (JSC::JSGlobalObject *
return JSValue::encode(jsUndefined());
}
// Register a pending mock module with placeholder exports.
// Called from the module loader after transpilation detects mock.module() calls
// that target modules which are also statically imported.
// This ensures the virtual module is registered BEFORE the module graph is built,
// so JSC's linker finds the mock instead of the real module.
extern "C" void Bun__registerPendingMockModule(
Zig::GlobalObject* globalObject,
const ZigString* resolvedSpecifier,
const ZigString* exportNames,
size_t exportNamesCount)
{
auto& vm = globalObject->vm();
// Create a plain object with all the expected export names set to undefined.
// This object will be used as the module's exports when the virtual module is fetched.
JSC::JSObject* placeholderExports = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), exportNamesCount > 0 ? exportNamesCount : 1);
for (size_t i = 0; i < exportNamesCount; i++) {
auto name = Zig::toStringCopy(exportNames[i]);
placeholderExports->putDirect(vm, Identifier::fromString(vm, name), jsUndefined());
}
// If there are no specific exports, add a default export
if (exportNamesCount == 0) {
placeholderExports->putDirect(vm, vm.propertyNames->defaultKeyword, jsUndefined());
}
// Create a pre-executed JSModuleMock that returns the placeholder object.
// This avoids needing the mockModuleStructure - we construct the structure inline.
auto* mockStructure = JSModuleMock::createStructure(vm, globalObject, globalObject->objectPrototype());
JSModuleMock* mock = JSModuleMock::create(vm, mockStructure, placeholderExports);
// Mark as already executed so executeOnce() returns the cached result directly
mock->hasCalledModuleMock = true;
auto specifierString = Zig::toStringCopy(*resolvedSpecifier);
globalObject->onLoadPlugins.addModuleMock(vm, specifierString, mock);
}
template<typename Visitor>
void JSModuleMock::visitChildrenImpl(JSCell* cell, Visitor& visitor)
{

View File

@@ -944,6 +944,7 @@ pub const Jest = struct {
xit: Ref = Ref.None,
xtest: Ref = Ref.None,
xdescribe: Ref = Ref.None,
mock: Ref = Ref.None,
};
// Doing this seems to yield a 1% performance improvement parsing larger files

View File

@@ -0,0 +1,4 @@
// This module intentionally does NOT export 'myExportedFunction'.
// The test verifies that mock.module() prevents the real module from
// being loaded, so the missing export doesn't cause a link error.
export const someOtherExport = "real-value";

View File

@@ -0,0 +1,27 @@
import { describe, expect, it, mock } from "bun:test";
// Register mock BEFORE the static import (which gets hoisted by ESM).
// The mock.module() call should be detected during transpilation and
// a placeholder virtual module registered so that the static import
// doesn't fail during module linking.
mock.module("./18358-fixture-missing-export.ts", () => ({
myExportedFunction: (fn: any) => ({
getClient: fn,
query: () => ({ data: { test: "test" }, loading: false, error: undefined }),
}),
}));
import { myExportedFunction } from "./18358-fixture-missing-export.ts";
describe("issue #18358", () => {
it("mock.module should intercept module loading before ESM link phase", () => {
expect(typeof myExportedFunction).toBe("function");
const result = myExportedFunction(() => "client");
expect(result.getClient()).toBe("client");
expect(result.query()).toEqual({
data: { test: "test" },
loading: false,
error: undefined,
});
});
});