diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 225fe3568023..98ed2315e03e 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -1980,7 +1980,6 @@ pub mod bv2_impl { let this_transpiler: &Transpiler<'a> = &*self.transpiler; let this_compile = this_transpiler.options.compile_mode.is_executable(); - let this_env = this_transpiler.env; // SAFETY: `self.transpiler` (and the data its `&'a` fields borrow) // outlives this `BundleV2<'a>`; `for_worker` widens those borrows to @@ -2035,7 +2034,6 @@ pub mod bv2_impl { // Re-project the resolver subset now that `target`/`conditions` etc. // have been overwritten for the browser. boxed.sync_resolver_opts(); - boxed.resolver.env_loader = NonNull::new(this_env.cast()); // Park the owning Box first, then derive both the published `NonNull` // and the returned `&mut` from its final resting place. Taking the diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 1f4678fa6c28..293e4a763779 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -159,7 +159,7 @@ impl<'a> Transpiler<'a> { } /// Reborrow the `DotEnv::Loader`. Returned lifetime is decoupled from - /// `&self` so call sites in `configure_defines` / `run_env_loader` can + /// `&self` so call sites in `configure_defines` / `load_env` can /// hold it across disjoint `&mut self.options` / `&mut self.resolver` /// borrows. #[inline] @@ -605,6 +605,14 @@ impl<'a> Transpiler<'a> { /// Load `.env` files into the env loader according to /// `options.env.behavior`. pub fn run_env_loader(&mut self, skip_default_env: bool) -> crate::Result<()> { + let loaded = self.load_env(skip_default_env); + // `load_env` also returns early with the process env loaded: an error, or a cwd that it cannot list. + let node_path = self.env().get(b"NODE_PATH").unwrap_or(b""); + self.resolver.set_node_path(node_path); + loaded + } + + fn load_env(&mut self, skip_default_env: bool) -> crate::Result<()> { use bun_options_types::schema::api::DotEnvBehavior; // Derived once up front; no other live `&mut` to this `Loader` exists // for the duration of this call. @@ -1118,6 +1126,8 @@ impl<'a> Transpiler<'a> { } }, }; + let env_loader_nn = + core::ptr::NonNull::new(env_loader).expect("Transpiler::init: env loader is non-null"); if dot_env::instance().is_none() { dot_env::set_instance(env_loader); @@ -1161,7 +1171,7 @@ impl<'a> Transpiler<'a> { outbase, ..Default::default() }, - resolver: Resolver::init1(log_nn, fs, resolver_opts), + resolver: Resolver::init1(log_nn, fs, env_loader_nn, resolver_opts), output_files: Vec::new(), resolve_results, resolve_queue: ResolveQueue::default(), diff --git a/src/install/auto_installer.rs b/src/install/auto_installer.rs index aa6af65c6bc7..1120fba8dc3f 100644 --- a/src/install/auto_installer.rs +++ b/src/install/auto_installer.rs @@ -436,7 +436,7 @@ fn __bun_resolver_parse_dependency( // process-lifetime; `init_with_runtime` stores it raw). // • `install` is `BundleOptions.install` (`?*Api.BunInstall`). The pointee is // the CLI-owned `Box` (process-lifetime), read-only. -// • `env` is the resolver's unwrapped `env_loader` (Transpiler-owned, +// • `env` is the resolver's `env_loader` (Transpiler-owned, // process-lifetime). `init_with_runtime` stores it as `NonNull`. #[unsafe(no_mangle)] #[inline(never)] diff --git a/src/install_jsc/install_binding.rs b/src/install_jsc/install_binding.rs index b6e2df49f3ef..0f2f6ded6855 100644 --- a/src/install_jsc/install_binding.rs +++ b/src/install_jsc/install_binding.rs @@ -2,8 +2,6 @@ use bun_jsc::bindgen::JsScope; use bun_jsc::{JSValue, StringJsc as _}; pub(crate) fn parse_lockfile(cx: &mut JsScope<'_>, cwd: &[u8]) -> bun_jsc::JsResult { - use core::ptr::NonNull; - use bstr::BStr; use bun_install::lockfile::lockfile_json_stringify_for_debugging::{ WriteStream, WriteStreamOptions, json_stringify, @@ -35,15 +33,9 @@ pub(crate) fn parse_lockfile(cx: &mut JsScope<'_>, cwd: &[u8]) -> bun_jsc::JsRes let mut lockfile_ = Lockfile::default(); - // `bun_vm()` returns a raw `*mut VirtualMachine`; deref locally so the - // env-loader fixup and the package-manager borrow are scoped - // independently. // SAFETY: `bun_vm()` returns the live VM that owns `global`; this host fn // runs on the JS thread so no concurrent `&mut VirtualMachine` exists. let vm = global.bun_vm().as_mut(); - if vm.transpiler.resolver.env_loader.is_none() { - vm.transpiler.resolver.env_loader = NonNull::new(vm.transpiler.env); - } // as long as we aren't migration from `package-lock.json`, leaving this undefined is okay let manager = vm.package_manager(); diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index edbedae6c0fc..8209d08b776f 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4271,18 +4271,20 @@ pub enum ResolveMode { RequireResolve, /// `import.meta.resolve()`, `Bun.resolveSync()`: ESM resolution that loads nothing. EsmResolve, + /// The key of a module mock: as `Esm`, but under `bun test` a package that only a hoist folder of `NODE_PATH` has does not resolve. + ModuleMock, } impl ResolveMode { #[inline] pub fn is_esm(self) -> bool { - matches!(self, Self::Esm | Self::EsmResolve) + matches!(self, Self::Esm | Self::EsmResolve | Self::ModuleMock) } #[inline] pub fn import_kind(self) -> bun_ast::ImportKind { match self { - Self::Esm | Self::EsmResolve => bun_ast::ImportKind::Stmt, + Self::Esm | Self::EsmResolve | Self::ModuleMock => bun_ast::ImportKind::Stmt, Self::Require => bun_ast::ImportKind::Require, Self::RequireResolve => bun_ast::ImportKind::RequireResolve, } @@ -5501,7 +5503,7 @@ impl VirtualMachine { ret: &mut ResolveFunctionResult<'r>, specifier: &'r [u8], source: &[u8], - is_esm: bool, + mode: ResolveMode, is_a_file_path: bool, global_cache: bun_resolver::GlobalCache, ) -> crate::CrateResult<()> { @@ -5573,7 +5575,7 @@ impl VirtualMachine { // the first miss. let mut retry_on_not_found = bun_paths::is_absolute(source_to_use); let result: bun_resolver::Result = loop { - let import_kind = if is_esm { + let import_kind = if mode.is_esm() { bun_ast::ImportKind::Stmt } else { bun_ast::ImportKind::Require @@ -5584,7 +5586,26 @@ impl VirtualMachine { import_kind, global_cache, ) { - ResultUnion::Success(r) => break r, + ResultUnion::Success(r) => { + if r.flags.is_from_node_path() { + // The chai that vitest depends on comes before one that only NODE_PATH has. + if let Some(chai) = self.resolve_chai_next_to_vitest( + normalized_specifier, + source_to_use, + import_kind, + ) { + break chai; + } + // Reported as not found: the mock then keeps its name as the key, and so it replaces every copy of the package. + if mode == ResolveMode::ModuleMock + && r.flags.is_from_hoist_folder() + && self.transpiler.options.rewrite_jest_for_tests + { + return Err(crate::CrateError::ModuleNotFound); + } + } + break r; + } ResultUnion::Failure(e) => return Err(e.into()), ResultUnion::Pending(_) | ResultUnion::NotFound => { if !retry_on_not_found { @@ -5974,7 +5995,7 @@ impl VirtualMachine { &mut result, specifier_utf8.slice(), normalize_source(source_utf8.slice()), - mode.is_esm(), + mode, IS_A_FILE_PATH, global_cache, ); diff --git a/src/jsc/bindings/BunPlugin.cpp b/src/jsc/bindings/BunPlugin.cpp index aa3abd8e5f89..f0f6c5e1aa08 100644 --- a/src/jsc/bindings/BunPlugin.cpp +++ b/src/jsc/bindings/BunPlugin.cpp @@ -3938,6 +3938,7 @@ __attribute__((minsize)) static bool resolveModuleMockSpecifier(Zig::GlobalObjec BunString from = Bun::toString(fromString); // Not resolving is fine (mocking a module that does not exist yet); anything else thrown // while resolving (e.g. by an onResolve plugin) propagates. + // Under `bun test` a package that only a hoist folder of NODE_PATH has does not resolve here either: see ResolveMode::ModuleMock. auto result = JSValue::decode(Bun__resolveSyncWithSourceIfExists(globalObject, JSValue::encode(specifierString), &from, true)); RETURN_IF_EXCEPTION(scope, false); diff --git a/src/jsc/bindings/BunPlugin.h b/src/jsc/bindings/BunPlugin.h index 96c3139a6d7f..caa00aedbb5b 100644 --- a/src/jsc/bindings/BunPlugin.h +++ b/src/jsc/bindings/BunPlugin.h @@ -301,7 +301,8 @@ class BunPlugin { enum class Asker : uint8_t { Import, Require, RequireResolve, - ImportResolve }; + ImportResolve, + ModuleMock }; JSC::EncodedJSValue run(JSC::JSGlobalObject* globalObject, const BunString* namespaceString, const BunString* path, const BunString* importer, const BunString* importerNamespace, const BunString* resolveDir, Asker); // Set by import() and consumed by the loader's resolve hook. diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index d15705aa5d5b..a3b8440316c5 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1062,7 +1062,6 @@ impl WebWorker { // SAFETY: `vm` is a valid heap-allocated VM ptr (checked above). unsafe { let b = &mut (*vm).transpiler; - b.resolver.env_loader = NonNull::new(b.env); b.options.env.behavior = bun_options_types::schema::api::DotEnvBehavior::LoadAllWithoutInlining; diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index e8f7f075939e..ec6df3280a92 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -498,12 +498,13 @@ pub struct Resolver<'a> { pub package_manager: Option>, pub on_wake_package_manager: Install::WakeHandler, // Stored as `NonNull` (not `&'a Loader`) because the same allocation is - // mutably reborrowed via `Transpiler.env: *mut Loader` after this field is - // set (e.g. bake/production/mod.rs assigns this then calls `configure_defines()` + // mutably reborrowed via `Transpiler.env: *mut Loader` (`configure_defines()` // → `run_env_loader()` which takes `&mut *self.env`). Holding a live // `&Loader` across that `&mut Loader` would be aliased-&mut UB; a raw // pointer carries no aliasing guarantee. - pub env_loader: Option>, + env_loader: NonNull, + /// `NODE_PATH` as of the owning transpiler's `run_env_loader`: a resolve can run on a thread that does not own the loader. A later load by another transpiler on a shared loader does not change it. + node_path: Interned, pub store_fd: bool, pub standalone_module_graph: Option<&'static dyn StandaloneModuleGraph>, @@ -604,7 +605,7 @@ impl<'a> Resolver<'a> { /// fresh `BundleOptions` subset and arena-allocates its own `Log`). /// /// # Safety - /// `env_loader()` returns `&'a Loader` and the caller picks `'a`: the + /// Auto-install receives the copied `env_loader`, and the caller picks `'a`: the /// loader that `from.env_loader` points to must outlive `'a`. pub unsafe fn for_worker( from: &Resolver<'_>, @@ -631,6 +632,7 @@ impl<'a> Resolver<'a> { package_manager: from.package_manager, on_wake_package_manager: from.on_wake_package_manager, env_loader: from.env_loader, + node_path: from.node_path, store_fd: from.store_fd, standalone_module_graph: from.standalone_module_graph, outfile_of_build: from.outfile_of_build, @@ -832,14 +834,11 @@ impl<'a> Resolver<'a> { if let Some(pm) = self.package_manager { return Ok(pm.as_ptr()); } - let env: NonNull = self - .env_loader - .expect("Resolver.env_loader must be set before auto-install"); - // SAFETY: `__bun_resolver_init_package_manager` is defined - // `#[no_mangle]` in `bun_install::auto_installer` and linked into the - // final binary; `self.log` / `self.opts.install` / `env` point at - // process-lifetime storage (Transpiler-owned). The returned pointer - // names the `PackageManager` singleton (`'static`). + // SAFETY: `__bun_resolver_init_package_manager` is defined `#[no_mangle]` + // in `bun_install::auto_installer` and linked into the final binary. The + // `PackageManager` singleton it returns (`'static`) keeps `self.log` and + // `self.env_loader`, and reads `self.opts.install`: the caller's + // transpiler has to own storage that lives as long as the process. let pm: NonNull = unsafe { bun_removable_features::call_removable!( Install, @@ -847,7 +846,7 @@ impl<'a> Resolver<'a> { __bun_resolver_init_package_manager, self.log, self.opts.install, - env, + self.env_loader, self.opts.install_preference == bun_options_types::offline_mode::OfflineMode::Offline, importer_dir, @@ -919,22 +918,29 @@ impl<'a> Resolver<'a> { } } - /// Safe read-only accessor for the optional `DotEnv::Loader` back-reference. - /// - /// Single `unsafe` deref site for the `env_loader: Option>` - /// field. The pointee is the Transpiler-owned loader (set from - /// `transpiler.env`) and strictly outlives the resolver. Only called once - /// resolution has begun (after `run_env_loader()`), so no `&mut Loader` is - /// live concurrently — see the field comment for why this is *not* stored - /// as `Option<&'a Loader>`. - #[inline] - pub(crate) fn env_loader(&self) -> Option<&'a DotEnv::Loader> { - // SAFETY: BACKREF — `env_loader` names the Transpiler-owned - // `DotEnv::Loader`, live for the resolver's lifetime `'a`; resolution - // never mutates the env, so no `&mut Loader` overlaps this shared - // borrow. Returned as `&'a` (not tied to `&self`) so callers may keep - // the env borrow across `&mut self` resolver calls. - self.env_loader.map(|p| unsafe { p.as_ref() }) + /// `Transpiler::run_env_loader` hands over the `NODE_PATH` of its loader. + pub fn set_node_path(&mut self, node_path: &[u8]) { + if *self.node_path != *node_path { + self.node_path = Self::intern_node_path(node_path); + } + } + + /// Returns a `'static` copy of `node_path`. Copies of a resolver on other threads keep the slice, so a published one is never freed. + fn intern_node_path(node_path: &[u8]) -> Interned { + static CACHE: bun_core::Mutex>> = + bun_core::Mutex::new(None); + if node_path.is_empty() { + return Interned::EMPTY; + } + let mut cache = CACHE.lock(); + let cache = cache.get_or_insert_default(); + if let Some((&interned, ())) = cache.get_key_value(node_path) { + return Interned::from_static(interned); + } + let interned = + bun_core::handle_oom(Fs::file_system::DirnameStore::instance().append(node_path)); + cache.insert(interned, ()); + Interned::from_static(interned) } #[inline] @@ -961,9 +967,11 @@ impl<'a> Resolver<'a> { self.opts.global_cache.is_enabled() } + /// `env_loader` is the loader of the `Transpiler` that owns this resolver. It outlives the resolver, and auto-install receives it. pub fn init1( log: NonNull, _fs: *mut Fs::FileSystem, + env_loader: NonNull, opts: options::BundleOptions, ) -> Self { // resolver_Mutex_loaded check elided; static is const-inited in Rust. @@ -990,7 +998,8 @@ impl<'a> Resolver<'a> { generation: 0, package_manager: None, on_wake_package_manager: Default::default(), - env_loader: None, + env_loader, + node_path: Interned::EMPTY, store_fd: false, standalone_module_graph: None, outfile_of_build: None, @@ -2704,6 +2713,10 @@ impl<'a> Resolver<'a> { result.flags.set_is_from_node_modules( result.flags.is_from_node_modules() || res.is_node_module, ); + result.flags.set_is_from_node_path(res.is_from_node_path); + result + .flags + .set_is_from_hoist_folder(res.is_from_hoist_folder); result.module_type = res.module_type; // Potentially rewrite the import path if it's external that // was remapped to a different path @@ -3219,10 +3232,7 @@ impl<'a> Resolver<'a> { // try resolve from `NODE_PATH` // https://nodejs.org/api/modules.html#loading-from-the-global-folders - let node_path: &[u8] = self - .env_loader() - .and_then(|env| env.get(b"NODE_PATH")) - .unwrap_or(b""); + let node_path: &[u8] = self.node_path.as_bytes(); if !node_path.is_empty() { let delim = if cfg!(windows) { b';' } else { b':' }; for path in node_path.split(|&b| b == delim).filter(|s| !s.is_empty()) { @@ -3242,6 +3252,8 @@ impl<'a> Resolver<'a> { .load_as_file_or_directory(abs_path, kind, out) .is_success() { + out.is_from_node_path = true; + out.is_from_hoist_folder = is_hoist_folder(path); if let Some(d) = self.debug_logs.as_mut() { d.decrease_indent(); } @@ -7176,6 +7188,21 @@ fn is_require(kind: ast::ImportKind) -> bool { ) } +/// Whether `dir` is a `node_modules` folder inside another `node_modules` folder. +fn is_hoist_folder(mut dir: &[u8]) -> bool { + let separators: &[u8] = if cfg!(windows) { b"/\\" } else { b"/" }; + while let [parent @ .., last] = dir + && separators.contains(last) + { + dir = parent; + } + let Some(parent) = dir.strip_suffix(b"node_modules") else { + return false; + }; + parent.last().is_some_and(|last| separators.contains(last)) + && strings::tokenize_any(parent, separators).any(|folder| folder == b"node_modules") +} + /// TypeScript matches `./foo.js` with `./foo.ts` (microsoft/TypeScript#4595). This /// is esbuild's `rewrittenFileExtensions` table plus Bun's `.js` → `.mts` /// (oven-sh/bun#12580). `inside_node_modules` gates only `.mjs` and `.cjs`: @@ -7254,3 +7281,45 @@ impl Dirname { &path[0..end_index + 1] } } + +#[cfg(test)] +mod tests { + use bun_sema_standalone as _; + + use super::*; + + #[test] + fn a_node_path_value_is_interned_once() { + let value: &[u8] = b"/node-path-tests/a:/node-path-tests/b"; + let first = Resolver::intern_node_path(&value.to_vec()); + let again = Resolver::intern_node_path(&value.to_vec()); + let on_a_thread = std::thread::spawn(move || Resolver::intern_node_path(value)) + .join() + .unwrap(); + assert_eq!(first.as_bytes(), value); + assert!(core::ptr::eq(first.as_bytes(), again.as_bytes())); + assert!(core::ptr::eq(first.as_bytes(), on_a_thread.as_bytes())); + + let other = Resolver::intern_node_path(b"/node-path-tests/c"); + assert_eq!(other.as_bytes(), b"/node-path-tests/c"); + assert!(Resolver::intern_node_path(b"").is_empty()); + } + + #[test] + fn a_hoist_folder_is_a_node_modules_folder_inside_another() { + assert!(is_hoist_folder(b"/app/node_modules/.pnpm/node_modules")); + assert!(is_hoist_folder( + b"/app/node_modules/.pnpm/cross-env@7.0.3/node_modules/" + )); + assert!(is_hoist_folder(b"node_modules/.pnpm/node_modules")); + assert!(!is_hoist_folder(b"/deps/node_modules")); + assert!(!is_hoist_folder(b"/deps/node_modules/")); + assert!(!is_hoist_folder(b"node_modules")); + assert!(!is_hoist_folder(b"/app/node_modules/app/src")); + assert!(!is_hoist_folder( + b"/app/node_modules/.pnpm/old_node_modules" + )); + assert!(!is_hoist_folder(b"/app/node_modules_old/node_modules")); + assert!(!is_hoist_folder(b"")); + } +} diff --git a/src/resolver/result.rs b/src/resolver/result.rs index 35ec28f4afc8..3d61b0347ecd 100644 --- a/src/resolver/result.rs +++ b/src/resolver/result.rs @@ -90,17 +90,21 @@ impl Default for Result<'_> { bitflags::bitflags! { #[derive(Default, Clone, Copy)] - pub struct ResultFlags: u8 { + pub struct ResultFlags: u16 { // Bits 0..=1 encode [`ExternalKind`]; write via `set_external_kind`. const IS_EXTERNAL = 1 << 0; const REWRITE_IMPORT_PATH = 1 << 1; const IS_STANDALONE_MODULE = 1 << 2; // This is true when the package was loaded from within the node_modules directory. const IS_FROM_NODE_MODULES = 1 << 3; + /// Found in a `NODE_PATH` directory: no `node_modules` folder on the way up from the importer has it. + const IS_FROM_NODE_PATH = 1 << 4; const EMIT_DECORATOR_METADATA = 1 << 5; const EXPERIMENTAL_DECORATORS = 1 << 6; /// tsconfig `"useDefineForClassFields": false` was set explicitly. const SET_SEMANTICS_FOR_CLASS_FIELDS = 1 << 7; + /// Found in a `NODE_PATH` directory that is a `node_modules` folder inside another one. pnpm links the packages that a project does not declare into `node_modules/.pnpm/node_modules`. + const IS_FROM_HOIST_FOLDER = 1 << 8; } } @@ -155,6 +159,22 @@ impl ResultFlags { self.set(Self::IS_FROM_NODE_MODULES, v) } #[inline] + pub fn is_from_node_path(self) -> bool { + self.contains(Self::IS_FROM_NODE_PATH) + } + #[inline] + pub(crate) fn set_is_from_node_path(&mut self, v: bool) { + self.set(Self::IS_FROM_NODE_PATH, v) + } + #[inline] + pub fn is_from_hoist_folder(self) -> bool { + self.contains(Self::IS_FROM_HOIST_FOLDER) + } + #[inline] + pub(crate) fn set_is_from_hoist_folder(&mut self, v: bool) { + self.set(Self::IS_FROM_HOIST_FOLDER, v) + } + #[inline] pub fn emit_decorator_metadata(self) -> bool { self.contains(Self::EMIT_DECORATOR_METADATA) } @@ -309,6 +329,8 @@ pub struct MatchResult { pub(crate) dirname_fd: FD, pub(crate) file_fd: FD, pub(crate) is_node_module: bool, + pub(crate) is_from_node_path: bool, + pub(crate) is_from_hoist_folder: bool, pub(crate) package_json: Option<&'static PackageJSON>, pub(crate) dir_info: Option, pub(crate) module_type: options::ModuleType, @@ -322,6 +344,8 @@ impl Default for MatchResult { dirname_fd: FD::INVALID, file_fd: FD::INVALID, is_node_module: false, + is_from_node_path: false, + is_from_hoist_folder: false, package_json: None, dir_info: None, module_type: options::ModuleType::Unknown, diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index a9e420b63dd9..5e83f88f0a7b 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -992,6 +992,7 @@ pub(crate) fn bun_resolve_sync_with_paths( /// `ResolveMessage` case, e.g. "Cannot find module") yields `undefined` instead of throwing; /// everything else — an `onResolve` plugin throwing or returning an invalid result, a specifier /// that is not a string — is thrown. +/// The module mock code keys a mock by the answer. [`ResolveMode::ModuleMock`] names the package that yields `undefined` too. // HOST_EXPORT(Bun__resolveSyncWithSourceIfExists, c) pub(crate) fn bun_resolve_sync_with_source_if_exists( global: &JSGlobalObject, @@ -1002,13 +1003,12 @@ pub(crate) fn bun_resolve_sync_with_source_if_exists( let Ok(specifier_str) = specifier.to_bun_string(global) else { return JSValue::ZERO; }; + let mode = match is_esm { + true => ResolveMode::ModuleMock, + false => ResolveMode::Require, + }; jsc::to_js_host_call(global, || { - match resolve_with_args::( - global, - &specifier_str, - source, - ResolveMode::from_ffi_bools(is_esm, false), - )? { + match resolve_with_args::(global, &specifier_str, source, mode)? { Resolved::Found(value) => value.into_js(global), Resolved::NotFound(_) => Ok(JSValue::UNDEFINED), } diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index 5e040a1fa6a0..ebe676048258 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -1326,9 +1326,6 @@ impl CompletionStruct for JSBundleCompletionTask { .conditions .append_slice(&[b"development"])?; } - // `transpiler.env` is the dotenv loader installed by - // `Transpiler::init`; non-null and valid for `'a`. - transpiler.resolver.env_loader = NonNull::new(transpiler.env); // `Resolver.opts` is the resolver-crate subset // — re-project from the now-mutated `transpiler.options`. transpiler.sync_resolver_opts(); diff --git a/src/runtime/bake/production/mod.rs b/src/runtime/bake/production/mod.rs index f98582b6f24f..4090598d2dea 100644 --- a/src/runtime/bake/production/mod.rs +++ b/src/runtime/bake/production/mod.rs @@ -160,12 +160,6 @@ pub(crate) fn build_command(ctx: Context) -> crate::Result<()> { b.resolver.opts.install_preference = offline; b.options.global_cache = b.resolver.opts.global_cache; b.options.install_preference = offline; - // SAFETY: `b.env` is the Transpiler-owned `*mut Loader`; store it - // as `NonNull` (not `&Loader`) because `configure_defines()` below - // reborrows the same allocation as `&mut Loader` via `run_env_loader()`, - // which would alias a live `&Loader` here. The Loader outlives the - // resolver (process-lifetime singleton or VM-owned). - b.resolver.env_loader = NonNull::new(b.env); b.options.minify_identifiers = ctx.bundler_options.minify_identifiers; b.options.minify_whitespace = ctx.bundler_options.minify_whitespace; b.options.ignore_dce_annotations = ctx.bundler_options.ignore_dce_annotations; diff --git a/src/runtime/cli/build_command.rs b/src/runtime/cli/build_command.rs index 11719959b141..908ca489fdc9 100644 --- a/src/runtime/cli/build_command.rs +++ b/src/runtime/cli/build_command.rs @@ -593,7 +593,6 @@ impl BuildCommand { // `bun_resolver::options::BundleOptions` subset, distinct from the // bundler-side `BundleOptions<'a>`; re-project the mutated options. this_transpiler.sync_resolver_opts(); - this_transpiler.resolver.env_loader = core::ptr::NonNull::new(this_transpiler.env); // Allow tsconfig.json overriding, but always set it to false if --production is passed. if ctx.bundler_options.production || ctx.bundler_options.library { diff --git a/src/runtime/cli/compile_command/configuration.rs b/src/runtime/cli/compile_command/configuration.rs index 18a9c998f982..d6048a405fc5 100644 --- a/src/runtime/cli/compile_command/configuration.rs +++ b/src/runtime/cli/compile_command/configuration.rs @@ -533,7 +533,6 @@ fn finish( } this_transpiler.sync_resolver_opts(); - this_transpiler.resolver.env_loader = NonNull::new(this_transpiler.env); if ctx.bundler_options.production { this_transpiler.options.jsx.development = false; diff --git a/src/runtime/cli/lint_js.rs b/src/runtime/cli/lint_js.rs index c905b502c4d2..bc22ca1add93 100644 --- a/src/runtime/cli/lint_js.rs +++ b/src/runtime/cli/lint_js.rs @@ -237,7 +237,6 @@ fn start_vm() -> Result<(), Vec> { let vm = VirtualMachine::get().as_mut(); // Transpile on this thread. It would only wait for the result anyway. vm.transpiler_store.enabled = false; - vm.transpiler.resolver.env_loader = NonNull::new(vm.transpiler.env); vm.transpiler.options.env.behavior = bun_options_types::schema::api::DotEnvBehavior::LoadAllWithoutInlining; // Same as for config files: see `run_script` in `lint_command.rs`. diff --git a/src/runtime/cli/repl_command.rs b/src/runtime/cli/repl_command.rs index a08f736d55a7..dcba7a18ffe9 100644 --- a/src/runtime/cli/repl_command.rs +++ b/src/runtime/cli/repl_command.rs @@ -111,7 +111,6 @@ impl ReplCommand { .unwrap_or(OfflineMode::Online); b.resolver.opts.install_preference = offline; b.options.install_preference = offline; - b.resolver.env_loader = NonNull::new(b.env); b.options.env.behavior = EnvBehavior::LoadAllWithoutInlining; b.options.dead_code_elimination = false; // REPL needs all code diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 1726abbb1a84..d59fa21fc0be 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -848,7 +848,6 @@ Full documentation is available at https://bun.com/docs/cli/run b.resolver.opts.install_preference = offline; b.options.global_cache = ctx.debug.global_cache; b.options.install_preference = offline; - b.resolver.env_loader = ::core::ptr::NonNull::new(b.env); if ctx.runtime_options.eval.reads_configuration { b.resolver.opts.packages_require_like_node(); } diff --git a/src/runtime/cli/test/ChangedFilesFilter.rs b/src/runtime/cli/test/ChangedFilesFilter.rs index 21dace43ca9c..be94c5a84670 100644 --- a/src/runtime/cli/test/ChangedFilesFilter.rs +++ b/src/runtime/cli/test/ChangedFilesFilter.rs @@ -160,7 +160,6 @@ pub(crate) fn filter<'a>( scan_transpiler.resolver.opts.target = scan_transpiler.options.target; scan_transpiler.resolver.opts.packages = bun_resolver::options::Packages::External; scan_transpiler.resolver.opts.output_dir = Box::default(); - scan_transpiler.resolver.env_loader = core::ptr::NonNull::new(scan_transpiler.env); // Stack-owned Mini loop so its tasks/concurrent_tasks queues drop at // scope exit; the arena bulk-free skips Drop. diff --git a/test/aot/runner-suites.known.txt b/test/aot/runner-suites.known.txt index 4be569aaf71d..d718f93638bd 100644 --- a/test/aot/runner-suites.known.txt +++ b/test/aot/runner-suites.known.txt @@ -175,6 +175,9 @@ test/js/bun/plugin/plugins.test.ts: the registry is not asked for a bare name th test/js/bun/plugin/plugins.test.ts: the registry is not asked for a bare name that onResolve answers about as well > by require() test/js/bun/plugin/plugins.test.ts: the registry is not asked for a bare name that onResolve answers about as well > by require.resolve() test/js/bun/plugin/plugins.test.ts: the registry is not asked for a bare name that onResolve answers about as well > whether or not an onLoad serves it +# The build of a compiled test run resolves the imports, with NODE_PATH. A mock whose key is a name does not reach them, and a chai in NODE_PATH comes before the one next to vitest. +test/js/bun/resolve/resolve.test.ts: NODE_PATH test > bun test > mock.module of a package in the hoist folder of pnpm +test/js/bun/resolve/resolve.test.ts: NODE_PATH test > bun test > the chai next to vitest comes before a chai that only NODE_PATH has test/js/bun/resolve/resolve.test.ts: auto-install init failure from an unreadable cwd is a catchable error # snapshot.test.ts gives each of these 5 seconds for three child runs: compiled ones keep that on a machine of their own, and not on a busy one or with a sanitizer. ~ test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts: inline snapshots > #16403 diff --git a/test/aot/runner-suites.programs.txt b/test/aot/runner-suites.programs.txt index 538ec862c9a2..c8142c3937d6 100644 --- a/test/aot/runner-suites.programs.txt +++ b/test/aot/runner-suites.programs.txt @@ -33,6 +33,9 @@ test/js/bun/import-attributes/import-attributes.test.ts: suffixes of an import p test/js/bun/resolve/import-query.test.ts: dynamic import of a .node addon ignores a query string suffix test/js/bun/resolve/import-query.test.ts: require of a .node addon with a query string reaches process.dlopen test/js/bun/resolve/import-query.test.ts: static import of a .node addon ignores a query string suffix +# The build of a compiled test run resolves the imports, with NODE_PATH. A mock whose key is a name does not reach them, and a chai in NODE_PATH comes before the one next to vitest. +test/js/bun/resolve/resolve.test.ts: NODE_PATH test > bun test > mock.module of a package in the hoist folder of pnpm +test/js/bun/resolve/resolve.test.ts: NODE_PATH test > bun test > the chai next to vitest comes before a chai that only NODE_PATH has test/js/bun/resolve/resolve.test.ts: TypeScript extension rewrite matrix > `.cjs` specifier does not pick up ["ts","tsx","mts"] files test/js/bun/resolve/resolve.test.ts: TypeScript extension rewrite matrix > `.js` specifier does not pick up ["cts"] files test/js/bun/resolve/resolve.test.ts: TypeScript extension rewrite matrix > `.mjs` specifier does not pick up ["ts","tsx","cts"] files diff --git a/test/bake/app-options.test.ts b/test/bake/app-options.test.ts index c7cab2036326..b84f31c9702f 100644 --- a/test/bake/app-options.test.ts +++ b/test/bake/app-options.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "node:path"; // Every option here is declared as `string` or `boolean` in bake.d.ts. A value // of another type must throw ERR_INVALID_ARG_TYPE from Bun.serve, before any @@ -379,3 +380,77 @@ test.concurrent("Bun.serve({ app }) reports an unsupported app router file", asy expect(stdout).toBe("200 page\n"); expect(exitCode).toBe(0); }); + +// Each graph of an app has its own transpiler. Their resolvers read NODE_PATH, as `bun build` does. +test.concurrent.each([ + ["Bun.serve({ app })", ["fixture.ts"]], + ["bun build --app", ["build", "--app", "./app.ts"]], +])("%s resolves a package from NODE_PATH in every graph and in a macro", async (_, args) => { + using dir = tempDir("bake-app-node-path", { + "vendor/dep/index.ts": `export const dep = "from NODE_PATH";\n`, + "client.ts": `import { dep } from "dep";\nconsole.log("client " + dep);\n`, + "server.ts": ` + export function render(req, meta) { + return new Response(meta.pageModule.default()); + } + export async function prerender(meta) { + return { files: { "index.html": meta.pageModule.default() } }; + } + export function registerClientReference(value, file, uid) { + return { value, file, uid }; + } + `, + "ssr.ts": `import { dep } from "dep";\nexport default "ssr " + dep;\n`, + "macro.ts": `import { dep } from "dep";\nexport function value() {\n return "macro " + dep;\n}\n`, + "routes/index.ts": ` + import { dep } from "dep"; + import ssr from "../ssr.ts" with { bunBakeGraph: "ssr" }; + import { value } from "../macro.ts" with { type: "macro" }; + const macro = value(); + export default () => ["server " + dep, ssr, macro].join("; "); + `, + "app.ts": ` + const fsr = { root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts", clientEntryPoint: "./client.ts" }; + const serverComponents = { + separateSSRGraph: true, + serverRuntimeImportSource: "./server.ts", + serverRegisterClientReferenceExport: "registerClientReference", + }; + export default { app: { framework: { fileSystemRouterTypes: [fsr], serverComponents } } }; + `, + "fixture.ts": ` + import config from "./app.ts"; + const server = Bun.serve({ port: 0, development: true, app: config.app, fetch: () => new Response("fallback") }); + const res = await fetch(server.url); + console.log(res.status + " " + (await res.text())); + await server.stop(true); + `, + }); + const isBuild = args[0] === "build"; + + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + env: { + ...bunEnv, + NODE_PATH: join(String(dir), "vendor"), + // `bun build --app` exits with its graph transpilers alive, which LeakSanitizer reports. + ...(isBuild && { ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=0"].filter(Boolean).join(":") }), + }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const rendered = "server from NODE_PATH; ssr from NODE_PATH; macro from NODE_PATH"; + + if (isBuild) { + const html = await Bun.file(join(String(dir), "dist", "index.html")) + .text() + .catch(() => stdout + stderr); + expect(html).toBe(rendered); + } else { + // The dev server also prints a "Bundled page" line. + expect(stdout.split("\n"), stderr).toContain("200 " + rendered); + } + expect(exitCode).toBe(0); +}); diff --git a/test/bundler/transpiler/macro-test.test.ts b/test/bundler/transpiler/macro-test.test.ts index 0eeb8fb9af1f..ab61b0873977 100644 --- a/test/bundler/transpiler/macro-test.test.ts +++ b/test/bundler/transpiler/macro-test.test.ts @@ -848,3 +848,65 @@ describe("macro VM env loader", () => { expect(withMacro).toEqual(withoutMacro); }); }); + +// A macro VM and a `Bun.Transpiler` each have their own resolver. Each gets NODE_PATH when its own transpiler loads its env. +describe("NODE_PATH", () => { + const specifierSource = `import { value } from "macro-package" with { type: "macro" }; console.log(value());`; + const files = { + "vendor/dep/index.ts": `export const dep = "from NODE_PATH";\n`, + "vendor/macro-package/index.ts": `export function value() {\n return "macro from NODE_PATH";\n}\n`, + "macro.ts": `import { dep } from "dep";\nexport function value() {\n return dep;\n}\n`, + "entry.ts": `import { value } from "./macro.ts" with { type: "macro" };\nconsole.log(value());\n`, + "specifier.ts": `${specifierSource}\n`, + // A module that is not the entry point is transpiled on a pool thread, and that thread creates a macro VM. + "imports-entry.ts": `import "./entry.ts";\n`, + "entry.test.ts": `import { test } from "bun:test";\nimport "./entry.ts";\ntest("macro", () => {});\n`, + "specifier.test.ts": `import { test } from "bun:test";\nimport "./specifier.ts";\ntest("macro", () => {});\n`, + "build.ts": `const result = await Bun.build({ entrypoints: ["./entry.ts"], target: "bun" });\nconsole.log(await result.outputs[0].text());\n`, + "transform-sync.ts": `console.log(new Bun.Transpiler({ loader: "ts" }).transformSync(${JSON.stringify(specifierSource)}));\n`, + "transform.ts": `console.log(await new Bun.Transpiler({ loader: "ts" }).transform(${JSON.stringify(specifierSource)}));\n`, + }; + const bundled = `console.log("from NODE_PATH");`; + const transformed = `console.log("macro from NODE_PATH");`; + + test.concurrent.each([ + { name: "bun build", cmd: ["build", "./entry.ts", "--target=bun"], expected: bundled }, + // The macro VM loads .env. The build does not: see "the .env loaded for a macro doesn't affect the build's resolver". + { + name: "bun build, NODE_PATH in .env", + cmd: ["build", "./entry.ts", "--target=bun"], + expected: bundled, + dotenv: true, + }, + // A parse thread of the bundler resolves the macro with its copy of the build's resolver. + { + name: "bun build, the macro is the package", + cmd: ["build", "./specifier.ts", "--target=bun"], + expected: transformed, + }, + { name: "Bun.build", cmd: ["build.ts"], expected: bundled }, + { name: "bun run, caller is an imported module", cmd: ["imports-entry.ts"], expected: "from NODE_PATH" }, + { name: "bun test, caller is an imported module", cmd: ["test", "./entry.test.ts"], expected: "from NODE_PATH" }, + { + name: "bun test, the macro is the package", + cmd: ["test", "./specifier.test.ts"], + expected: "macro from NODE_PATH", + }, + { name: "Bun.Transpiler#transformSync", cmd: ["transform-sync.ts"], expected: transformed }, + { name: "Bun.Transpiler#transform", cmd: ["transform.ts"], expected: transformed }, + ])("a macro resolves a package from NODE_PATH: $name", async ({ cmd, expected, dotenv }) => { + using dir = tempDir("macro-node-path", files); + const nodePath = path.join(String(dir), "vendor"); + if (dotenv) await Bun.write(path.join(String(dir), ".env"), `NODE_PATH=${nodePath}\n`); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...cmd], + env: { ...bunEnv, NODE_PATH: dotenv ? undefined : nodePath }, + 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).toContain(expected); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/http/bun-serve-html.test.ts b/test/js/bun/http/bun-serve-html.test.ts index 928f19dc0891..2ac7634e21dc 100644 --- a/test/js/bun/http/bun-serve-html.test.ts +++ b/test/js/bun/http/bun-serve-html.test.ts @@ -740,9 +740,13 @@ test("serve html whose file is deleted before its first bundle", async () => { }); /** Runs the `serve.ts` of a fixture directory to completion. */ -async function runServeFixture(dir: { toString(): string }, env: Record = {}) { +async function runServeFixture( + dir: { toString(): string }, + env: Record = {}, + args: string[] = [], +) { await using proc = Bun.spawn({ - cmd: [bunExe(), "serve.ts"], + cmd: [bunExe(), ...args, "serve.ts"], env: { ...bunEnv, ...env }, cwd: String(dir), stdout: "pipe", @@ -1561,6 +1565,103 @@ test("wildcard static routes", async () => { } }); +// These run behind a test that is not concurrent, so that they do not run beside the tests above that race a rebuild. +// The dev server bundles with its own transpilers. Their resolvers read NODE_PATH, as the production bundle does. +// A `require` in a `try` block that does not resolve is left to throw at run time: it is bundled when it resolves. +describe("NODE_PATH", () => { + const page = ``; + // Fetches the page and the script that the page names. + const fetchScript = /*ts*/ ` + const page = await fetch(server.url); + const scriptSrc = (await page.text()).match(/]*\\ssrc="([^"]+)"/)?.[1]; + const script = scriptSrc ? await (await fetch(new URL(scriptSrc, server.url))).text() : ""; + `; + + test.concurrent.each([ + { development: true, from: "the process" }, + { development: true, from: ".env" }, + { development: false, from: "the process" }, + { development: false, from: ".env" }, + ])("resolves packages from the NODE_PATH of $from with development: $development", async ({ development, from }) => { + using dir = tempDir("bun-serve-html-node-path", { + "vendor/dep/index.ts": `export const dep = "import from NODE_PATH";\n`, + "vendor/optional-dep/index.ts": `export const optional = "handled require from NODE_PATH";\n`, + "vendor/macro-dep/index.ts": `export function value() {\n return "macro from NODE_PATH";\n}\n`, + "index.html": page, + "app.ts": ` + import { dep } from "dep"; + import { value } from "macro-dep" with { type: "macro" }; + let optional; + try { + optional = require("optional-dep").optional; + } catch {} + console.log(dep, optional, value()); + `, + "serve.ts": /*ts*/ ` + import html from "./index.html"; + + using server = Bun.serve({ port: 0, development: ${development}, routes: { "/": html } }); + ${fetchScript} + console.log( + JSON.stringify({ + status: page.status, + import: script.includes("import from NODE_PATH"), + handledRequire: script.includes("handled require from NODE_PATH"), + macro: script.includes("macro from NODE_PATH"), + }), + ); + `, + }); + const nodePath = join(String(dir), "vendor"); + if (from === ".env") await Bun.write(join(String(dir), ".env"), `NODE_PATH=${nodePath}\n`); + const { stdout, stderr, exitCode } = await runServeFixture(dir, { + NODE_PATH: from === ".env" ? undefined : nodePath, + }); + // A debug build also prints a line for the macro call. + expect({ stdout: stdout.split(/\r?\n/).at(-1), exitCode }, stderr).toEqual({ + stdout: JSON.stringify({ status: 200, import: true, handledRequire: true, macro: true }), + exitCode: 0, + }); + }); + + // A resolver has the NODE_PATH that its own transpiler loaded. `[serve.static] env` makes the transpiler of the + // client bundle load `.env`, into the loader that it shares with the VM. The VM skipped `.env`. + test.concurrent("the .env that [serve.static] env loads gives NODE_PATH to the client bundle only", async () => { + using dir = tempDir("bun-serve-html-node-path-env", { + "vendor/node-path-only-dep/index.ts": `export const dep = "import from NODE_PATH";\n`, + "bunfig.toml": `[serve.static]\nenv = "PUBLIC_*"\n`, + "index.html": page, + "app.ts": `import { dep } from "node-path-only-dep";\nconsole.log(dep);\n`, + "serve.ts": /*ts*/ ` + import html from "./index.html"; + + const resolves = () => { + try { + require.resolve("node-path-only-dep"); + return true; + } catch { + return false; + } + }; + const before = resolves(); + using server = Bun.serve({ port: 0, development: true, routes: { "/": html } }); + ${fetchScript} + const client = script.includes("import from NODE_PATH"); + console.log(JSON.stringify({ before, status: page.status, client, after: resolves() })); + `, + }); + await Bun.write(join(String(dir), ".env"), `NODE_PATH=${join(String(dir), "vendor")}\n`); + const { stdout, stderr, exitCode } = await runServeFixture(dir, { NODE_PATH: undefined }, [ + "--no-install", + "--no-env-file", + ]); + expect({ stdout, exitCode }, stderr).toEqual({ + stdout: JSON.stringify({ before: false, status: 200, client: true, after: false }), + exitCode: 0, + }); + }); +}); + test("serve html with JSX runtime in development mode", async () => { const dir = join(import.meta.dir, "jsx-runtime"); const { default: html } = await import(join(dir, "index.html")); diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index eda051bb12d4..59efadd38bdc 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -429,6 +429,262 @@ describe("NODE_PATH test", () => { expect(exitCode).toBe(0); expect(stdout.toString().trim()).toBe("NODE_PATH from lib works"); }); + + const files = { + "modules/node-path-test/index.js": "exports.testValue = 'NODE_PATH works';", + "modules/node-path-preload/index.js": "globalThis.preloaded = 'NODE_PATH preload works';", + "modules/node-path-mocked/index.js": "exports.copy = 'NODE_PATH';", + "modules/setup.js": "globalThis.setup = 'NODE_PATH';", + "app/package.json": JSON.stringify({ name: "app" }), + "app/node_modules/dependent/index.js": "exports.copy = require('node-path-mocked').copy;", + "app/node_modules/dependent/node_modules/node-path-mocked/index.js": "exports.copy = 'nested';", + "app/setup.js": "globalThis.setup = 'cwd';", + "app/resolve.test.js": /*js*/ ` + import { expect, test } from "bun:test"; + import { join } from "node:path"; + import { testValue } from "node-path-test"; + + const entry = join(import.meta.dir, "..", "modules", "node-path-test", "index.js"); + + test("NODE_PATH", async () => { + expect({ + import: testValue, + require: require("node-path-test").testValue, + dynamicImport: (await import("node-path-test")).testValue, + requireResolve: require.resolve("node-path-test"), + resolveSync: Bun.resolveSync("node-path-test", import.meta.dir), + importMetaResolve: Bun.fileURLToPath(import.meta.resolve("node-path-test")), + }).toEqual({ + import: "NODE_PATH works", + require: "NODE_PATH works", + dynamicImport: "NODE_PATH works", + requireResolve: entry, + resolveSync: entry, + importMetaResolve: entry, + }); + }); + `, + "app/second.test.js": /*js*/ ` + import { expect, test } from "bun:test"; + import { testValue } from "node-path-test"; + + test("NODE_PATH", () => { + expect(testValue).toBe("NODE_PATH works"); + }); + `, + "app/preload.test.js": /*js*/ ` + import { expect, test } from "bun:test"; + + test("NODE_PATH", () => { + expect(globalThis.preloaded).toBe("NODE_PATH preload works"); + }); + `, + "app/mock.test.js": /*js*/ ` + import { expect, mock, test } from "bun:test"; + + mock.module("node-path-mocked", () => ({ copy: "mocked" })); + + test("NODE_PATH", () => { + expect({ test: require("node-path-mocked").copy, dependent: require("dependent").copy }).toEqual({ + test: "mocked", + dependent: "nested", + }); + }); + `, + "app/worker.js": "postMessage(require('node-path-test').testValue + ', preload from ' + globalThis.setup);", + "app/main.js": /*js*/ ` + const worker = new Worker(new URL("./worker.js", import.meta.url), { preload: ["setup.js"] }); + worker.onmessage = event => { + console.log(event.data); + worker.terminate(); + }; + `, + "app/worker.test.js": /*js*/ ` + import { test } from "bun:test"; + + test("NODE_PATH", async () => { + const worker = new Worker(new URL("./worker.js", import.meta.url), { preload: ["setup.js"] }); + const { promise, resolve, reject } = Promise.withResolvers(); + worker.onmessage = event => resolve(event.data); + worker.onerror = reject; + console.log(await promise); + worker.terminate(); + }); + `, + }; + + // `bun test` creates its own VM. The resolver of that VM reads NODE_PATH, as the resolver of `bun run` does. + describe("bun test", () => { + it.concurrent.each([ + { name: "resolves modules from NODE_PATH", args: ["./resolve.test.js"], pass: 1 }, + { name: "--isolate", args: ["--isolate", "./second.test.js", "./resolve.test.js"], pass: 2 }, + { name: "--parallel", args: ["--parallel=2", "./resolve.test.js", "./second.test.js"], pass: 2 }, + { name: "--preload of a package", args: ["--preload", "node-path-preload", "./preload.test.js"], pass: 1 }, + { name: "NODE_PATH from .env", args: ["./resolve.test.js"], pass: 1, dotenv: true }, + // `modules` is not the hoist folder of a package manager: the mock replaces the file that the test resolves, not another copy. + { name: "mock.module replaces the copy that the test resolves", args: ["./mock.test.js"], pass: 1 }, + ])("$name", async ({ args, pass, dotenv }) => { + using dir = tempDir("node-path-bun-test", files); + const nodePath = join(String(dir), "modules"); + if (dotenv) writeFileSync(join(String(dir), "app", ".env"), `NODE_PATH=${nodePath}\n`); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", ...args], + env: { ...bunEnv, NODE_PATH: dotenv ? undefined : nodePath }, + cwd: join(String(dir), "app"), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr, stdout).toContain(` ${pass} pass`); + expect(stderr).toContain(" 0 fail"); + expect(exitCode).toBe(0); + }); + + // `import { assert } from "vitest"` imports "chai". pnpm links the chai of vitest next to vitest, and its bin shims + // put the packages that it hoists in NODE_PATH. A mock of "chai" is a mock of the chai next to vitest too. + it.concurrent("the chai next to vitest comes before a chai that only NODE_PATH has", async () => { + const store = "node_modules/.pnpm/vitest@1.0.0/node_modules"; + const hoistFolder = "node_modules/.pnpm/node_modules"; + const chaiPackage = `{ "name": "chai", "type": "module", "main": "index.js" }`; + using dir = tempDir("node-path-chai", { + [`${store}/vitest/package.json`]: `{ "name": "vitest", "exports": { "./package.json": "./package.json" } }`, + [`${store}/chai/package.json`]: chaiPackage, + [`${store}/chai/index.js`]: `export const assert = () => "the chai next to vitest";`, + [`${hoistFolder}/chai/package.json`]: chaiPackage, + [`${hoistFolder}/chai/index.js`]: `export const assert = () => "a chai in NODE_PATH";`, + "a.test.js": `import { assert, test } from "vitest"; test("test", () => console.log(assert()));`, + "b.test.js": /*js*/ ` + import { test, vi } from "vitest"; + + vi.mock("chai", async importOriginal => ({ ...(await importOriginal()), mocked: "the mock of" })); + + test("test", async () => { + const chai = await import("chai"); + console.log(chai.mocked, chai.assert()); + }); + `, + }); + symlinkSync(join(String(dir), store, "vitest"), join(String(dir), "node_modules", "vitest"), "junction"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test"], + env: { ...bunEnv, NODE_PATH: join(String(dir), hoistFolder) }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect( + stdout.split(/\r?\n/).filter(line => line.includes("chai")), + stderr, + ).toEqual(["the chai next to vitest", "the mock of the chai next to vitest"]); + expect(exitCode).toBe(0); + }); + + // pnpm links every package of the project into node_modules/.pnpm/node_modules, and its bin shims put that folder in + // NODE_PATH. Under `bun test` the key of the mock of a package that only that folder has is its name, as for a name + // that does not resolve: the mock replaces every copy. Under `bun run` the key is the file that the name resolves to. + it.concurrent("mock.module of a package in the hoist folder of pnpm", async () => { + const hoistFolder = "node_modules/.pnpm/node_modules"; + using dir = tempDir("node-path-hoisted", { + "packages/lib/package.json": JSON.stringify({ + name: "node-path-hoisted", + main: "./index.cjs", + exports: { ".": { import: "./index.mjs", require: "./index.cjs" } }, + }), + "packages/lib/index.cjs": `exports.copy = "cjs";`, + "packages/lib/index.mjs": `export const copy = "esm";`, + "node_modules/dependent/package.json": `{ "name": "dependent", "type": "module" }`, + "node_modules/dependent/index.js": `export { copy } from "node-path-hoisted";`, + "node_modules/node-path-declared/index.js": `exports.copy = "declared";`, + [`${hoistFolder}/node-path-declared/index.js`]: `exports.copy = "the copy in the hoist folder";`, + "a.test.js": /*js*/ ` + import { expect, mock, test } from "bun:test"; + import { join } from "node:path"; + import { copy as declared } from "node-path-declared"; + + const resolved = require.resolve("node-path-hoisted"); + mock.module("node-path-hoisted", () => ({ copy: "mocked" })); + mock.module("node-path-declared", () => ({ copy: "mocked" })); + + test("test", async () => { + expect({ + resolved, + test: (await import("node-path-hoisted")).copy, + dependent: (await import("dependent")).copy, + declared, + }).toEqual({ + resolved: join(import.meta.dir, "packages", "lib", "index.cjs"), + test: "mocked", + dependent: "mocked", + declared: "mocked", + }); + }); + `, + "run.js": /*js*/ ` + import { mock } from "bun:test"; + + mock.module("node-path-hoisted", () => ({ copy: "mocked" })); + const [test, dependent] = await Promise.all([import("node-path-hoisted"), import("dependent")]); + console.log(JSON.stringify({ test: test.copy, dependent: dependent.copy })); + `, + }); + // As pnpm links a package of the workspace: the folder of the package is outside node_modules. + const lib = join(String(dir), "packages", "lib"); + mkdirSync(join(String(dir), "node_modules", "dependent", "node_modules")); + symlinkSync(lib, join(String(dir), "node_modules", "dependent", "node_modules", "node-path-hoisted"), "junction"); + symlinkSync(lib, join(String(dir), hoistFolder, "node-path-hoisted"), "junction"); + + const run = async (...args: string[]) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + env: { ...bunEnv, NODE_PATH: join(String(dir), hoistFolder) }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + }; + const [underTest, underRun] = await Promise.all([run("test"), run("run.js")]); + + expect(underTest.stderr, underTest.stdout).toContain(" 1 pass"); + expect(underTest.stderr).toContain(" 0 fail"); + expect(underTest.exitCode).toBe(0); + expect({ stdout: underRun.stdout, exitCode: underRun.exitCode }, underRun.stderr).toEqual({ + stdout: `{"test":"mocked","dependent":"esm"}\n`, + exitCode: 0, + }); + }); + }); + + // `setup.js` is in the cwd and in the NODE_PATH directory. Both commands resolve the Worker's preload the same way. + it.concurrent("a Worker resolves its modules and its preload under bun test as under bun run", async () => { + using dir = tempDir("node-path-worker", files); + const run = async (...args: string[]) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + env: { ...bunEnv, NODE_PATH: join(String(dir), "modules") }, + cwd: join(String(dir), "app"), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // `bun test` prints its version line to stdout too. + return { message: stdout.split(/\r?\n/).find(line => line.includes("preload from")), stderr, exitCode }; + }; + const [underRun, underTest] = await Promise.all([run("main.js"), run("test", "./worker.test.js")]); + + expect(underRun.message, underRun.stderr).toStartWith("NODE_PATH works, preload from "); + expect(underRun.exitCode, underRun.stderr).toBe(0); + expect({ message: underTest.message, exitCode: underTest.exitCode }, underTest.stderr).toEqual({ + message: underRun.message, + exitCode: 0, + }); + }); }); it("can resolve with source directories that do not exist", () => { @@ -1452,6 +1708,62 @@ describe("dangling symlinks in node_modules", () => { } catch {} } }); + + // The env load of the VM stops before the `.env` files when the cwd cannot be listed. The process env is loaded by then. + it.skipIf(!canTriggerEACCES)("bun test resolves from NODE_PATH in a cwd that it cannot list", async () => { + using dir = tempDir("node-path-unreadable-cwd", { + "modules/node-path-only/index.js": `module.exports = "NODE_PATH works";`, + "app/a.test.js": ` + import { expect, test } from "bun:test"; + + test("NODE_PATH", () => { + expect(require("node-path-only")).toBe("NODE_PATH works"); + }); + `, + "work/.keep": "", + }); + const root = String(dir); + const work = join(root, "work"); + const testFile = join(root, "app", "a.test.js"); + const packageDir = join(root, "modules", "node-path-only"); + + const cmd = [bunExe(), "test", testFile]; + if (canUseRunuser) { + // Let `nobody` traverse and read everything except the cwd. + for (const p of [ + root, + join(root, "app"), + testFile, + join(root, "modules"), + packageDir, + join(packageDir, "index.js"), + ]) { + chmodSync(p, 0o777); + } + cmd.unshift("runuser", "-u", "nobody", "--"); + } + // Execute-only: the spawn can chdir into it, but listing it fails. + chmodSync(work, 0o111); + + try { + await using proc = Bun.spawn({ + cmd, + env: { ...bunEnv, NODE_PATH: join(root, "modules") }, + cwd: work, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr, stdout).toContain(" 1 pass"); + expect(exitCode).toBe(0); + } finally { + // Ensure tempDir cleanup can delete work/.keep. + try { + chmodSync(work, 0o755); + } catch {} + } + }); } describe("resolving external URL specifiers with non-ASCII characters", () => {