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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/test/mocks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down
5 changes: 2 additions & 3 deletions src/aot/link/module_records.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
30 changes: 19 additions & 11 deletions src/aot/module_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
[path, b"?", loader_name(loader).as_bytes()].concat()
}
Expand Down Expand Up @@ -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]
Expand Down
9 changes: 9 additions & 0 deletions src/ast/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
201 changes: 184 additions & 17 deletions src/js_parser/lower/hoist_test_mocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<Item = (Ref, js_ast::StoreStr)> + '_ {
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<js_ast::Loader>, 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 {
Expand Down Expand Up @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

let mut exported = RefMap::default();
for stmt in stmts {
if let StmtData::SExportClause(clause) = stmt.data {
Expand All @@ -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;
Expand All @@ -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, &not_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, &not_exported);
}
}
lowered.push(before.remove(index));
}
lowered.append(imports);
*imports = lowered;
}

/// `ImportKind::Dynamic` on an `S::Import`'s record tells `ImportScanner` to lower it.
Expand All @@ -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 =
Expand All @@ -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,
},
Expand Down
2 changes: 1 addition & 1 deletion src/js_parser/lower/import_meta_glob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading