Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/test/mocks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ test("mock.module", async () => {
});
```

Like the rest of Bun, module mocks support both `import` and `require`.
Like the rest of Bun, module mocks support both `import` and `require`. `require()` returns the object the factory returned as `module.exports`, named exports included. As in Jest, an `__esModule: true` property on that object does not change what `require()` returns.

### Overriding Already Imported Modules

Expand Down
37 changes: 13 additions & 24 deletions src/jsc/bindings/BunPlugin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2267,20 +2267,8 @@ __attribute__((minsize)) static void overrideLoadedModuleExports(Zig::GlobalObje
bool isBuiltin = isBuiltinModuleKey(specifier);
bool loaderHoldsMock = isBuiltin && loaded.esmNamespace && loaded.esmNamespace->moduleRecord()->hasLiveExports();
if (loaded.compiledModule && (!loaded.esmNamespace || loaderHoldsMock) && JSC::AOT::hasWordOfMock(globalObject, *loaded.compiledModule) && (mock->compiledModuleThatNeverRan || isBuiltin || JSC::AOT::hasMockInPlace(globalObject, *loaded.compiledModule))) {
JSValue answer = exports;
if (commonJSExports) {
answer = commonJSExports->exportsObject();
RETURN_IF_EXCEPTION(scope, );
} else {
JSValue isESModule = exports->getIfPropertyExists(globalObject, vm.propertyNames->__esModule);
RETURN_IF_EXCEPTION(scope, );
if (isESModule && isESModule.toBoolean(globalObject) && mock->compiledModuleThatNeverRan && !(isBuiltin && loaded.commonJSModule) && !JSC::AOT::wasRequiredUnderMockInPlace(globalObject, *loaded.compiledModule)) {
JSValue defaultValue = exports->getIfPropertyExists(globalObject, vm.propertyNames->defaultKeyword);
RETURN_IF_EXCEPTION(scope, );
if (defaultValue && !defaultValue.isUndefined())
answer = defaultValue;
}
}
JSValue answer = Bun::commonJSExportsOfModuleMockResult(exports);
RETURN_IF_EXCEPTION(scope, );
Comment thread
robobun marked this conversation as resolved.
Resolved
bool isInPlace = JSC::AOT::setMock(globalObject, *loaded.compiledModule, mock, answer, mock->leavesStandingMockToItsImporters.value_or(mock->isOfVitest()));
RETURN_IF_EXCEPTION(scope, );
if (isInPlace) {
Expand Down Expand Up @@ -2361,11 +2349,8 @@ __attribute__((minsize)) static void overrideLoadedModuleExports(Zig::GlobalObje
// Two mocks may patch one variable, which two modules export. The last patch is undone first, as above.
if (!mock->patchOrder)
mock->patchOrder = ++patches;
JSValue whatRequireAnswers = exports;
if (commonJSExports) {
whatRequireAnswers = commonJSExports->exportsObject();
RETURN_IF_EXCEPTION(scope, );
}
JSValue whatRequireAnswers = Bun::commonJSExportsOfModuleMockResult(exports);
RETURN_IF_EXCEPTION(scope, );
JSC::AOT::willMockModule(globalObject, *loaded.compiledModule, whatRequireAnswers, !!commonJSExports);
Comment thread
robobun marked this conversation as resolved.
Resolved
RETURN_IF_EXCEPTION(scope, );
}
Expand Down Expand Up @@ -2397,11 +2382,8 @@ __attribute__((minsize)) static void overrideLoadedModuleExports(Zig::GlobalObje
RETURN_IF_EXCEPTION(scope, );
if (cached != moduleObject)
return;
JSValue moduleExports = exports;
if (commonJSExports) {
moduleExports = commonJSExports->exportsObject();
RETURN_IF_EXCEPTION(scope, );
}
JSValue moduleExports = Bun::commonJSExportsOfModuleMockResult(exports);
RETURN_IF_EXCEPTION(scope, );
moduleObject->putDirect(vm, Bun::builtinNames(vm).exportsPublicName(), moduleExports, 0);
moduleObject->hasEvaluated = true;
}
Expand Down Expand Up @@ -5361,6 +5343,13 @@ JSC::JSObject* resultOfModuleMock(JSC::JSObject* moduleMock)
return mock && mock->state == Zig::JSModuleMock::State::Settled ? mock->result.get() : nullptr;
}

JSC::JSValue commonJSExportsOfModuleMockResult(JSC::JSObject* result)
{
if (auto* commonJSModule = dynamicDowncast<Bun::JSCommonJSModule>(result))
return commonJSModule->exportsObject();
return result;
}

static bool hasUncalledFactoryThatAwaits(Zig::JSModuleMock* mock)
{
if (!mock || mock->state != Zig::JSModuleMock::State::NotCalled)
Expand Down
2 changes: 2 additions & 0 deletions src/jsc/bindings/BunPlugin.h
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,8 @@ JSC::JSPromise* runModuleMock(Zig::GlobalObject*, JSC::JSObject* moduleMock, con
bool isModuleMockInUse(Zig::GlobalObject*, JSC::JSObject* moduleMock);
// The exports, or a JSCommonJSModule that has them as `module.exports`. Null until the factory settles.
JSC::JSObject* resultOfModuleMock(JSC::JSObject* moduleMock);
// What require() of the mock returns: the `module.exports` of a JSCommonJSModule result, else the result itself. Can throw.
JSC::JSValue commonJSExportsOfModuleMockResult(JSC::JSObject* result);
// The factory is an async function that is running, or that nothing has called. require() can't wait for it, and calling it
// from there would make the test file the file of a running factory, whose own import of the module gets the original.
bool hasModuleMockUnsettledAsyncFactory(JSC::JSObject* moduleMock);
Expand Down
38 changes: 23 additions & 15 deletions src/jsc/bindings/ModuleLoader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -412,22 +412,19 @@ static OnLoadResult handleOnLoadResult(Zig::GlobalObject* globalObject, JSC::JSV
OnLoadResult result = {};
result.type = OnLoadResultTypePromise;
result.value.promise = objectValue;
result.wasMock = wasModuleMock;
return result;
}

return handleOnLoadResultNotPromise(globalObject, objectValue, specifier, typeAttribute, wasModuleMock);
}

// The `module.exports` of a plugin `loader: "object"` module: the `default` of an object marked `__esModule`.
// Returns empty when require() should return the module namespace object instead.
static JSValue commonJSExportsOfObjectModule(Zig::GlobalObject* globalObject, JSC::JSObject* object, bool wasModuleMock)
static JSValue commonJSExportsOfObjectModule(Zig::GlobalObject* globalObject, JSC::JSObject* object)
{
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);

if (auto* mockedCommonJSModule = wasModuleMock ? dynamicDowncast<JSCommonJSModule>(object) : nullptr)
RELEASE_AND_RETURN(scope, mockedCommonJSModule->exportsObject());

auto esModuleValue = object->getIfPropertyExists(globalObject, vm.propertyNames->__esModule);
RETURN_IF_EXCEPTION(scope, {});
if (esModuleValue && esModuleValue.toBoolean(globalObject)) {
Expand All @@ -436,7 +433,7 @@ static JSValue commonJSExportsOfObjectModule(Zig::GlobalObject* globalObject, JS
if (defaultValue && !defaultValue.isUndefined())
return defaultValue;
}
return wasModuleMock ? object : JSValue();
return {};
}

template<bool allowPromise>
Expand Down Expand Up @@ -518,9 +515,25 @@ static JSValue handleVirtualModuleResult(

case OnLoadResultTypeObject: {
JSC::JSObject* object = onLoadResult.value.object.getObject();
JSC::JSObject* exportsObject = wasModuleMock ? Bun::resultOfModuleMock(object) : object;
if (commonJSModule && exportsObject) {
JSValue exports = commonJSExportsOfObjectModule(globalObject, exportsObject, wasModuleMock);

if (wasModuleMock) {
// As in Jest, require() gets what the factory returned. `__esModule` on it only shapes the default import.
JSC::JSObject* result = Bun::resultOfModuleMock(object);
if (commonJSModule && result) {
JSValue exports = Bun::commonJSExportsOfModuleMockResult(result);
if (scope.exception()) [[unlikely]] {
return rejectOrResolve({});
}
commonJSModule->setExportsObject(exports);
commonJSModule->hasEvaluated = true;
return commonJSModule;
}
JSSourceCode* source = Bun::sourceCodeOfModuleMock(globalObject, object, specifier->toWTFString(), fetch.typeAttribute ? fetch.typeAttribute->toWTFString() : String());
RELEASE_AND_RETURN(scope, rejectOrResolve(source));
}

if (commonJSModule) {
JSValue exports = commonJSExportsOfObjectModule(globalObject, object);
if (scope.exception()) [[unlikely]] {
return rejectOrResolve({});
}
Expand All @@ -531,11 +544,6 @@ static JSValue handleVirtualModuleResult(
}
}

if (wasModuleMock) {
JSSourceCode* source = Bun::sourceCodeOfModuleMock(globalObject, object, specifier->toWTFString(), fetch.typeAttribute ? fetch.typeAttribute->toWTFString() : String());
RELEASE_AND_RETURN(scope, rejectOrResolve(source));
}

RELEASE_AND_RETURN(scope, rejectOrResolve(createObjectModuleSourceCode(vm, object, specifier->toWTFString(BunString::ZeroCopy))));
}

Expand Down Expand Up @@ -664,7 +672,7 @@ BuiltinModule fetchBuiltinModuleWithoutResolution(
JSC::throwException(globalObject, scope, createAsyncModuleRequiredError(globalObject, specifier->toWTFString(BunString::ZeroCopy)));
return {};
}
JSValue exports = commonJSExportsOfObjectModule(globalObject, result, true);
JSValue exports = Bun::commonJSExportsOfModuleMockResult(result);
RETURN_IF_EXCEPTION(scope, {});
return { Kind::Exports, exports };
}
Expand Down
1 change: 0 additions & 1 deletion src/jsc/bindings/ModuleLoader.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ union OnLoadResultValue {
struct OnLoadResult {
OnLoadResultValue value;
OnLoadResultType type;
bool wasMock;
// Backs `value.sourceText.string` when `contents` is a typed array.
// No inline capacity, so moving the struct doesn't invalidate the pointer.
WTF::Vector<uint8_t> sourceTextCopy;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function runsAsPlain(
body: string,
files: Record<string, string> = {},
inLayersToo = false,
imageAnswersRequireWithDefault = false,
) {
const testFile = (name: string) => `
import { mock, test } from "bun:test";
Expand All @@ -44,8 +45,16 @@ function runsAsPlain(
expect(plain?.result).toBe("pass");
expect(plain?.printed).toContain("mocked");
expect(plain?.printed).not.toContain("real");
expect((await group.ran(["--debug-aot"])).get(name)).toEqual(plain);
if (nameInLayers) expect((await inLayers.ran(["--debug-aot"], "TestRunLayers:0")).get(nameInLayers)).toEqual(plain);
// The image answers require() of a mock that was laid over a module which an import alone had loaded with the `default` of an
// object marked `__esModule`. Plain Bun answers the object itself. Until the engine follows, what require() got is not compared there.
const expectAsPlain = (ran: typeof plain) => {
if (!imageAnswersRequireWithDefault) return expect(ran).toEqual(plain);
expect(ran?.result).toBe("pass");
expect(ran?.printed).toContain("mocked");
expect(ran?.printed).not.toContain("real");
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
expectAsPlain((await group.ran(["--debug-aot"])).get(name));
if (nameInLayers) expectAsPlain((await inLayers.ran(["--debug-aot"], "TestRunLayers:0")).get(nameInLayers));
});
}

Expand Down Expand Up @@ -264,6 +273,8 @@ for (const name of modulesOfLibrary) {
{},
true,
);
// A copy of the exports of bun:sqlite has `__esModule` and `default`: over a module that an import alone loaded, the image answers its `default`.
const copyIsMarkedESModule = name === "bun:sqlite";
runsAsPlain(
ofLibrary,
`${name} is mocked behind an import() of it`,
Expand All @@ -275,9 +286,10 @@ for (const name of modulesOfLibrary) {
console.log(key, imported === first, imported[key] === mocked && imported[key](), imported.marker ?? null, required.marker ?? null, Object.keys(imported).length);`,
{},
true,
copyIsMarkedESModule,
);
// The loader holds such a module as an ES module once an import has loaded it, so require() answers the namespace object, which shows the mock.
// Where what the factory made has `__esModule` and `default`, as a copy of bun:sqlite has, require() answers that `default`.
// What the factory made is answered as it is, even where it has `__esModule` and `default`, as a copy of bun:sqlite has.
runsAsPlain(
ofLibrary,
`require("${name}") behind an import() of it and a mock`,
Expand All @@ -288,6 +300,7 @@ for (const name of modulesOfLibrary) {
console.log(mocked(), require("${name}")[key] === mocked, require("${name}") === first, Object.keys(require("${name}")).length);`,
{},
true,
copyIsMarkedESModule,
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { testOneByOne as test } from "./supported";

// Under `bun run` the first require("bun:sqlite") leaves a module object in require.cache, and that object answers every later require().
// mock.module() stores what its factory made into a module object that is there, so the next require() answers that very object.
// Where there is none yet, require() answers the `default` of an object with `__esModule`, as a copy of bun:sqlite is, and else the object that the factory made.
// Where there is none yet, require() answers the object that the factory made, even an object with `__esModule`, as a copy of bun:sqlite is.
const steps: Record<string, (name: string, at: number) => string> = {
// require() in the test file, in a CommonJS file, and in a file that is written while the test runs
r: name => `tell("r", require("${name}"));`,
Expand Down Expand Up @@ -37,6 +37,24 @@ const orders = [
];
const ordersInLayers = "ra rb rs ha wb rbw rabr ia ib it ibw iarb irb ris a b arb rad iard".split(" ");

// The image answers require() of a mock that was laid over a module of code which an import alone had loaded with the `default` of
// an object marked `__esModule`: `b`, and `t` where the exports of the module say `__esModule`, as bun:sqlite's do. Plain Bun answers
// the object itself, and so does the host for a module of native code, which is its own. Until the engine follows, that is what the
// image is expected to say.
const asImageAnswers = (told: string[], name: string) =>
told.map(line => {
const order = line.split(":")[0];
const isOfCode = name !== "bun:jsc";
const exportsAreMarked = name === "bun:sqlite";
const isAnsweredWithDefault =
(isOfCode && /^[ia]*i[ia]*b/.test(order)) || (exportsAreMarked && /^[ia]*t/.test(order));
if (!isAnsweredWithDefault) return line;
return line
.split(" ")
.map(answer => (/^[iI]=/.test(answer) ? answer : answer.replace(/=([BT]\d+)$/, "=$1.default")))
.join(" ");
Comment thread
robobun marked this conversation as resolved.
Resolved
});

const testFileOf = (name: string, order: string) => (id: string) => `
import { mock, test } from "bun:test";
import { rmSync, writeFileSync } from "node:fs";
Expand Down Expand Up @@ -97,17 +115,18 @@ for (const name of ["bun:sqlite", "ws", "bun:jsc"]) {
const toldAsOneProgram = casesIn(asOneProgram, orders);
test(`require("${name}") in every order of require, import and mock`, async () => {
const plain = toldAsOneProgram(await asOneProgram.ran());
// The object that the factory made and its `default` are among the answers. The namespace object that an import got first is not.
// The object that the factory made is the answer, marked `__esModule` or not, loaded before or not. The namespace object that an
// import got first is not.
const lastAnswerOf = (order: string) => plain[orders.indexOf(order)].split(" ").at(-1);
expect(["ra", "ib", "ia"].map(lastAnswerOf)).toEqual(["R=A1", "R=B1.default", "R=A1"]);
expect(["ra", "ib", "ia", "b"].map(lastAnswerOf)).toEqual(["R=A1", "R=B1", "R=A1", "R=B0"]);
expect(plain.filter(line => !line.includes(": pass "))).toEqual([]);
expect(toldAsOneProgram(await asOneProgram.ran(["--debug-aot"]))).toEqual(plain);
expect(toldAsOneProgram(await asOneProgram.ran(["--debug-aot"]))).toEqual(asImageAnswers(plain, name));
Comment thread
robobun marked this conversation as resolved.
Resolved
});

const toldInLayers = casesIn(inLayers, ordersInLayers);
test(`require("${name}") where each test file is an image of its own`, async () => {
const plain = toldInLayers(await inLayers.ran());
expect(plain.filter(line => !line.includes(": pass "))).toEqual([]);
expect(toldInLayers(await inLayers.ran(["--debug-aot"], "TestRunLayers:0"))).toEqual(plain);
expect(toldInLayers(await inLayers.ran(["--debug-aot"], "TestRunLayers:0"))).toEqual(asImageAnswers(plain, name));
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,10 @@ function casesIn(project: typeof byName, name: string, orders: string[]) {
);
}

// In none of these orders is anything loaded when the mock is made. Every require() answers the very object that the factory made, or its `default` where the object says that it is an ES module.
// In none of these orders is anything loaded when the mock is made. Every require() answers the very object that the factory made, even where the object says that it is an ES module.
function expectAnswersOfPlainBun(plain: string[]) {
expect(plain.filter(line => !line.includes(": pass "))).toEqual([]);
expect(plain.filter(line => !line.startsWith("b ") && / [rR]=(?!made\b)/.test(line))).toEqual([]);
expect(plain.filter(line => line.startsWith("b ") && / [rR]=(?!default\b)/.test(line))).toEqual([]);
expect(plain.filter(line => / [rR]=(?!made\b)/.test(line))).toEqual([]);
}

// Its exports say `__esModule`.
Expand Down
Loading
Loading