diff --git a/src/jsc/bindings/BunPlugin.cpp b/src/jsc/bindings/BunPlugin.cpp index d1a3ceb10f71..4f306238e5ef 100644 --- a/src/jsc/bindings/BunPlugin.cpp +++ b/src/jsc/bindings/BunPlugin.cpp @@ -791,9 +791,12 @@ extern "C" int ModuleLoader__builtinAliasIndex(const Latin1Character*, size_t); static std::optional builtinModuleOfOriginalKey(const String& key) { - if (!key.is8Bit() || !key.endsWith("?actual"_s)) + if (!key.is8Bit()) return std::nullopt; - int index = ModuleLoader__builtinAliasIndex(key.span8().data(), key.length() - "?actual"_s.length()); + String name = withoutOriginalModuleQuery(key); + if (name.isEmpty() || name.length() == key.length()) + return std::nullopt; + int index = ModuleLoader__builtinAliasIndex(name.span8().data(), name.length()); if (index < 0) return std::nullopt; return Bun::builtinModuleKeys[index]; @@ -997,7 +1000,7 @@ __attribute__((minsize)) static JSObject* esmExportsBeforePatches(Zig::GlobalObj // Reading a builtin's exports would force its lazy ones, and the caller can load it again for free. String specifier = mock->specifier->value(globalObject); RETURN_IF_EXCEPTION(scope, nullptr); - if (builtinModuleOfOriginalKey(originalModuleKey(specifier))) + if (isBuiltinModuleKey(specifier)) return nullptr; LoadedModule loaded; @@ -5194,7 +5197,13 @@ Structure* createModuleMockStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObj JSC::JSValue runVirtualModule(Zig::GlobalObject* globalObject, BunString* specifier, bool& wasModuleMock, Zig::BunPlugin::OnLoad::Matches& onLoad) { auto fallback = [&]() -> JSC::JSValue { - return globalObject->onLoadPlugins.run(globalObject, specifier->toWTFString(BunString::ZeroCopy), onLoad); + JSC::JSValue answer = globalObject->onLoadPlugins.run(globalObject, specifier->toWTFString(BunString::ZeroCopy), onLoad); + // What a plugin serves for the original of a builtin has the builtin's name, as what it serves for the builtin has. + if (answer) [[unlikely]] { + if (auto builtin = Zig::builtinModuleOfOriginalKey(specifier->toWTFString(BunString::ZeroCopy))) + *specifier = Bun::toStringView(*builtin); + } + return answer; }; if (isBunTest && !Zig::JSMock__isInPreload(globalObject)) { @@ -5277,9 +5286,6 @@ JSC::JSValue runVirtualModule(Zig::GlobalObject* globalObject, BunString* specif return result; } - if (auto builtin = Zig::builtinModuleOfOriginalKey(specifierString)) - *specifier = Bun::toStringView(*builtin); - if (Zig::isModuleMockEvaluatorKey(specifierString)) { auto& vm = JSC::getVM(globalObject); JSObject* exports = JSC::constructEmptyObject(globalObject); diff --git a/src/jsc/bindings/ModuleLoader.cpp b/src/jsc/bindings/ModuleLoader.cpp index bf6f72a9c90c..717cc4c1ee1e 100644 --- a/src/jsc/bindings/ModuleLoader.cpp +++ b/src/jsc/bindings/ModuleLoader.cpp @@ -641,6 +641,47 @@ extern "C" void Bun__onFulfillAsyncModule( } } +// True for a key that ends in "?actual" or "&actual". Every module that is not a builtin gets here, so it reads the key's own bytes and makes no String. +static ALWAYS_INLINE bool endsWithOriginalModuleQuery(const BunString* key) +{ + static constexpr auto query = "?actual"_s; + if (key->tag != BunStringTag::WTFStringImpl || !key->impl.wtf->is8Bit()) + return false; + auto characters = key->impl.wtf->span8(); + if (characters.size() <= query.length() || characters.back() != query[query.length() - 1]) + return false; + auto tail = characters.last(query.length()); + return (tail[0] == '?' || tail[0] == '&') && WTF::equalSpans(tail.subspan(1), query.span8().subspan(1)); +} + +// The name of the builtin that `key` is the original of (originalModuleKey() in BunPlugin.cpp), held by `original`. Null for any other key. +static NEVER_INLINE const BunString* builtinOfOriginalModuleKey(Zig::GlobalObject* globalObject, const BunString* key, BunString& original) +{ + String keyString = key->toWTFString(BunString::ZeroCopy); + auto builtin = Bun::builtinModuleOfOriginalKey(keyString); + if (!builtin) + return nullptr; + // Outside bun test runVirtualModule() is asked after the builtins: what is registered under this very key is its to serve. + if (auto* virtualModules = isBunTest ? nullptr : globalObject->onLoadPlugins.virtualModules; virtualModules && virtualModules->contains(keyString)) + return nullptr; + original = Bun::toStringView(*builtin); + return &original; +} + +// Not gated on isBunTest or on a registered mock: a factory asks for the original of a builtin before its mock is registered, after Bun.plugin.clearAll(), and outside `bun test`. +static ALWAYS_INLINE bool fetchBuiltinModuleOrItsOriginal(void* bunVM, Zig::GlobalObject* globalObject, const BunString*& specifier, BunString& original, bool isCommonJSRequire, ErrorableResolvedSource* res) +{ + if (Bun__fetchBuiltinModule(bunVM, globalObject, specifier, isCommonJSRequire, res)) + return true; + if (!endsWithOriginalModuleQuery(specifier)) [[likely]] + return false; + const BunString* builtin = builtinOfOriginalModuleKey(globalObject, specifier, original); + if (!builtin) + return false; + specifier = builtin; + return Bun__fetchBuiltinModule(bunVM, globalObject, specifier, isCommonJSRequire, res); +} + BuiltinModule fetchBuiltinModuleWithoutResolution( Zig::GlobalObject* globalObject, const BunString* specifier, @@ -650,13 +691,8 @@ BuiltinModule fetchBuiltinModuleWithoutResolution( void* bunVM = globalObject->bunVM(); auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - BunString original; if (globalObject->onLoadPlugins.hasVirtualModules() && isBunTest) [[unlikely]] { - // The file of a running factory gets this key for the builtin that it mocks. - if (auto builtin = Bun::builtinModuleOfOriginalKey(specifier->toWTFString(BunString::ZeroCopy))) { - original = Bun::toStringView(*builtin); - specifier = &original; - } else if (JSValue moduleMock = Bun::findModuleMock(globalObject, specifier); moduleMock && !Bun::hasModuleMockUnsettledAsyncFactory(moduleMock.getObject())) { + if (JSValue moduleMock = Bun::findModuleMock(globalObject, specifier); moduleMock && !Bun::hasModuleMockUnsettledAsyncFactory(moduleMock.getObject())) { JSC::JSPromise* pending = Bun::runModuleMock(globalObject, moduleMock.getObject(), specifier->toWTFString(BunString::ZeroCopy), true); RETURN_IF_EXCEPTION(scope, {}); JSC::JSObject* result = pending ? nullptr : Bun::resultOfModuleMock(moduleMock.getObject()); @@ -669,7 +705,8 @@ BuiltinModule fetchBuiltinModuleWithoutResolution( return { Kind::Exports, exports }; } } - if (Bun__fetchBuiltinModule(bunVM, globalObject, specifier, true, res)) { + BunString original; + if (fetchBuiltinModuleOrItsOriginal(bunVM, globalObject, specifier, original, true, res)) { if (!res->success) [[unlikely]] { throwException(scope, res->result.err, globalObject); RELEASE_AND_RETURN(scope, {}); @@ -1265,7 +1302,9 @@ static JSValue fetchESMSourceCode( } } - if (!pluginContents && Bun__fetchBuiltinModule(bunVM, globalObject, specifier, false, res)) { + const BunString* builtinKey = specifier; + BunString original; + if (!pluginContents && fetchBuiltinModuleOrItsOriginal(bunVM, globalObject, builtinKey, original, false, res)) { if (!res->success) [[unlikely]] { throwException(scope, res->result.err, globalObject); auto* exception = scope.exception(); @@ -1291,7 +1330,7 @@ static JSValue fetchESMSourceCode( } } - auto moduleKey = specifier->toWTFString(BunString::ZeroCopy); + auto moduleKey = builtinKey->toWTFString(BunString::ZeroCopy); // bun:wrap and a few other builtins return real JS source (not a // synthetic generator). Their providers are identical across globals, diff --git a/test/js/bun/test/mock/mock-module.test.ts b/test/js/bun/test/mock/mock-module.test.ts index 44f863210468..50f941d775ea 100644 --- a/test/js/bun/test/mock/mock-module.test.ts +++ b/test/js/bun/test/mock/mock-module.test.ts @@ -825,6 +825,369 @@ describe.concurrent("the original of a mocked module", () => { ); }); + // A mock of a loaded builtin calls its factory at once, before the mock is registered. In the first mock of a run, no mock is registered yet. + test.each(isolationFlags)("importOriginal of a builtin that a preload loaded, in the first mock %j", async args => { + await expectFixturesToPass( + { + "preload.ts": `import "node:fs";`, + "original.test.ts": ` + import { expect, jest, test, vi } from "bun:test"; + import * as fs from "node:fs"; + + const given = vi.hoisted(() => ({})); + vi.mock("node:fs", async importOriginal => ({ ...(given.original = await importOriginal()), readFileSync: vi.fn(() => "mocked") })); + + test("importOriginal, vi.importActual and jest.requireActual", async () => { + expect(fs.readFileSync("x")).toBe("mocked"); + expect(fs.existsSync(import.meta.path)).toBe(true); + expect(require("node:fs").readFileSync).toBe(fs.readFileSync); + // What the factory got is not the module that the mock is laid over. + expect(given.original.readFileSync(import.meta.path, "utf8")).toContain("importOriginal"); + + const actual = await vi.importActual("node:fs"); + expect(actual).toBe(given.original); + expect(actual.existsSync).toBe(fs.existsSync); + expect(jest.requireActual("node:fs")).toBe(actual.default); + }); + `, + }, + 1, + ["--preload", "./preload.ts", ...args], + ); + }); + + test("importOriginal of a builtin that the test file loaded, in the first mock", async () => { + await expectFixturesToPass( + { + "mock-module.test.ts": ` + import { expect, mock, test } from "bun:test"; + import * as fs from "node:fs"; + + let original; + const patched = mock.module("node:fs", async importOriginal => ({ ...(original = await importOriginal()), readFileSync: () => "mocked" })); + + test("mock.module() below the import", async () => { + await patched; + expect([fs.readFileSync("x"), fs.existsSync(import.meta.path)]).toEqual(["mocked", true]); + expect(original.readFileSync(import.meta.path, "utf8")).toContain("importOriginal"); + }); + `, + "do-mock.test.ts": ` + import { expect, test, vi } from "bun:test"; + import * as os from "node:os"; + + test("vi.doMock() in a test", async () => { + let original; + await vi.doMock("os", async importOriginal => ({ ...(original = await importOriginal()), platform: () => "mocked" })); + expect([os.platform(), original.platform(), os.arch()]).toEqual(["mocked", process.platform, process.arch]); + }); + `, + "fake.cjs": `exports.fake = () => "mocked";`, + "loads-first.test.ts": ` + import { expect, mock, test } from "bun:test"; + import * as fs from "node:fs"; + + let original; + const patched = mock.module("node:fs", async importOriginal => { + const { fake } = require("./fake.cjs"); + return { ...(original = await importOriginal()), readFileSync: fake }; + }); + + test("a factory that loads another module first", async () => { + await patched; + expect([fs.readFileSync("x"), fs.existsSync(import.meta.path)]).toEqual(["mocked", true]); + expect(original.readFileSync(import.meta.path, "utf8")).toContain("importOriginal"); + }); + `, + "not-awaited.test.ts": ` + import { expect, mock, test } from "bun:test"; + import * as path from "node:path"; + + let original; + mock.module("node:path", importOriginal => ((original = importOriginal()), { basename: () => "mocked" })); + + test("a factory that does not wait for importOriginal()", async () => { + expect(path.basename("a/b")).toBe("mocked"); + expect((await original).basename("a/b")).toBe("b"); + }); + `, + "bun-builtin.test.ts": ` + import { expect, mock, test } from "bun:test"; + import * as sqlite from "bun:sqlite"; + + let original; + const patched = mock.module("bun:sqlite", async importOriginal => ({ ...(original = await importOriginal()), Database: "mocked" })); + + test("a bun: builtin", async () => { + await patched; + expect([sqlite.Database, typeof sqlite.Statement, typeof original.Database]).toEqual(["mocked", "function", "function"]); + }); + `, + "native.test.ts": ` + import { expect, mock, test } from "bun:test"; + import * as buffer from "node:buffer"; + + let original; + const patched = mock.module("node:buffer", async importOriginal => ({ ...(original = await importOriginal()), kMaxLength: "mocked" })); + + test("a builtin that native code makes", async () => { + await patched; + expect([buffer.kMaxLength, buffer.Buffer, typeof original.kMaxLength]).toEqual(["mocked", Buffer, "number"]); + }); + `, + "native-lazy.test.ts": ` + import { expect, mock, test } from "bun:test"; + import * as proc from "node:process"; + + let original; + const patched = mock.module("node:process", async importOriginal => ({ ...(original = await importOriginal()), arch: "mocked" })); + + test("a builtin that native code makes, with exports that are made on first use", async () => { + await patched; + expect([proc.arch, proc.platform, original.arch]).toEqual(["mocked", process.platform, process.arch]); + }); + `, + }, + 7, + // Each file starts in a global object that has never mocked a module. + ["--isolate"], + ); + }); + + test("importOriginal of a builtin that an earlier test file loaded, in the first mock", async () => { + await expectFixturesToPass( + { + "1-loads.test.ts": ` + import { expect, test } from "bun:test"; + import * as fs from "node:fs"; + + test("loads the builtin", () => expect(fs.existsSync(import.meta.path)).toBe(true)); + `, + "2-mocks.test.ts": ` + import { expect, test, vi } from "bun:test"; + import * as fs from "node:fs"; + + const given = vi.hoisted(() => ({})); + vi.mock("node:fs", async importOriginal => ({ ...(given.original = await importOriginal()), readFileSync: () => "mocked" })); + + test("mocks it", () => { + expect([fs.readFileSync("x"), fs.existsSync(import.meta.path)]).toEqual(["mocked", true]); + expect(given.original.readFileSync(import.meta.path, "utf8")).toContain("importOriginal"); + }); + `, + }, + 2, + ); + }); + + test("importOriginal of a builtin with no mock registered, and while another original of it loads", async () => { + await expectFixturesToPass( + { + "clear-all.test.ts": ` + import { expect, mock, test } from "bun:test"; + import * as os from "node:os"; + import * as path from "node:path"; + + test("in a factory that Bun.plugin.clearAll() left running", async () => { + const platform = os.platform(); + const cleared = Promise.withResolvers(); + const original = Promise.withResolvers(); + const patched = mock.module("node:os", async importOriginal => { + await cleared.promise; + original.resolve(importOriginal()); + return { ...(await original.promise), platform: () => "mocked" }; + }); + Bun.plugin.clearAll(); + cleared.resolve(); + expect((await original.promise).platform()).toBe(platform); + await patched; + expect(os.platform()).toBe(platform); + }); + + test("by an importOriginal that is first called after Bun.plugin.clearAll()", async () => { + let importOriginal; + mock.module("node:path", original => ((importOriginal = original), { basename: () => "mocked" })); + expect(path.basename("a/b")).toBe("mocked"); + Bun.plugin.clearAll(); + expect((await importOriginal()).basename("a/b")).toBe("b"); + }); + `, + "same-tick.test.ts": ` + import { expect, mock, test } from "bun:test"; + import * as os from "node:os"; + + test("two mocks of one builtin in the same tick", async () => { + const originals = {}; + const factory = name => async importOriginal => ({ ...(originals[name] = await importOriginal()), platform: () => name }); + await Promise.all([mock.module("node:os", factory("first")), mock.module("node:os", factory("second"))]); + expect([os.platform(), originals.first.platform(), originals.second.platform()]).toEqual(["second", process.platform, process.platform]); + }); + `, + "copy.test.ts": ` + import { expect, mock, test } from "bun:test"; + import * as os from "node:os"; + + // A mock is registered, so this is about the copy alone. The second factory asks while "node:os?actual" loads, and gets "node:os?actual&actual". + mock.module("not-a-builtin", () => ({})); + + test("two mocks of one builtin, a microtask apart", async () => { + const originals = {}; + const factory = name => async importOriginal => ({ ...(originals[name] = await importOriginal()), platform: () => name }); + const first = mock.module("node:os", factory("first")); + await null; + const second = mock.module("node:os", factory("second")); + await Promise.all([first, second]); + expect([os.platform(), originals.first.platform(), originals.second.platform()]).toEqual(["second", process.platform, process.platform]); + expect(originals.second).not.toBe(originals.first); + }); + `, + "require.test.ts": ` + import { expect, test, vi } from "bun:test"; + import * as sqlite from "bun:sqlite"; + + const given = vi.hoisted(() => ({})); + vi.mock("bun:sqlite", () => ({ ...(given.original = require("bun:sqlite")), Database: "mocked" })); + + test("require() of the builtin in its own factory, which the first import calls", () => { + expect([sqlite.Database, typeof sqlite.Statement, typeof given.original.Database]).toEqual(["mocked", "function", "function"]); + }); + `, + }, + 5, + ["--isolate"], + ); + }); + + // Under bun test a plugin is asked before the builtins, so one that serves a builtin serves the original of its mock too. + test("importOriginal of a builtin that a plugin serves, and a module that a plugin names like an original", async () => { + const served = ` + import { expect, test, vi } from "bun:test"; + import * as os from "node:os"; + + const given = vi.hoisted(() => ({})); + vi.mock("node:os", async importOriginal => ({ ...(given.original = await importOriginal()), platform: () => "mocked" })); + + test("the plugin is shown the path of the builtin, and what it serves has the name of the builtin", () => { + expect([os.platform(), given.original.platform(), given.original.shown]).toEqual(["mocked", "plugin", "os"]); + expect(given.original.frame).toMatch(/ node:os:\\d+:\\d+$/); + }); + `; + await expectFixturesToPass( + { + "preload.ts": ` + import { plugin } from "bun"; + + plugin({ + name: "serves node:os", + setup(build) { + build.onLoad({ filter: /^os$/, namespace: "node" }, args => ({ + contents: + 'export const platform = () => "plugin"; export const shown = ' + + JSON.stringify(args.path) + + "; export const frame = new Error().stack.split(String.fromCharCode(10))[1];", + loader: "js", + })); + }, + }); + // After the plugin, so that it serves this one too. + await import("node:os"); + `, + "first-mock.test.ts": served, + "later-mock.test.ts": served.replace("const given", `vi.mock("not-a-builtin", () => ({}));\n const given`), + "named.test.ts": ` + import { plugin } from "bun"; + import { expect, test } from "bun:test"; + + plugin({ + name: "names a module like an original", + setup: build => void build.module("node:zlib?actual", () => ({ exports: { from: "plugin" }, loader: "object" })), + }); + + test("import() asks the plugin for it, and require() of a node: name asks the builtins alone", async () => { + const required = require("node:zlib?actual"); + expect([(await import("node:zlib?actual")).from, typeof required.gzipSync]).toEqual(["plugin", "function"]); + }); + `, + }, + 3, + ["--preload", "./preload.ts", "--isolate"], + ); + }); + + test("the original of a builtin, outside bun test", async () => { + using dir = tempDir("mock-module", { + "run.ts": ` + import { plugin } from "bun"; + import { mock, vi } from "bun:test"; + import * as sqlite from "bun:sqlite"; + import * as fs from "node:fs"; + import * as os from "node:os"; + import * as path from "node:path"; + + // Outside bun test no plugin is asked for a builtin, so none is asked for its original. + const asked = []; + plugin({ + name: "records what it is asked for", + setup(build) { + for (const namespace of ["node", "bun"]) build.onLoad({ filter: /.*/, namespace }, args => void asked.push(args.path)); + }, + }); + + let original; + await mock.module("node:fs", async importOriginal => ({ ...(original = await importOriginal()), readFileSync: () => "mocked" })); + + mock.module("node:os", () => ({ platform: () => "mocked" })); + const actual = await vi.importActual("node:os"); + + const required = {}; + await mock.module("node:path", async () => { + await null; + required.path = require("node:path"); + return { basename: () => "mocked" }; + }); + await mock.module("bun:sqlite", async () => { + await null; + required.sqlite = require("bun:sqlite"); + return { Database: "mocked" }; + }); + + // A module that a plugin names like the original of a builtin is still the plugin's. + plugin({ + name: "names a module like an original", + setup: build => void build.module("node:zlib?actual", () => ({ exports: { from: "plugin" }, loader: "object" })), + }); + const virtual = await import("node:zlib?actual"); + + console.log(JSON.stringify({ + importOriginal: [fs.readFileSync("x"), original.readFileSync(import.meta.path, "utf8").includes("importOriginal")], + importActual: [os.platform(), actual.platform() === process.platform], + require: [path.basename("a/b"), required.path.basename("a/b"), sqlite.Database, typeof required.sqlite.Database], + asked, + virtual: virtual.from, + })); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "run.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr }).toEqual({ + stdout: JSON.stringify({ + importOriginal: ["mocked", true], + importActual: ["mocked", true], + require: ["mocked", "b", "mocked", "function"], + asked: [], + virtual: "plugin", + }), + stderr: "", + }); + expect(exitCode).toBe(0); + }); + test("require() returns the original until an async factory settles", async () => { await expectFixturesToPass( {