diff --git a/docs/runtime/module-resolution.mdx b/docs/runtime/module-resolution.mdx index 77760cb4b9ad..9131e4cb6cfa 100644 --- a/docs/runtime/module-resolution.mdx +++ b/docs/runtime/module-resolution.mdx @@ -309,7 +309,7 @@ Bun includes its own implementation of a few npm packages. When you import one o A built-in module does not implement every API of the package it replaces. For example, the built-in `undici` exports `Agent`, `Pool`, `Client`, `ProxyAgent`, and `MockAgent` so that imports succeed. These classes do not manage connections, and `fetch` ignores the `dispatcher` option. `stream()`, `pipeline()`, `connect()`, and `upgrade()` throw an error. If your code needs an API that is missing, [open an issue](https://bun.com/issues). -Bun matches the whole specifier. Any other subpath, such as `undici/package.json`, resolves to the installed package. For a replaced specifier, `require.resolve()` and `import.meta.resolve()` return the name of the built-in module instead of a file path. For example, both `"undici"` and `"next/dist/compiled/undici"` resolve to `"undici"`. +Bun matches the whole specifier. Any other subpath, such as `undici/package.json`, resolves to the installed package. A replaced specifier with a query string, such as `ws?v=1`, does not resolve. `--preload` and the `preload` option of `Worker` load the built-in module too. For a replaced specifier, `require.resolve()` and `import.meta.resolve()` return the name of the built-in module instead of a file path. For example, both `"undici"` and `"next/dist/compiled/undici"` resolve to `"undici"`. [`bun build`](/bundler) applies the same replacement when the target is `bun`, and leaves the import in the bundle. With `--target=node` or `--target=browser`, `bun build` bundles the copy in `node_modules`. diff --git a/src/js_parser_jsc/Macro.rs b/src/js_parser_jsc/Macro.rs index d4b574bf8fab..8eec72a4ef67 100644 --- a/src/js_parser_jsc/Macro.rs +++ b/src/js_parser_jsc/Macro.rs @@ -126,7 +126,7 @@ impl MacroContext { let input_specifier: &[u8] = 'brk: { if let Some(replacement) = ModuleLoader::HardcodedModule::Alias::get( - import_record_path, + import_record_path_without_macro_prefix, bun_ast::Target::Bun, Default::default(), ) { diff --git a/src/jsc/ModuleLoader.rs b/src/jsc/ModuleLoader.rs index 68c53285fe4b..c0acdf810240 100644 --- a/src/jsc/ModuleLoader.rs +++ b/src/jsc/ModuleLoader.rs @@ -202,6 +202,17 @@ pub fn exposed_internal_tag(spec: &[u8]) -> Option<(Vec, crate::ResolvedSour Some((name, tag)) } +/// Whether `name` is a builtin module's: an alias-table key, or an `internal/` module under `--expose-internals`. +pub fn is_builtin(name: &[u8]) -> bool { + HardcodedModule::Alias::has(name, bun_ast::Target::Bun, Default::default()) + || exposed_internal_tag(name).is_some() +} + +/// Whether a preload skips the resolver, which takes a builtin's name for a package. Any `node:` name skips it too. +pub fn preload_skips_resolver(name: &[u8]) -> bool { + name.starts_with(b"node:") || is_builtin(name) +} + /// C++ entry point: whether `data[..len]` names a builtin module. #[unsafe(no_mangle)] unsafe extern "C" fn ModuleLoader__isBuiltin(data: *const u8, len: usize) -> bool { diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index b4873eb211ef..ca1459eeb87a 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5067,7 +5067,7 @@ impl VirtualMachine { /// Note: `is_a_file_path` is a runtime /// arg to avoid duplicating the body for both monomorphizations. - pub(crate) fn _resolve( + fn _resolve( &mut self, ret: &mut ResolveFunctionResult, specifier: &[u8], @@ -5084,6 +5084,7 @@ impl VirtualMachine { // note). Erase to `'static` to seat the result paths without threading // a lifetime parameter through the VM. let specifier: &'static [u8] = unsafe { bun_ptr::detach_lifetime(specifier) }; + debug_assert!(!ModuleLoader::is_builtin(specifier)); // `Runtime.Runtime.Imports.{alt_name, Name}` are both `"bun:wrap"` // (see js_parser/runtime.rs). @@ -5103,14 +5104,6 @@ impl VirtualMachine { ret.path = self.dupe_resolved_path(specifier); return Ok(()); } - if let Some(result) = ModuleLoader::HardcodedModule::Alias::get( - specifier, - bun_ast::Target::Bun, - Default::default(), - ) { - ret.path = result.path.as_bytes(); - return Ok(()); - } if self.module_loader.eval_source.is_some() && (specifier.ends_with(bun_paths::path_literal!("/[eval]").as_bytes()) || specifier.ends_with(bun_paths::path_literal!("/[stdin]").as_bytes())) @@ -5134,6 +5127,10 @@ impl VirtualMachine { let is_special_source = source == MAIN_FILE_NAME || Macro::is_macro_path(source); let mut query_string: &[u8] = b""; let normalized_specifier = normalize_specifier_for_resolution(specifier, &mut query_string); + // `path?v=2` names no module, as in Node: what the cut leaves is the builtin's name, not a package's. + if !query_string.is_empty() && ModuleLoader::is_builtin(normalized_specifier) { + return Err(crate::CrateError::ModuleNotFound); + } let top_level_dir = self.top_level_dir(); let source_to_use: &[u8] = if !is_special_source { if is_a_file_path { diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index af673c56f55b..a73dfb305491 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -327,9 +327,8 @@ impl WebWorker { let mut preloads: Vec> = Vec::with_capacity(preload_modules_len); for module in preload_modules { let utf8_slice = module.to_utf8(); - // node: builtin specifiers skip the file resolver — the worker-side - // module loader resolves them. - if utf8_slice.slice().starts_with(b"node:") { + // The entry-point resolver takes a builtin's name for a package, or for a `./name` file. + if crate::module_loader::preload_skips_resolver(utf8_slice.slice()) { preloads.push(utf8_slice.slice().to_vec().into_boxed_slice()); continue; } diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 09239c6db4df..18981784d21d 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -751,9 +751,8 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS .strip_prefix(b"file://".as_slice()) .unwrap_or(preload_slice); - // node: builtin specifiers bypass the file resolver — JSModuleLoader - // resolves them internally, so `bun --import node:*` works like Node's. - let module_name = if normalized.starts_with(b"node:") { + // JSModuleLoader resolves a builtin's name: `bun --import path` works like Node's. + let module_name = if bun_jsc::module_loader::preload_skips_resolver(normalized) { bun_core::String::from_bytes(normalized) } else { // ── resolve ───────────────────────────────────────────────────── diff --git a/test/bundler/transpiler/macro-test.test.ts b/test/bundler/transpiler/macro-test.test.ts index 4b5254346695..8c120a7a7464 100644 --- a/test/bundler/transpiler/macro-test.test.ts +++ b/test/bundler/transpiler/macro-test.test.ts @@ -21,6 +21,33 @@ test("bun builtins can be used in macros", async () => { expect(escapeHTML("abc!")).toBe("abc!"); }); +// `macro:path` is the builtin, as `"path" with { type: "macro" }` is: not the package of that name. +test("a macro: import of a builtin's name does not run the package of that name in node_modules", async () => { + using dir = tempDir("macro-builtin-name", { + "node_modules/path/package.json": JSON.stringify({ name: "path", version: "1.0.0", main: "index.js" }), + "node_modules/path/index.js": `exports.basename = () => "node_modules/path";`, + "index.js": ` + import { basename } from "macro:path"; + import { basename as withAttribute } from "path" with { type: "macro" }; + console.log(basename("/a/b.txt"), withAttribute("/a/b.txt")); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // Debug builds print "[macro] call basename" to stdout first, so only the tail of stdout is matched. + expect({ stdout, stderr, exitCode }).toMatchObject({ + stdout: expect.stringMatching(/(^|\n)b\.txt b\.txt\n$/), + stderr: "", + exitCode: 0, + }); +}); + test("latin1 string", () => { expect(identity("©")).toBe("©"); }); diff --git a/test/cli/run/preload-test.test.js b/test/cli/run/preload-test.test.js index 7bf569149458..497ed1590165 100644 --- a/test/cli/run/preload-test.test.js +++ b/test/cli/run/preload-test.test.js @@ -1,7 +1,7 @@ import { spawnSync } from "bun"; import { describe, expect, test } from "bun:test"; import { mkdirSync, realpathSync } from "fs"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, isDebug, tempDir } from "harness"; import { tmpdir } from "os"; import { join } from "path"; const preloadModule = ` @@ -191,6 +191,152 @@ plugin({ expect({ stdout, stderr, exitCode }).toEqual({ stdout: "main ran\n", stderr: "", exitCode: 0 }); }); + // A builtin's bare name is the builtin, as it is to `import` and to `node -r path`. The resolver would take it + // for a package. "abort-controller" is a package that Bun replaces with a built-in module. + describe("of a builtin by its bare name", () => { + const builtins = ["path", "fs/promises", "bun:sqlite", "abort-controller", "events"]; + const packages = (...names) => + Object.fromEntries( + names.flatMap(name => [ + [`node_modules/${name}/package.json`, JSON.stringify({ name, version: "1.0.0", main: "index.js" })], + [`node_modules/${name}/index.js`, `console.log("node_modules/${name} ran");`], + ]), + ); + + async function run(cwd, args) { + const requests = []; + await using registry = Bun.serve({ + port: 0, + fetch(req) { + requests.push(new URL(req.url).pathname); + return new Response("{}", { status: 404, headers: { "content-type": "application/json" } }); + }, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args, "main.js"], + env: { + ...bunEnv, + NODE_NO_WARNINGS: undefined, + BUN_CONFIG_REGISTRY: registry.url.href, + NPM_CONFIG_REGISTRY: registry.url.href, + }, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode, requests }; + } + + test.each(["--preload", "--import", "--require", "bunfig.toml"])( + "%s does not run a file that the resolver has for that name", + async how => { + // A package that is no builtin, and a file, are still the resolver's. + const preloads = [...builtins, "lodashy", "./setup.js"]; + using dir = tempDir("preload-builtin-name-resolver", { + ...packages("path", "fs", "abort-controller", "lodashy"), + "node_modules/fs/promises.js": `console.log("node_modules/fs/promises.js ran");`, + "tsconfig.json": JSON.stringify({ compilerOptions: { baseUrl: "./src" } }), + "src/events.js": `console.log("src/events.js ran");`, + "setup.js": `console.log("setup.js ran");`, + "main.js": `console.log("main ran");`, + ...(how === "bunfig.toml" ? { "bunfig.toml": `preload = ${JSON.stringify(preloads)}` } : {}), + }); + + const args = how === "bunfig.toml" ? [] : preloads.flatMap(name => [how, name]); + expect(await run(String(dir), args)).toEqual({ + stdout: "node_modules/lodashy ran\nsetup.js ran\nmain ran\n", + stderr: "", + exitCode: 0, + requests: [], + }); + }, + ); + + test("--preload does not ask the registry for a package of that name", async () => { + using dir = tempDir("preload-builtin-name-autoinstall", { "main.js": `console.log("main ran");` }); + const args = builtins.flatMap(name => ["--preload", name]); + expect(await run(String(dir), args)).toEqual({ stdout: "main ran\n", stderr: "", exitCode: 0, requests: [] }); + }); + + // The name that goes to the resolver is the one without `file://`, so that name is the one to test. + test("--preload file://path does not run the package of that name", async () => { + using dir = tempDir("preload-builtin-name-file-url", { + ...packages("path"), + "main.js": `console.log("main ran");`, + }); + const { stdout, requests } = await run(String(dir), ["--preload", "file://path"]); + expect({ stdout, requests }).toEqual({ stdout: "main ran\n", requests: [] }); + }); + + const worker = preload => ` + const worker = new Worker("./worker.js", { preload: ${JSON.stringify(preload)} }); + worker.onmessage = e => { + console.log(e.data); + worker.terminate(); + }; + worker.onerror = e => { + console.log("error: " + e.message); + }; + `; + + // The `preload` option of a Worker is "equivalent to passing the `--preload` CLI argument". Its resolver also + // takes a bare name for a `./name` file: `util` is the builtin, and `./util` is the file. + test("Worker preload does not run a file that the resolver has for that name", async () => { + using dir = tempDir("preload-builtin-name-worker", { + ...packages("path", "abort-controller", "lodashy"), + "util.ts": `console.log("util.ts ran");`, + "worker.js": `postMessage("main ran");`, + "main.js": worker(["path", "bun:sqlite", "abort-controller", "util", "./util", "lodashy"]), + }); + expect(await run(String(dir), [])).toEqual({ + stdout: "util.ts ran\nnode_modules/lodashy ran\nmain ran\n", + stderr: "", + exitCode: 0, + requests: [], + }); + }); + + // A preload that is skipped passes the tests above too. Only the builtin `_tls_wrap` warns when it loads. + // It loads `node:tls`, which takes seconds in a debug build. + test.skipIf(isDebug).each(["--preload", "Worker preload"])("%s loads the builtin", async how => { + using dir = tempDir("preload-builtin-name-loads", { + ...packages("_tls_wrap"), + "worker.js": `postMessage("main ran");`, + "main.js": how === "--preload" ? `console.log("main ran");` : worker(["_tls_wrap"]), + }); + const { stdout, stderr, exitCode } = await run(String(dir), how === "--preload" ? [how, "_tls_wrap"] : []); + // "(node:) ", then a hint that names the executable. + const warnings = stderr + .split("\n") + .map(line => line.replace(/^\(node:\d+\) /, "")) + .filter(line => line && !line.startsWith("(Use `")); + expect({ stdout, warnings, exitCode }).toEqual({ + stdout: "main ran\n", + warnings: ["[DEP0192] DeprecationWarning: The _tls_wrap module is deprecated. Use `node:tls` instead."], + exitCode: 0, + }); + }); + }); + + // Under `--expose-internals` an `internal/` module is a builtin too, and `internal` is a package's name. + test("--preload of an internal/ module under --expose-internals", async () => { + using dir = tempDir("preload-builtin-name-expose-internals", { + "node_modules/internal/package.json": JSON.stringify({ name: "internal", version: "1.0.0" }), + "node_modules/internal/validators.js": `console.log("node_modules/internal/validators.js ran");`, + "main.js": `console.log("main ran");`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--expose-internals", "--preload", "internal/validators", "main.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "main ran\n", stderr: "", exitCode: 0 }); + }); + test("throws an error when preloaded module not found", async () => { const bunfig = `preload = ["./bad-file.js"]`; diff --git a/test/js/bun/resolve/import-query.test.ts b/test/js/bun/resolve/import-query.test.ts index cb39878bba5a..43a75f4bb1c2 100644 --- a/test/js/bun/resolve/import-query.test.ts +++ b/test/js/bun/resolve/import-query.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, expect, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "bun:test"; import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; globalThis.importQueryFixtureOrder = []; const resolvedPath = require.resolve("./import-query-fixture.ts"); @@ -234,3 +234,203 @@ test("Bun.resolveSync with non-ASCII specifier and query string", async () => { expect(resolved).toEndWith("target.js?v=caf\u00e9-\u65e5\u672c\u8a9e"); expect(exitCode).toBe(0); }); + +// A builtin takes no query: Node reports `path?v=2` as a module that does not exist. The name that is left when +// the query is cut is the builtin's, so it is not a package to look up in node_modules or in the registry either. +describe("a builtin's name with a ?query", () => { + const specifiers = ["path?v=2", "path?", "fs/promises?x", "ws?x", "bun?x"]; + const every = (code: string) => specifiers.map(() => code); + const notFound = { + "static import": every("ERR_MODULE_NOT_FOUND"), + "export from": every("ERR_MODULE_NOT_FOUND"), + "import() of a literal": every("ERR_MODULE_NOT_FOUND"), + "require() of a literal": every("MODULE_NOT_FOUND"), + "import()": every("ERR_MODULE_NOT_FOUND"), + "require()": every("MODULE_NOT_FOUND"), + "require.resolve()": every("MODULE_NOT_FOUND"), + "createRequire()": every("MODULE_NOT_FOUND"), + "import.meta.resolve()": every("ERR_MODULE_NOT_FOUND"), + "Bun.resolveSync()": every("ERR_MODULE_NOT_FOUND"), + }; + + // What naming a module gives: the code of the error, or what it loaded. A package of these fixtures exports its + // own path. + const outcome = `async run => { + try { + const value = await run(); + const exported = typeof value === "string" ? value : value.default; + return typeof exported === "string" ? "loaded " + exported : "loaded a builtin"; + } catch (e) { + return e.code; + } + }`; + + // Every way to name a module, with each specifier: as a literal (an import record of the file) and as a value. + const files: Record = { + "forms.cjs": `module.exports = { require: s => require(s), resolve: s => require.resolve(s) };`, + "run.mjs": ` + import { createRequire } from "node:module"; + import cjs from "./forms.cjs"; + const specifiers = ${JSON.stringify(specifiers)}; + const forms = { + "static import": i => import("./static-" + i + ".mjs"), + "export from": i => import("./reexport-" + i + ".mjs"), + "import() of a literal": i => cjs.require("./literal-" + i + ".cjs").imp(), + "require() of a literal": i => cjs.require("./literal-" + i + ".cjs").req(), + "import()": i => import(specifiers[i]), + "require()": i => cjs.require(specifiers[i]), + "require.resolve()": i => cjs.resolve(specifiers[i]), + "createRequire()": i => createRequire(import.meta.url)(specifiers[i]), + "import.meta.resolve()": i => import.meta.resolve(specifiers[i]), + "Bun.resolveSync()": i => Bun.resolveSync(specifiers[i], import.meta.dir), + }; + const outcome = ${outcome}; + const out = {}; + for (const [form, run] of Object.entries(forms)) { + out[form] = []; + for (let i = 0; i < specifiers.length; i++) out[form].push(await outcome(() => run(i))); + } + const controls = {}; + for (const specifier of process.argv.slice(2)) controls[specifier] = await outcome(() => cjs.require(specifier)); + console.log(JSON.stringify({ out, controls })); + `, + "entry.mjs": `import "path?v=2"; console.log("entry ran");`, + }; + specifiers.forEach((specifier, i) => { + const literal = JSON.stringify(specifier); + files[`static-${i}.mjs`] = `import * as m from ${literal}; export default m.default;`; + files[`reexport-${i}.mjs`] = `export { default } from ${literal};`; + files[`literal-${i}.cjs`] = `exports.req = () => require(${literal}); exports.imp = () => import(${literal});`; + }); + + // Nothing listens on the default registry: a test that expects no install does not reach the network if it fails. + async function run(cwd: string, args: string[], registry = "http://127.0.0.1:1/") { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + env: { ...bunEnv, BUN_CONFIG_REGISTRY: registry, NPM_CONFIG_REGISTRY: registry }, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test("does not load the package of that name in node_modules", async () => { + const packages: Record = {}; + for (const name of ["path", "fs", "ws", "bun", "lodashy"]) { + packages[`node_modules/${name}/package.json`] = JSON.stringify({ name, version: "1.0.0", main: "index.js" }); + packages[`node_modules/${name}/index.js`] = `module.exports = "node_modules/${name}";`; + } + packages["node_modules/fs/promises.js"] = `module.exports = "node_modules/fs/promises.js";`; + using dir = tempDir("import-query-builtin-node-modules", { ...files, ...packages }); + + const controls = ["path", "node:path?v=2", "lodashy?v=1", "path/"]; + const { stdout, stderr, exitCode } = await run(String(dir), ["run.mjs", ...controls]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + out: notFound, + controls: { + "path": "loaded a builtin", + // Bun's code for this was already Node's. + "node:path?v=2": "ERR_UNKNOWN_BUILTIN_MODULE", + // Not changed here. Node rejects a query on a package, and Bun has no rule for it yet (#36275). + "lodashy?v=1": "loaded node_modules/lodashy", + // As in Node, a trailing slash names the package and not the builtin. + "path/": "loaded node_modules/path", + }, + }); + expect(exitCode).toBe(0); + }); + + test("does not ask the registry for a package of that name", async () => { + using dir = tempDir("import-query-builtin-autoinstall", files); + const requests: string[] = []; + await using registry = Bun.serve({ + port: 0, + fetch(req) { + requests.push(new URL(req.url).pathname); + return new Response("{}", { status: 404, headers: { "content-type": "application/json" } }); + }, + }); + + // `--install=force` installs whatever the resolver takes for a package, with or without a node_modules. + const { stdout, stderr, exitCode } = await run(String(dir), ["--install=force", "run.mjs"], registry.url.href); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ out: notFound, controls: {} }); + expect(requests).toEqual([]); + expect(exitCode).toBe(0); + }); + + // The entry file is always transpiled on the JS thread, unlike the files that run.mjs imports. + test("does not ask the registry when the entry file imports it", async () => { + using dir = tempDir("import-query-builtin-autoinstall-entry", files); + const requests: string[] = []; + await using registry = Bun.serve({ + port: 0, + fetch(req) { + requests.push(new URL(req.url).pathname); + return new Response("{}", { status: 404, headers: { "content-type": "application/json" } }); + }, + }); + + const { stdout, stderr, exitCode } = await run(String(dir), ["--install=force", "entry.mjs"], registry.url.href); + expect(stdout).toBe(""); + expect(stderr).toContain("Cannot find package 'path?v=2'"); + expect(requests).toEqual([]); + expect(exitCode).toBe(1); + }); + + // The resolver has more than node_modules for a name: tsconfig `paths` and `baseUrl` too. + test("does not load the file that tsconfig maps that name to", async () => { + using dir = tempDir("import-query-builtin-tsconfig", { + "tsconfig.json": JSON.stringify({ compilerOptions: { baseUrl: "./src", paths: { ws: ["./ws-shim.js"] } } }), + "src/constants.js": `module.exports = "src/constants.js";`, + "src/ws-shim.js": `module.exports = "src/ws-shim.js";`, + "src/lodashy.js": `module.exports = "src/lodashy.js";`, + "run.cjs": ` + const outcome = ${outcome}; + (async () => { + const out = {}; + for (const specifier of ["constants?x", "ws?x", "constants", "lodashy?v=1"]) { + out[specifier] = await outcome(() => require(specifier)); + } + console.log(JSON.stringify(out)); + })(); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["run.cjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + "constants?x": "MODULE_NOT_FOUND", + "ws?x": "MODULE_NOT_FOUND", + "constants": "loaded a builtin", + "lodashy?v=1": "loaded src/lodashy.js", + }); + expect(exitCode).toBe(0); + }); + + // Under `--expose-internals` an `internal/` module is a builtin too, and `internal` is a package's name. + test("does not load node_modules/internal for an internal/ module under --expose-internals", async () => { + using dir = tempDir("import-query-builtin-expose-internals", { + "node_modules/internal/package.json": JSON.stringify({ name: "internal", version: "1.0.0" }), + "node_modules/internal/validators.js": `module.exports = "node_modules/internal/validators.js";`, + "run.cjs": ` + const outcome = ${outcome}; + (async () => { + console.log( + JSON.stringify([ + await outcome(() => require("internal/validators")), + await outcome(() => require("internal/validators?x")), + await outcome(() => import("internal/validators?x")), + ]), + ); + })(); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["--expose-internals", "run.cjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(["loaded a builtin", "MODULE_NOT_FOUND", "ERR_MODULE_NOT_FOUND"]); + expect(exitCode).toBe(0); + }); +});