diff --git a/docs/test/mocks.mdx b/docs/test/mocks.mdx index 61f5b4ec0993..84e000581337 100644 --- a/docs/test/mocks.mdx +++ b/docs/test/mocks.mdx @@ -539,6 +539,14 @@ As in Vitest, `vi.doMock()` leaves a module that's already imported as it is. Wh In a file with hoisted calls, Bun turns the other imports into `await import()` and reads them through the module namespace, so they stay live. The exceptions are imports from `bun:test`, `vitest`, `@jest/globals` and `bun`, imports with attributes, `import defer`, `export ... from`, and imports the file re-exports. Those stay static. +An import of a package with attributes becomes `await import()` too when one of the hoisted calls names its path, unless the file re-exports one of its names. So does the import of the JSX runtime that Bun adds for the file's JSX. This way, `vi.mock("react/jsx-dev-runtime", ...)` reaches the JSX of the file that calls it. A call names a path with a string literal, or with a `const` that holds one. A relative import with attributes stays static, unless its path goes into `node_modules`: a mock updates a loaded module of your own in place, so that import gets the mock too. So does a name that can't be a package name: a `#name` from `imports` in `package.json`, or a name such as `@/name` or `~/name` from `paths` in `tsconfig.json`. If such a name points into a package, the import keeps the real package. Any other name from `paths` counts as a package, even if it points to a module of your own. + +Name the runtime that the file imports. By default that is `react/jsx-dev-runtime`. It is `react/jsx-runtime` with `"jsx": "react-jsx"` in `tsconfig.json` or with `NODE_ENV=production`. With another `jsxImportSource`, the path starts with that source, as in `preact/jsx-dev-runtime`. The mocked runtime is imported behind the hoisted calls and ahead of the other imports that became `await import()`, so JSX that a `vi.hoisted()` callback evaluates is a `ReferenceError`, as in Vitest. JSX in a function that the callback returns is fine. + +The other static imports are linked before the hoisted calls run. A hoisted `vi.mock()` of a package doesn't reach them: they keep the real package (see [Mocking a Package](#mocking-a-package)). + +A file whose imports become `await import()` has a top-level `await`. If another module imports that file with a static import, the imports that follow it can run before its body finishes, and `require()` of the file throws. A `--preload` script finishes before the test files load. + Bun does the same in a test file or a `--preload` script that has no hoisted call, if it imports `vitest` or runs with `globals = "vitest"`. Vitest runs each import before it looks at the next one, so a module that the file imports first can mock what the next import loads: ```ts title="cart.test.ts" icon="/icons/typescript.svg" @@ -807,6 +815,10 @@ test("lazy import", async () => { - A factory that uses `importOriginal` is called after the original module is evaluated, not before, so after the factories that don't use it. A factory that declares the parameter counts as using it, so the original is evaluated even if the factory never calls it. - `export * from` a mocked module re-exports the original's export names if the factory uses `importOriginal`. Names the factory adds aren't re-exported, and names it leaves out are re-exported as `undefined`. - If two files import each other and both have hoisted calls, they wait on each other and the run fails with an error that names both files. Keep the hoisted calls in only one of them. +- An import that the file exports again (`import { a } from "pkg"; export { a }`) keeps the real package after a hoisted `vi.mock()` of that package. In Vitest it gets the mock. +- A hoisted mock of the JSX runtime reaches the JSX of its file only if the call names the runtime by the path that the file imports, with a string literal or a `const` that holds one. The file's JSX keeps the real runtime if the call names another path to the same module, such as `react/jsx-dev-runtime.js`, or passes another value, such as one that `vi.hoisted()` returns. It also keeps the real runtime if the mock is made by `vi.doMock()` inside `vi.hoisted()`. In Vitest it gets the mock in each case. The same holds for an import with attributes. +- With `"jsx": "react-jsx"` in `tsconfig.json`, the JSX of a test file imports the `jsx-runtime` of its JSX source, which is `react/jsx-runtime` by default. In Vitest it imports the `jsx-dev-runtime`. Mock both to run the same file with both. +- The import of the JSX runtime that Bun adds runs before the file's other imports, like the one that esbuild adds. Vite 8 adds it behind the imports at the head of the file. There, a module that the file imports first can mock the JSX runtime for the file. ## Implementation Details diff --git a/src/aot/link/module_records.rs b/src/aot/link/module_records.rs index 949de8ef3e9f..46f5383134f2 100644 --- a/src/aot/link/module_records.rs +++ b/src/aot/link/module_records.rs @@ -21,7 +21,6 @@ use crate::ir::{ }; use crate::load::sources::parsed_source; use crate::load::{PerProject, ProgramFile, ProjectId, SourceKind}; -use crate::module_keys::type_attribute_of; use crate::modules::{ self, ExportBinding, ExportLookup, ImportBinding, Interop, ModuleFailureKind, ModuleGraph, ModuleKind, ModuleRef, Shape, Spelling, SyntheticExportRule, compare_code_units, @@ -657,8 +656,8 @@ impl RecordBuilder<'_, '_, '_> { if graph.is_imported_as_namespace(own) { record.flags |= ModuleFlags::IS_IMPORTED_AS_NAMESPACE; } - record.type_attribute = - type_attribute.map(|it| self.strings.intern(type_attribute_of(it).as_bytes())); + record.type_attribute = type_attribute + .map(|it| self.strings.intern(it.import_attribute_type().as_bytes())); } ExternalKind::HostValue => { record.external_kind = generated::ExternalKind::HostValue; diff --git a/src/aot/module_keys.rs b/src/aot/module_keys.rs index 4a51382d80f0..ec5b1bb8eb80 100644 --- a/src/aot/module_keys.rs +++ b/src/aot/module_keys.rs @@ -100,14 +100,6 @@ pub fn loader_name(loader: Loader) -> &'static str { loader.into() } -pub fn type_attribute_of(loader: Loader) -> &'static str { - match loader { - Loader::Bunsh => "sh", - Loader::SqliteEmbedded => "sqlite", - _ => loader_name(loader), - } -} - pub fn path_of_form(path: &[u8], loader: Loader) -> Vec { [path, b"?", loader_name(loader).as_bytes()].concat() } @@ -241,16 +233,32 @@ mod tests { #[test] fn type_attribute_round_trips_to_its_loader() { for loader in [ - Loader::Toml, + Loader::Jsx, + Loader::Js, + Loader::Ts, + Loader::Tsx, + Loader::Css, + Loader::File, Loader::Json, + Loader::Jsonc, + Loader::Toml, + Loader::Wasm, + Loader::Napi, + Loader::Base64, + Loader::Dataurl, Loader::Text, Loader::Bunsh, Loader::Sqlite, + Loader::Html, + Loader::Yaml, + Loader::Json5, + Loader::Md, + Loader::Xml, ] { - let written = type_attribute_of(loader).as_bytes(); + let written = loader.import_attribute_type().as_bytes(); assert_eq!(Loader::from_string(written), Some(loader)); } - assert_eq!(type_attribute_of(Loader::SqliteEmbedded), "sqlite"); + assert_eq!(Loader::SqliteEmbedded.import_attribute_type(), "sqlite"); } #[test] diff --git a/src/ast/loader.rs b/src/ast/loader.rs index cf748647a6d8..dd7770275ff6 100644 --- a/src/ast/loader.rs +++ b/src/ast/loader.rs @@ -168,6 +168,15 @@ impl Loader { } } + /// The `type` of the import attribute that selects this loader. + pub fn import_attribute_type(self) -> &'static str { + match self { + Loader::Bunsh => "sh", + Loader::SqliteEmbedded => "sqlite", + loader => loader.into(), + } + } + // `from_js` lives in bundler_jsc as an extension trait. pub(crate) const NAMES: &'static __ComptimeStringMap_LOADER_NAMES = &LOADER_NAMES; diff --git a/src/js_parser/lower/hoist_test_mocks.rs b/src/js_parser/lower/hoist_test_mocks.rs index 0f01d82e66d0..d947b1f74f4a 100644 --- a/src/js_parser/lower/hoist_test_mocks.rs +++ b/src/js_parser/lower/hoist_test_mocks.rs @@ -6,7 +6,9 @@ use bun_ast::expr::Data as ExprData; use bun_ast::stmt::Data as StmtData; use bun_ast::{self as js_ast, B, E, Expr, G, ImportKind, Ref, S, Stmt}; use bun_collections::VecExt; +use bun_core::strings; +use crate::lower::import_meta_glob::object_of; use crate::p::P; use crate::parser::{Jest, RefMap}; @@ -59,6 +61,37 @@ pub(crate) fn drop_namespace_of_lowered_callee( is_lowered } +/// A package name starts with a letter, a digit, `-`, or `@scope/` and a name, so `#cfg`, `~/cfg`, `@cfg` and `@/cfg` are aliases. +fn is_alias_name(path: &[u8]) -> bool { + match path { + [b'@', name @ ..] => !matches!( + strings::index_of_char_usize(name, b'/'), + Some(slash) if slash > 0 && slash + 1 < name.len() + ), + [first, ..] => first.is_ascii() && !first.is_ascii_alphanumeric() && *first != b'-', + [] => false, + } +} + +fn import_bindings(import: &S::Import) -> impl Iterator + '_ { + import + .default_name + .iter() + .map(|name| (name.ref_, js_ast::StoreStr::new(b"default"))) + .chain(import.items.iter().map(|item| (item.name.ref_, item.alias))) +} + +/// The second argument of `import()` for an import whose attribute picked the loader: `{ with: { type: "json" } }`. +#[optimize(size)] +fn import_options_of(loader: Option, loc: js_ast::Loc) -> Expr { + let Some(loader) = loader else { + return Expr::EMPTY; + }; + let r#type = loader.import_attribute_type().as_bytes(); + let r#type = Expr::init(E::String::init(r#type), loc); + object_of(b"with", object_of(b"type", r#type)) +} + impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { /// Picks the transpiler cache key, so `bun test` and `bun run` have to compute the same answer. pub(crate) fn uses_test_api(&self) -> bool { @@ -244,6 +277,14 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { } self.did_apply_test_feature = true; + for stmt in stmts { + if let StmtData::SExpr(expr) = stmt.data + && self.is_hoisted_mock_stmt(stmt) + { + self.note_hoisted_mock_paths(stmts, expr.value); + } + } + let mut exported = RefMap::default(); for stmt in stmts { if let StmtData::SExportClause(clause) = stmt.data { @@ -270,32 +311,110 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { Some(order.into_bump_slice()) } - /// Aliases a lowered import's names to its namespace, so `a` prints as `ns.a`. + /// Records the paths that the `mock()` and `unmock()` calls of a hoisted statement name. #[optimize(size)] - pub(crate) fn plan_import(&mut self, import: &S::Import, exported: &RefMap) -> MockHoistOrder { + fn note_hoisted_mock_paths(&mut self, stmts: &[Stmt], expr: Expr) { + let mut callee = match expr.data { + ExprData::EAwait(awaited) => awaited.value, + _ => expr, + }; + while let ExprData::ECall(call) = callee.data { + let ExprData::EDot(dot) = call.target.data else { + return; + }; + if matches!(dot.name.slice(), b"mock" | b"unmock") + && let Some(&path) = call.args.slice().first() + && let Some(path) = self.unvisited_mock_path(stmts, path) + { + self.hoisted_mock_paths.push(js_ast::StoreStr::new(path)); + } + callee = dot.target; + } + } + + /// `"./a"`, `import("./a")`, `await import("./a")`, or a `const` with that string that is hoisted with the mocks. + fn unvisited_mock_path(&self, stmts: &[Stmt], path: Expr) -> Option<&'a [u8]> { + let path = match path.data { + ExprData::EAwait(awaited) => awaited.value, + _ => path, + }; + let path = match path.data { + ExprData::EImport(import) => import.expr, + _ => path, + }; + let ExprData::EIdentifier(id) = path.data else { + return path.as_string(self.arena); + }; + let symbol = self.unvisited_top_level_symbol(id.ref_)?; + stmts + .iter() + .filter(|stmt| self.is_primitive_literal_declaration(stmt)) + .find_map(|stmt| { + let StmtData::SLocal(local) = stmt.data else { + return None; + }; + if local.kind != S::Kind::KConst { + return None; + } + local + .decls + .slice() + .iter() + .find_map(|decl| match decl.binding.data { + js_ast::binding::Data::BIdentifier(binding) + if binding.r#ref.eql(symbol) => + { + decl.value?.as_string(self.arena) + } + _ => None, + }) + }) + } + + /// A mock updates a loaded project file in place. Only an import that can be in a package has to load behind the call that names it. + fn loads_behind_hoisted_mock(&self, path: &[u8]) -> bool { + let can_be_in_package = if bun_paths::is_package_path(path) { + !is_alias_name(path) + } else { + strings::contains(path, b"/node_modules/") + }; + can_be_in_package + && self + .hoisted_mock_paths + .iter() + .any(|named| named.slice() == path) + } + + /// Where an import goes in a file that hoists. + #[optimize(size)] + fn import_order(&self, import: &S::Import, exported: &RefMap) -> MockHoistOrder { let record = &self.import_records.items()[import.import_record_index as usize]; // An import of "bun" prints as `var`s that read `globalThis.Bun`. if is_test_module(record.path.text) || record.path.text == b"bun" { return MockHoistOrder::BeforeHoisted; } - if import.phase_defer - || record.loader.is_some() - || record.tag != js_ast::ImportRecordTag::None - { + if import.phase_defer || record.tag != js_ast::ImportRecordTag::None { + return MockHoistOrder::InPlace; + } + // An import with attributes stays static, unless a hoisted mock of its path would miss it. + if record.loader.is_some() && !self.loads_behind_hoisted_mock(record.path.text) { return MockHoistOrder::InPlace; } - - let bindings = || { - import - .default_name - .iter() - .map(|name| (name.ref_, js_ast::StoreStr::new(b"default"))) - .chain(import.items.iter().map(|item| (item.name.ref_, item.alias))) - }; // `export { a }` needs `a` to be a binding. - if bindings().any(|(ref_, _)| exported.contains_key(&ref_)) { + if import_bindings(import).any(|(ref_, _)| exported.contains_key(&ref_)) { return MockHoistOrder::InPlace; } + MockHoistOrder::LoweredImport + } + + /// Aliases a lowered import's names to its namespace, so `a` prints as `ns.a`. + #[optimize(size)] + pub(crate) fn plan_import(&mut self, import: &S::Import, exported: &RefMap) -> MockHoistOrder { + let order = self.import_order(import, exported); + if !matches!(order, MockHoistOrder::LoweredImport) { + return order; + } + let bindings = || import_bindings(import); if import.star_name_loc.is_empty() && bindings().next().is_some() { self.temp_ref_count += 1; @@ -320,7 +439,51 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { was_originally_property_access: false, })); } - MockHoistOrder::LoweredImport + order + } + + /// An import that the parser added after the visit pass goes behind a hoisted mock that names it. + #[optimize(size)] + fn lower_mocked_generated_imports( + &mut self, + before: &mut BumpVec<'a, js_ast::Part>, + imports: &mut BumpVec<'a, js_ast::Part>, + ) { + let not_exported = RefMap::default(); + let mut lowered = BumpVec::new_in(self.arena); + let mut index = 0; + while index < before.len() { + let stmts = before[index].stmts; + let mut is_mocked = false; + let can_be_lowered = stmts.iter().all(|stmt| { + let StmtData::SImport(import) = stmt.data else { + return true; + }; + let record = &self.import_records.items()[import.import_record_index as usize]; + is_mocked |= self.loads_behind_hoisted_mock(record.path.text); + // "chai" binds `assert` and `should` of "vitest", which a mock of "chai" doesn't reach in Vitest. + !record + .flags + .contains(js_ast::ImportRecordFlags::IS_INTERNAL) + && record.path.text != b"chai" + && matches!( + self.import_order(&import, ¬_exported), + MockHoistOrder::LoweredImport + ) + }); + if !(can_be_lowered && is_mocked) { + index += 1; + continue; + } + for stmt in stmts.iter() { + if let StmtData::SImport(import) = stmt.data { + self.plan_import(&import, ¬_exported); + } + } + lowered.push(before.remove(index)); + } + lowered.append(imports); + *imports = lowered; } /// `ImportKind::Dynamic` on an `S::Import`'s record tells `ImportScanner` to lower it. @@ -333,6 +496,9 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { exports_kind: js_ast::ExportsKind, ) { if exports_kind != js_ast::ExportsKind::Cjs { + if !self.hoisted_mock_paths.is_empty() { + self.lower_mocked_generated_imports(before, imports); + } for stmt in imports.iter().flat_map(|part| part.stmts.iter()) { if let StmtData::SImport(import) = stmt.data { self.import_records.items_mut()[import.import_record_index as usize].kind = @@ -348,11 +514,12 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { pub(crate) fn lower_import_to_dynamic(&mut self, import: &S::Import, loc: js_ast::Loc) -> Stmt { let record = &self.import_records.items()[import.import_record_index as usize]; let (path, path_loc) = (record.path.text, record.range.loc); + let options = import_options_of(record.loader, loc); let path = self.new_expr(E::String::init(path), path_loc); let namespace = self.new_expr( E::Import { expr: path, - options: Expr::EMPTY, + options, import_record_index: import.import_record_index, namespace_ref: Ref::NONE, }, diff --git a/src/js_parser/lower/import_meta_glob.rs b/src/js_parser/lower/import_meta_glob.rs index 1db6b49beb81..47b00419b8f8 100644 --- a/src/js_parser/lower/import_meta_glob.rs +++ b/src/js_parser/lower/import_meta_glob.rs @@ -903,7 +903,7 @@ impl<'a> Arguments<'a, '_> { } } -fn object_of(key: &'static [u8], value: Expr) -> Expr { +pub(crate) fn object_of(key: &'static [u8], value: Expr) -> Expr { Expr::init( E::Object { properties: G::PropertyList::init_one(G::Property { diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 3859c3391621..3255576e7fc6 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -473,6 +473,8 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { pub(crate) did_apply_test_feature: bool, /// Also computed outside `bun test`, for `uses_test_api()`. pub(crate) has_hoisted_mock_stmt: bool, + /// The paths that the file's hoisted `mock()` and `unmock()` calls name with a string literal. + pub(crate) hoisted_mock_paths: List<'a, js_ast::StoreStr>, pub(crate) import_meta_glob_use: crate::lower::import_meta_glob::ImportMetaGlobUse, pub(crate) import_meta_glob_imports: List<'a, Stmt>, @@ -9804,6 +9806,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O chai_imports: Default::default(), did_apply_test_feature: false, has_hoisted_mock_stmt: false, + hoisted_mock_paths: BumpVec::new_in(arena), import_meta_glob_use: Default::default(), import_meta_glob_imports: BumpVec::new_in(arena), import_meta_glob_eager_calls: BumpVec::new_in(arena), diff --git a/src/js_parser/react_compiler_host.rs b/src/js_parser/react_compiler_host.rs index 425bafcd61c8..c6bcfaeeae29 100644 --- a/src/js_parser/react_compiler_host.rs +++ b/src/js_parser/react_compiler_host.rs @@ -185,7 +185,11 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> bun_react_compiler::Host let p = &mut *self.p; let path = p.arena.alloc_slice_copy(path); let index = p.add_import_record_by_range(kind, bun_ast::Range::NONE, path); - let namespace_ref = p.new_symbol(js_ast::symbol::Kind::Other, path); + // An identifier, because `bun test` prints the namespace of an import that it lowers behind hoisted mocks. + let name = bun_alloc::arena_format!(in p.arena, "{}", bun_core::fmt::fmt_identifier(path)) + .into_bump_str() + .as_bytes(); + let namespace_ref = p.new_symbol(js_ast::symbol::Kind::Other, name); VecExt::append(&mut p.module_scope_mut().generated, namespace_ref); (index, namespace_ref) } diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 067c3be8510c..e99d0d959ead 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -4285,9 +4285,12 @@ pub(crate) mod __gated_printer { self.print_undefined(expr.loc, level); did_print = true; } else if let Some(namespace) = &symbol.namespace_alias { + let mut is_lowered_import = false; if (namespace.import_record_index as usize) < self.import_records.len() { let import_record = self.import_record(namespace.import_record_index as usize); + // Only an import that `bun test` lowers behind hoisted mocks has a record of this kind. + is_lowered_import = import_record.kind == ImportKind::Dynamic; if namespace.was_originally_property_access { let mut wrap = false; did_print = true; @@ -4329,12 +4332,20 @@ pub(crate) mod __gated_printer { if !did_print { did_print = true; - let wrap = if let Some(target) = &self.call_target { - e.was_originally_identifier() - && matches!(target, ExprData::EIdentifier(id) if id.ref_.eql(e.ref_)) - } else { - false - }; + // A call built after the visit pass, such as a JSX call, still has the import as its callee. + let is_lowered_callee = is_lowered_import + && e.was_originally_identifier() + && matches!(&self.call_target, Some(ExprData::EImportIdentifier(id)) if id.ref_.eql(e.ref_)); + if is_lowered_callee { + self.call_target = None; + } + let wrap = is_lowered_callee + || if let Some(target) = &self.call_target { + e.was_originally_identifier() + && matches!(target, ExprData::EIdentifier(id) if id.ref_.eql(e.ref_)) + } else { + false + }; if wrap { self.print_whitespacer(ws!(b"(0, ")); @@ -6256,72 +6267,9 @@ pub(crate) mod __gated_printer { // backwards compatibility: previously, we always stripped type if IS_BUN_PLATFORM { if let Some(loader) = record.loader { - use bun_ast::Loader; - match loader { - Loader::Jsx => { - self.print_whitespacer(ws!(b" with { type: \"jsx\" }")) - } - Loader::Js => { - self.print_whitespacer(ws!(b" with { type: \"js\" }")) - } - Loader::Ts => { - self.print_whitespacer(ws!(b" with { type: \"ts\" }")) - } - Loader::Tsx => { - self.print_whitespacer(ws!(b" with { type: \"tsx\" }")) - } - Loader::Css => { - self.print_whitespacer(ws!(b" with { type: \"css\" }")) - } - Loader::File => { - self.print_whitespacer(ws!(b" with { type: \"file\" }")) - } - Loader::Json => { - self.print_whitespacer(ws!(b" with { type: \"json\" }")) - } - Loader::Jsonc => { - self.print_whitespacer(ws!(b" with { type: \"jsonc\" }")) - } - Loader::Toml => { - self.print_whitespacer(ws!(b" with { type: \"toml\" }")) - } - Loader::Yaml => { - self.print_whitespacer(ws!(b" with { type: \"yaml\" }")) - } - Loader::Json5 => { - self.print_whitespacer(ws!(b" with { type: \"json5\" }")) - } - Loader::Xml => { - self.print_whitespacer(ws!(b" with { type: \"xml\" }")) - } - Loader::Wasm => { - self.print_whitespacer(ws!(b" with { type: \"wasm\" }")) - } - Loader::Napi => { - self.print_whitespacer(ws!(b" with { type: \"napi\" }")) - } - Loader::Base64 => { - self.print_whitespacer(ws!(b" with { type: \"base64\" }")) - } - Loader::Dataurl => { - self.print_whitespacer(ws!(b" with { type: \"dataurl\" }")) - } - Loader::Text => { - self.print_whitespacer(ws!(b" with { type: \"text\" }")) - } - Loader::Bunsh => { - self.print_whitespacer(ws!(b" with { type: \"sh\" }")) - } - Loader::Sqlite | Loader::SqliteEmbedded => { - self.print_whitespacer(ws!(b" with { type: \"sqlite\" }")) - } - Loader::Html => { - self.print_whitespacer(ws!(b" with { type: \"html\" }")) - } - Loader::Md => { - self.print_whitespacer(ws!(b" with { type: \"md\" }")) - } - } + self.print_whitespacer(ws!(b" with { type: \"")); + self.print(loader.import_attribute_type().as_bytes()); + self.print_whitespacer(ws!(b"\" }")); } } self.print_semicolon_after_statement(); @@ -6336,31 +6284,11 @@ pub(crate) mod __gated_printer { let irp_id = mi.str(record.path.text); let fetch_parameters: FP = if IS_BUN_PLATFORM { if let Some(loader) = record.loader { - use bun_ast::Loader; match loader { - Loader::Json => FP::Json, - Loader::Jsx => FP::host_defined(mi.str(b"jsx")), - Loader::Js => FP::host_defined(mi.str(b"js")), - Loader::Ts => FP::host_defined(mi.str(b"ts")), - Loader::Tsx => FP::host_defined(mi.str(b"tsx")), - Loader::Css => FP::host_defined(mi.str(b"css")), - Loader::File => FP::host_defined(mi.str(b"file")), - Loader::Jsonc => FP::host_defined(mi.str(b"jsonc")), - Loader::Toml => FP::host_defined(mi.str(b"toml")), - Loader::Yaml => FP::host_defined(mi.str(b"yaml")), - Loader::Wasm => FP::host_defined(mi.str(b"wasm")), - Loader::Napi => FP::host_defined(mi.str(b"napi")), - Loader::Base64 => FP::host_defined(mi.str(b"base64")), - Loader::Dataurl => FP::host_defined(mi.str(b"dataurl")), - Loader::Text => FP::host_defined(mi.str(b"text")), - Loader::Bunsh => FP::host_defined(mi.str(b"sh")), - Loader::Sqlite | Loader::SqliteEmbedded => { - FP::host_defined(mi.str(b"sqlite")) - } - Loader::Html => FP::host_defined(mi.str(b"html")), - Loader::Json5 => FP::host_defined(mi.str(b"json5")), - Loader::Xml => FP::host_defined(mi.str(b"xml")), - Loader::Md => FP::host_defined(mi.str(b"md")), + bun_ast::Loader::Json => FP::Json, + loader => FP::host_defined( + mi.str(loader.import_attribute_type().as_bytes()), + ), } } else { FP::None diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index cafb4f0281bf..8da5bd86e325 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -85,7 +85,8 @@ bun_core::declare_scope!(cache, visible); /// Version 50: The body of a class with lowered class decorators names the class by the /// binding that holds what the decorators return. /// Version 51: A `bun:` specifier that names no builtin is printed as it is written. -const EXPECTED_VERSION: u32 = 51; +/// Version 52: `bun test` lowers an import that a hoisted mock names when it has attributes or the parser adds it. +const EXPECTED_VERSION: u32 = 52; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/test/aot/mocks-are-hoisted-above-the-imports.test.ts b/test/aot/mocks-are-hoisted-above-the-imports.test.ts index 80051c8da76a..65723a7f26c9 100644 --- a/test/aot/mocks-are-hoisted-above-the-imports.test.ts +++ b/test/aot/mocks-are-hoisted-above-the-imports.test.ts @@ -9,6 +9,17 @@ import { test } from "./supported"; const isolations = [`[]`, `["--isolate"]`, `["--isolate=modules"]`]; const laterConst = "factory can read a later const though an earlier file or a preload loaded the module"; +const vitest = `import { test, vi } from "vitest";`; +const mockedRuntimeIsBehind = "errors > a mocked JSX runtime is imported behind the hoisted statements"; +const namedBy = ["a string literal", "a const that holds the string"].map( + by => `gets a hoisted mock that names its path with ${by}, and the real module is not evaluated`, +); +const notInstalled = "needs no real module once it is mocked"; +const readThroughNamespace = + "is called without a this and read through its namespace, so a later mock.module() reaches it"; +const staysStatic = [`"./mod"`, `"./mod\u00e8le"`, `require.resolve("./mod")`, `byLet`, `fromHoisted`].map( + path => `stays static while no hoisted call names its path: vi.mock(${path})`, +); // A case that comes to agree turns this test red: take it out of its list. const differs = { @@ -24,13 +35,56 @@ const differs = { "an error of the build has its number in front": [ "errors > an import statement in a CommonJS file is still rejected", ], + "the JSX runtime and an import with attributes are linked in front of a hoisted mock that names them": [ + ...[ + ` vi.mock("react/jsx-dev-runtime"`, + `${vitest} vi.mock("react/jsx-dev-runtime"`, + `import { test, vi } from "bun:test"; vi.mock("react/jsx-dev-runtime"`, + `import { test, jest } from "bun:test"; jest.mock("react/jsx-dev-runtime"`, + `${vitest} await vi.mock(import("react/jsx-dev-runtime")`, + vitest + " vi.mock(`react/jsx-dev-runtime`", + `${vitest} const runtime = "react/jsx-dev-runtime"; vi.mock(runtime`, + ].map(call => `hoisting > ${call}, factory) reaches the JSX of the file`), + `hoisting > with "jsx": "react-jsx", a mock of react/jsx-runtime`, + "hoisting > a hand-written import of the JSX runtime gets the same mock as the file's JSX", + ...[`[]`, `["--preload","./preload.jsx"]`].map( + flags => `hoisting > a factory that wraps the real JSX runtime sees the JSX of the file: ${flags}`, + ), + "hoisting > a mocked JSX runtime loads behind the hoisted statements and ahead of the file's imports", + "hoisting > JSX in a function that vi.hoisted() returns uses the mocked JSX runtime", + "hoisting > a mock of a package reaches an import of it with attributes", + "hoisting > a hoisted call names an import with attributes by a path that is not ASCII", + "hoisting > an import with attributes that a hoisted call names keeps its loader", + "hoisting > an eager import.meta.glob entry with a query follows a hoisted mock that names it", + `${mockedRuntimeIsBehind}: JSX that vi.hoisted() evaluates`, + `${mockedRuntimeIsBehind}: a factory that reads a later const`, + ...["the JSX runtime", "createElement of the JSX package"].flatMap(kind => + [...namedBy, notInstalled].map(rule => `${kind} > ${rule}`), + ), + `an import with attributes > ${notInstalled}`, + ], + "the build does not see a mock of a module that is imported with attributes": [ + ...namedBy, + readThroughNamespace, + staysStatic[3], + ].map(rule => `an import with attributes > ${rule}`), + "the React Compiler leaves compiled code as it is": [ + "hoisting > a call that the React Compiler writes again that the file imports has no this", + ...[...namedBy, notInstalled, readThroughNamespace, ...staysStatic].map( + rule => `the runtime of the React Compiler > ${rule}`, + ), + ], }; // How many cases of each section of the specification pass. const sections = { - "hoisting": 36, + "hoisting": 50, + "the JSX runtime": 6, + "createElement of the JSX package": 6, + "the runtime of the React Compiler": 0, + "an import with attributes": 4, "imports that stay static": 4, - "not hoisted": 21, + "not hoisted": 22, "vi.mock(import(path))": 12, "modes": 7, "a top-level variable in a factory of jest.mock": 18, @@ -67,6 +121,6 @@ test.each(Object.entries(sections))( expect(named("fail")).toEqual(differing.sort()); expect(named("pass")).toHaveLength(passing); // A section that is not listed here would run in no case. - expect(Number(/^ *(\d+) filtered out$/m.exec(stderr)?.[1])).toBe(122 + 12 - passing - differing.length); + expect(Number(/^ *(\d+) filtered out$/m.exec(stderr)?.[1])).toBe(122 + 12 + 71 - passing - differing.length); }, ); diff --git a/test/js/bun/test/mock/mock-hoist.test.ts b/test/js/bun/test/mock/mock-hoist.test.ts index 99f07e6b1c8f..9fd0085ca912 100644 --- a/test/js/bun/test/mock/mock-hoist.test.ts +++ b/test/js/bun/test/mock/mock-hoist.test.ts @@ -26,6 +26,40 @@ const modules = { }; const print = `test("print", () => console.log(JSON.stringify(globalThis.events ?? [])));`; +// Each module says when it is evaluated, and each function whether a call passes it a `this`. +const withoutThis = (value: string) => `(this === undefined ? ${value} : "has this")`; +const memoCache = `new Array(size).fill(Symbol.for("react.memo_cache_sentinel"))`; +const react = { + "node_modules/react/package.json": `{ "name": "react", "type": "module" }`, + "node_modules/react/index.js": ` + (globalThis.events ??= []).push("load react"); + export function createElement(type) { return ${withoutThis("type")}; } + `, + "node_modules/react/jsx-dev-runtime.js": ` + (globalThis.events ??= []).push("load runtime"); + export function jsxDEV(type) { return ${withoutThis("type")}; } + export const Fragment = "fragment"; + `, + "node_modules/react/jsx-runtime.js": ` + (globalThis.events ??= []).push("load production runtime"); + export function jsx(type) { return ${withoutThis("type")}; } + export const jsxs = jsx, Fragment = "fragment"; + `, + "node_modules/react/compiler-runtime.js": ` + (globalThis.events ??= []).push("load compiler runtime"); + export function c(size) { events.push(${withoutThis(`"cache"`)}); return ${memoCache}; } + `, + "node_modules/pkg/package.json": `{ "name": "pkg", "type": "module" }`, + "node_modules/pkg/self.js": ` + (globalThis.events ??= []).push("load pkg"); + export function self() { return ${withoutThis(`"self"`)}; } + `, +}; +const mockedRuntime = `() => { + (globalThis.events ??= []).push("factory"); + return { jsxDEV: type => "mocked " + type, Fragment: "fragment of the mock" }; +}`; + async function run( files: Record, args: string[] = ["hoist.test.ts"], @@ -442,6 +476,451 @@ describe.concurrent("hoisting", () => { ), ).toEqual([["mocked", "mocked name", "fragment"]]); }); + + test.each([ + [`import { test, vi } from "vitest";`, `vi.mock("react/jsx-dev-runtime"`], + [`import { test, vi } from "bun:test";`, `vi.mock("react/jsx-dev-runtime"`], + [``, `vi.mock("react/jsx-dev-runtime"`], + [`import { test, jest } from "bun:test";`, `jest.mock("react/jsx-dev-runtime"`], + [`import { test, vi } from "vitest";`, `await vi.mock(import("react/jsx-dev-runtime")`], + [`import { test, vi } from "vitest";`, "vi.mock(`react/jsx-dev-runtime`"], + [`import { test, vi } from "vitest"; const runtime = "react/jsx-dev-runtime";`, `vi.mock(runtime`], + ])("%s %s, factory) reaches the JSX of the file", async (header, call) => { + expect( + await events( + { + ...react, + "hoist.test.tsx": ` + ${header} + ${call}, ${mockedRuntime}); + (globalThis.events ??= []).push(, <>); + ${print} + `, + }, + ["hoist.test.tsx"], + ), + // The real runtime is never evaluated. + ).toEqual([["factory", "mocked b", "mocked fragment of the mock"]]); + }); + + // `"jsx": "react-jsx"` makes the file import "react/jsx-runtime", and a mock of the other runtime names nothing it imports. + test.each([ + ["react/jsx-runtime", ["mocked b", "mocked many a", "mocked fragment of the mock"]], + ["react/jsx-dev-runtime", ["load production runtime", "b", "a", "fragment"]], + ])(`with "jsx": "react-jsx", a mock of %s`, async (runtime, expected) => { + expect( + await events( + { + ...react, + "tsconfig.json": `{ "compilerOptions": { "jsx": "react-jsx" } }`, + "hoist.test.tsx": ` + import { test, vi } from "vitest"; + vi.mock("${runtime}", () => ({ + jsx: type => "mocked " + type, + jsxs: type => "mocked many " + type, + jsxDEV: type => "mocked " + type, + Fragment: "fragment of the mock", + })); + (globalThis.events ??= []).push(, , <>); + ${print} + `, + }, + ["hoist.test.tsx"], + ), + ).toEqual([expected]); + }); + + test("a hand-written import of the JSX runtime gets the same mock as the file's JSX", async () => { + expect( + await events( + { + ...react, + "hoist.test.tsx": ` + import { test, vi } from "vitest"; + import { jsxDEV as byHand } from "react/jsx-dev-runtime"; + vi.mock("react/jsx-dev-runtime", ${mockedRuntime}); + (globalThis.events ??= []).push(byHand("u"), ); + ${print} + `, + }, + ["hoist.test.tsx"], + ), + ).toEqual([["factory", "mocked u", "mocked b"]]); + }); + + // The factory that public suites write: it wraps the real runtime. + test.each([ + [[], ["load runtime", "factory", "load other", "b", 1, "other"]], + [ + ["--preload", "./preload.jsx"], + ["load runtime", "preloaded i", "factory", "load other", "b", 1, "other"], + ], + ])("a factory that wraps the real JSX runtime sees the JSX of the file: %j", async (flags, expected) => { + expect( + await events( + { + ...react, + "preload.jsx": `(globalThis.events ??= []).push("preloaded " + );`, + "hoist.test.tsx": ` + import { test, vi } from "vitest"; + import { jsxDEV } from "react/jsx-dev-runtime"; + import { other } from "./other"; + vi.mock("react/jsx-dev-runtime", async importOriginal => { + const actual = await importOriginal(); + (globalThis.events ??= []).push("factory"); + return { ...actual, jsxDEV: vi.fn(actual.jsxDEV) }; + }); + events.push(, jsxDEV.mock.calls.length, other); + ${print} + `, + }, + [...flags, "hoist.test.tsx"], + ), + ).toEqual([expected]); + }); + + test("a mocked JSX runtime loads behind the hoisted statements and ahead of the file's imports", async () => { + expect( + await events( + { + ...react, + "hoist.test.tsx": ` + import { test, vi } from "vitest"; + import { other } from "./other"; + vi.hoisted(() => (globalThis.events ??= []).push("hoisted")); + vi.unmock("react/jsx-dev-runtime"); + events.push(, other); + ${print} + `, + }, + ["hoist.test.tsx"], + ), + ).toEqual([["hoisted", "load runtime", "load other", "b", "other"]]); + }); + + test("a hoisted mock of the JSX runtime loads nothing in a file without JSX", async () => { + expect( + await events( + { + ...react, + "hoist.test.tsx": ` + import { test, vi } from "vitest"; + vi.mock("react/jsx-dev-runtime", ${mockedRuntime}); + ${print} + `, + }, + ["hoist.test.tsx"], + ), + ).toEqual([[]]); + }); + + test("JSX in a function that vi.hoisted() returns uses the mocked JSX runtime", async () => { + expect( + await events( + { + ...react, + "hoist.test.tsx": ` + import { test, vi } from "vitest"; + const mocks = vi.hoisted(() => ({ icon: () => })); + vi.mock("react/jsx-dev-runtime", ${mockedRuntime}); + (globalThis.events ??= []).push(mocks.icon()); + ${print} + `, + }, + ["hoist.test.tsx"], + ), + ).toEqual([["factory", "mocked i"]]); + }); + + // No hoisted call names the JSX runtime, so it is loaded when the factory runs where vi.mock() is written. + test.each([`"./mod"`, `require.resolve("./mod")`])( + "a hoisted factory with JSX updates a module that is already linked: vi.mock(%s)", + async path => { + expect( + await events({ + ...react, + "reader.ts": `import { value } from "./mod"; export const read = () => value;`, + "mocks.tsx": ` + import { vi } from "bun:test"; + vi.mock(${path}, () => ({ value: })); + `, + "hoist.test.ts": ` + import { test } from "bun:test"; + import "./mocks"; + import { read } from "./reader"; + (globalThis.events ??= []).push(read()); + ${print} + `, + }), + ).toEqual([["load runtime", "load mod", "i"]]); + }, + ); + + test("a mock of a package reaches an import of it with attributes", async () => { + expect( + await events({ + "node_modules/pkg/package.json": `{ "name": "pkg" }`, + "node_modules/pkg/data.json": `{ "v": "real" }`, + "node_modules/pkg/note.txt": `real`, + "hoist.test.ts": ` + import { test, vi } from "vitest"; + import data from "pkg/data.json" with { type: "json" }; + import note from "pkg/note.txt" with { type: "text" }; + vi.mock("pkg/data.json", () => ({ default: { v: "mocked" } })); + vi.mock("pkg/note.txt", () => ({ default: "mocked" })); + (globalThis.events ??= []).push(data.v, note); + ${print} + `, + }), + ).toEqual([["mocked", "mocked"]]); + }); + + test("a hoisted call names an import with attributes by a path that is not ASCII", async () => { + expect( + await events({ + "hoist.test.ts": ` + import { test, vi } from "vitest"; + import data from "caf\u00e9/data.json" with { type: "json" }; + vi.mock("caf\u00e9/data.json", () => ({ default: { v: "mocked" } })); + (globalThis.events ??= []).push(data.v); + ${print} + `, + }), + ).toEqual([["mocked"]]); + }); + + // A mock updates a loaded project file in place, so this import gets the mock and the helper needs no top-level await. + test.each([ + { name: "vi.mock", path: "./cfg.json", call: "vi.mock", expected: "mocked" }, + { name: "vi.unmock", path: "./cfg.json", call: "vi.unmock", expected: "real" }, + { name: "a name with node_modules in it", path: "./node_modules-cfg.json", call: "vi.mock", expected: "mocked" }, + { + name: "an alias from imports in package.json", + path: "#cfg", + call: "vi.mock", + expected: "mocked", + files: { "package.json": `{ "imports": { "#cfg": "./cfg.json" } }` }, + }, + ...["@cfg", "@/cfg", "~/cfg", "$cfg"].map(path => ({ + name: `the alias ${path} from paths in tsconfig.json`, + path, + call: "vi.mock", + expected: "mocked", + files: { "tsconfig.json": `{ "compilerOptions": { "paths": { "${path}": ["./cfg.json"] } } }` }, + })), + ])( + "a helper that names a project file that it imports with attributes runs ahead of the next import: $name", + async ({ path, call, expected, files }) => { + const factory = call === "vi.mock" ? `, () => ({ default: { env: "mocked" } })` : ``; + expect( + await events({ + [path.startsWith(".") ? path : "cfg.json"]: `{ "env": "real" }`, + ...files, + "helper.ts": ` + import { vi } from "bun:test"; + import cfg from "${path}" with { type: "json" }; + ${call}("${path}"${factory}); + globalThis.env = cfg.env; + `, + "app.ts": `(globalThis.events ??= []).push(globalThis.env);`, + "hoist.test.ts": ` + import { test } from "bun:test"; + import "./helper"; + import "./app"; + ${print} + `, + }), + ).toEqual([[expected]]); + }, + ); + + test("require() loads a helper that names a project file that it imports with attributes", async () => { + expect( + await events({ + "cfg.json": `{ "env": "real" }`, + "helper.ts": ` + import { vi } from "bun:test"; + import cfg from "./cfg.json" with { type: "json" }; + vi.mock("./cfg.json", () => ({ default: { env: "mocked" } })); + globalThis.env = cfg.env; + `, + "hoist.test.ts": ` + import { test } from "bun:test"; + require("./helper"); + (globalThis.events ??= []).push(globalThis.env); + ${print} + `, + }), + ).toEqual([["mocked"]]); + }); + + test("an import with attributes that a hoisted call names keeps its loader", async () => { + expect( + await events({ + "node_modules/pkg/package.json": `{ "name": "pkg" }`, + "node_modules/pkg/data.ts": `not typescript`, + "hoist.test.ts": ` + import { test, vi } from "vitest"; + import text from "pkg/data.ts" with { type: "text" }; + vi.unmock("pkg/data.ts"); + function read() { return text; } + (globalThis.events ??= []).push(text, read.toString().replace(/\\s+/g, " ")); + ${print} + `, + }), + ).toEqual([ + ["not typescript", expect.stringMatching(/^function read\(\) \{ return import_data\$\d+\.default; \}$/)], + ]); + }); + + test("an eager import.meta.glob entry with a query follows a hoisted mock that names it", async () => { + expect( + await events({ + "node_modules/pkg/package.json": `{ "name": "pkg" }`, + "node_modules/pkg/note.txt": `real`, + "hoist.test.ts": ` + import { test, vi } from "vitest"; + const notes = import.meta.glob("./node_modules/pkg/*.txt", { eager: true, query: "?raw", import: "default" }); + vi.mock("./node_modules/pkg/note.txt?raw", () => ({ default: "mocked" })); + (globalThis.events ??= []).push(notes); + ${print} + `, + }), + ).toEqual([[{ "./node_modules/pkg/note.txt": "mocked" }]]); + }); + + // The visit pass can't drop the namespace of these callees: the parser and the React Compiler write the calls later. + test.each([ + ["a classic JSX factory", [], `/** @jsxRuntime classic @jsx h */`, ``, ["b"]], + [ + "a call that the React Compiler writes again", + ["--react-compiler"], + ``, + `useLast("b")[0]`, + ["load compiler runtime", "cache", "b"], + ], + ])("%s that the file imports has no this", async (_, flags, pragma, use, expected) => { + expect( + await events( + { + ...react, + "h.ts": `export function h(this: unknown, type: string) { return ${withoutThis("type")}; }`, + "view.jsx": ` + ${pragma} + import { h } from "./h"; + vi.mock("./mod", () => ({ value: "mocked" })); + function useOne() { return 1; } + function useLast(type) { const one = useOne(); return [h(type), one]; } + export const view = () => ${use}; + `, + "hoist.test.ts": ` + import { test } from "bun:test"; + import { view } from "./view.jsx"; + (globalThis.events ??= []).push(view()); + ${print} + `, + }, + [...flags, "hoist.test.ts"], + ), + ).toEqual([expected]); + }); +}); + +// An import that stays static in a file that hoists runs behind the hoisted statements once one of them names its path. +describe.concurrent.each([ + { + kind: "the JSX runtime", + path: "react/jsx-dev-runtime", + file: "node_modules/react/jsx-dev-runtime.js", + head: ``, + use: ``, + mock: `{ jsxDEV: () => "mocked" }`, + real: ["load runtime", "b"], + flags: [], + }, + { + kind: "createElement of the JSX package", + path: "react", + file: "node_modules/react/index.js", + head: `const props = {};`, + use: ``, + mock: `{ createElement: () => "mocked" }`, + real: ["load react", "b"], + flags: [], + }, + { + kind: "the runtime of the React Compiler", + path: "react/compiler-runtime", + file: "node_modules/react/compiler-runtime.js", + head: `function useOne() { return 1; } function useLast(value) { const one = useOne(); return [value, one]; }`, + use: `(useLast(1), events.pop())`, + mock: `{ c(size) { events.push("mocked"); return ${memoCache}; } }`, + real: ["load compiler runtime", "cache"], + flags: ["--react-compiler"], + }, + { + kind: "an import with attributes", + path: "pkg/self.js", + file: "node_modules/pkg/self.js", + head: `import { self } from "pkg/self.js" with { type: "js" };`, + use: `self()`, + mock: `{ self: () => "mocked" }`, + real: ["load pkg", "self"], + flags: [], + }, +])("$kind", ({ path, file, head, use, mock, real, flags }) => { + async function eventsOf(hoisted: string, body = `events.push(result());`, files: Record = react) { + return await events( + { + ...files, + "module.jsx": ` + import { vi } from "bun:test"; + ${head} + ${hoisted} + export const result = () => ${use}; + `, + "hoist.test.ts": ` + import { test, mock } from "bun:test"; + import { result } from "./module.jsx"; + globalThis.events ??= []; + ${body} + ${print} + `, + }, + [...flags, "hoist.test.ts"], + ); + } + + test.each([ + ["a string literal", `vi.mock("${path}", () => (${mock}));`], + ["a const that holds the string", `const byConst = "${path}"; vi.mock(byConst, () => (${mock}));`], + ])("gets a hoisted mock that names its path with %s, and the real module is not evaluated", async (_, hoisted) => { + expect(await eventsOf(hoisted)).toEqual([["mocked"]]); + }); + + test("needs no real module once it is mocked", async () => { + const { [file]: _, ...notInstalled } = react; + expect(await eventsOf(`vi.mock("${path}", () => (${mock}));`, undefined, notInstalled)).toEqual([["mocked"]]); + }); + + test("is called without a this and read through its namespace, so a later mock.module() reaches it", async () => { + const body = `events.push(result()); mock.module("${path}", () => (${mock})); events.push(result());`; + expect(await eventsOf(`vi.unmock("${path}");`, body)).toEqual([[...real, "mocked"]]); + }); + + // A value that only the run knows names no path, even one that holds the path of this import. + test.each([`"./mod"`, `"./mod\u00e8le"`, `require.resolve("./mod")`, `byLet`, `fromHoisted`])( + "stays static while no hoisted call names its path: vi.mock(%s)", + async named => { + const hoisted = ` + let byLet = "${path}"; + const fromHoisted = vi.hoisted(() => "${path}"); + vi.mock(${named}, () => ({})); + vi.hoisted(() => (globalThis.events ??= []).push("hoisted")); + `; + expect(await eventsOf(hoisted)).toEqual([[real[0], "hoisted", real[1]]]); + }, + ); }); describe.concurrent("imports that stay static", () => { @@ -655,6 +1134,26 @@ describe.concurrent("not hoisted", () => { ]); }); + // The JSX transform of esbuild puts its import there too. The one of Vite 8 puts it behind the imports that lead the file. + test("without a hoisted call the JSX runtime loads ahead of the file's imports", async () => { + expect( + await events( + { + ...react, + "mocks.ts": `import { vi } from "vitest"; vi.mock("react/jsx-dev-runtime", ${mockedRuntime});`, + "hoist.test.tsx": ` + import { test } from "vitest"; + import "./mocks"; + import { other } from "./other"; + events.push(, other); + ${print} + `, + }, + ["hoist.test.tsx"], + ), + ).toEqual([["load runtime", "load other", "b", "other"]]); + }); + test("without a hoisted statement the module has no top-level await", async () => { expect( await events({ @@ -989,6 +1488,33 @@ describe.concurrent("errors", () => { expect(exitCode).toBe(1); }); + test.each([ + [ + "JSX that vi.hoisted() evaluates", + `vi.mock("react/jsx-dev-runtime", () => ({})); const element = vi.hoisted(() => );`, + /ReferenceError: Cannot access 'jsx_dev_runtime\$\d+' before initialization/, + ], + [ + "a factory that reads a later const", + `const later = () => "mocked"; vi.mock("react/jsx-dev-runtime", () => ({ jsxDEV: later }));`, + /ReferenceError: Cannot access 'later' before initialization/, + ], + ])("a mocked JSX runtime is imported behind the hoisted statements: %s", async (_, statements, error) => { + const { stderr, exitCode } = await run( + { + ...react, + "hoist.test.tsx": ` + import { test, vi } from "vitest"; + ${statements} + test("unreachable", () => { ; }); + `, + }, + ["hoist.test.tsx"], + ); + expect(stderr).toMatch(error); + expect(exitCode).toBe(1); + }); + test.each(isolationFlags.flatMap(([flags]) => ["vi", "jest"].map(api => [api, flags] as const)))( "a %s.mock() factory can read a later const though an earlier file or a preload loaded the module: %j", async (api, flags) => {