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/guides/test/svelte-test.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,15 @@ Use `bun test` to run your tests.
```bash terminal icon="terminal"
bun test
```

---

Use `bun test --changed` to run only the test files that depend on the files you changed since the last commit.

```bash terminal icon="terminal"
bun test --changed
```

To find those test files, Bun reads the `import` statements in the `<script>` blocks of a component. If `Counter.svelte` imports `./Button.svelte`, a change to `Button.svelte` runs `hello-svelte.test.ts`. This works for any file that keeps its imports in `<script>` blocks.

Bun does not run your plugin to find the test files, so it does not see an import that only the plugin adds. After a change to `svelte-loader.ts` or `bunfig.toml`, run `bun test`.
104 changes: 104 additions & 0 deletions src/bundler/ParseTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1376,6 +1376,20 @@ pub mod parse_worker {
}
Loader::File | Loader::Wasm => {
debug_assert!(loader.should_copy_for_bundling());
// A plugin compiles a component in the test run. This scan runs no plugin, and reads what the scripts import.
if topts.component_scripts
&& topts.target.is_server_side()
&& crate::component_scripts::has_scripts(source.path.text, source.contents())
{
return get_component_ast(
log,
&mut topts.define,
&topts.loaders,
opts,
bump,
source,
);
Comment thread
robobun marked this conversation as resolved.
Resolved
}

// Put a unique key in the AST to implement the URL loader. At the end
// of the bundle, the key is replaced with the actual URL.
Expand Down Expand Up @@ -1447,6 +1461,96 @@ pub mod parse_worker {
}
}

/// The AST of a component in a scan: it has what the scripts import and nothing else. No macro runs, and no error is reported.
#[cold]
fn get_component_ast(
log: &mut Log,
define: &'static mut js_parser::Define,
loaders: &bun_ast::LoaderHashTable,
opts: ParserOptions<'static>,
bump: &'static Bump,
source: &'static Source,
) -> core::result::Result<JSAst<'static>, AnyError> {
let mut scan_pass = js_parser::ScanPassResult::init();
// A script that does not parse keeps the imports before the error: the test run reports the error.
let mut scan = |is_jsx: bool, script: &Source| -> (Vec<ImportRecord>, bool) {
let mut script_opts = opts.clone_for_lazy_export();
script_opts.ts = true;
script_opts.jsx.parse = is_jsx;
scan_pass.reset();
let mut errors = Log::init();
let scanned = (crate::cache::JavaScript {}).scan(
bump,
&mut scan_pass,
script_opts,
define,
&mut errors,
script,
);
let is_parsed = scanned.is_ok() && errors.errors == 0;
(core::mem::take(&mut scan_pass.import_records), is_parsed)
};
let mut import_records = Vec::new();
crate::component_scripts::for_each(source.path.text, source.contents(), |code| {
let script = Source {
path: source.path,
contents: std::borrow::Cow::Borrowed(code),
index: source.index,
..Default::default()
};
// The language of a script is not read: TypeScript has JavaScript, and TSX has JSX.
let (mut records, is_parsed) = scan(false, &script);
if !is_parsed {
records.append(&mut scan(true, &script).0);
}
for mut record in records {
// The markup uses the imports that the script does not.
record.flags.remove(bun_ast::ImportRecordFlags::IS_UNUSED);
// The scan has one row for each path: `with { type: "text" }` must not make a code file an asset for its other importers.
let names_no_imports = record
.path
.loader(loaders)
.is_some_and(|by_name| !by_name.is_javascript_like() && by_name != Loader::Css);
if !names_no_imports {
record.loader = None;
Comment thread
robobun marked this conversation as resolved.
Resolved
}
import_records.push(record);
}
});
// The scan pass has no named imports, so each import takes every export of a barrel file.
let mut symbols = bun_ast::symbol::List::with_capacity_in(import_records.len(), bump);
for _ in &import_records {
symbols.push(Default::default());
}
let mut ast = js_parser::new_lazy_export_ast_impl(
bump,
define,
opts,
log,
Expr::init(E::Missing {}, Loc::EMPTY),
source,
b"",
symbols,
)?
.ok_or(AnyError::ParserError)?;
for index in 0..import_records.len() as u32 {
bun_core::handle_oom(ast.named_imports.put(
ast::Ref::new(index, source.index.0, ast::RefTag::Symbol),
ast::NamedImport {
alias: Some(ast::StoreStr::new(b"")),
alias_loc: Loc::EMPTY,
namespace_ref: ast::Ref::NONE,
import_record_index: index,
local_parts_with_uses: bun_alloc::AstAlloc::vec(),
alias_is_star: true,
is_exported: false,
},
));
}
ast.import_records = bun_alloc::vec_from_iter_in(import_records, bump);
Ok(JSAst::init(ast))
}

// ───────────────────────────────────────────────────────────────────────────
// getCodeForParseTaskWithoutPlugins
// ───────────────────────────────────────────────────────────────────────────
Expand Down
74 changes: 74 additions & 0 deletions src/bundler/component_scripts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//! Finds the scripts of a component: a file of markup and `<script>` blocks that a plugin compiles, such as `.svelte` or `.vue`.

use bun_core::strings;

/// True if `contents` is text that can have a script.
pub(crate) fn has_scripts(path: &[u8], contents: &[u8]) -> bool {
let is_binary = strings::contains_char(&contents[..contents.len().min(8192)], 0);
!is_binary && (strings::contains(contents, b"<script") || frontmatter(path, contents).is_some())
}

/// Passes `each` the code of every script of a component.
pub(crate) fn for_each<'a>(path: &[u8], contents: &'a [u8], mut each: impl FnMut(&'a [u8])) {
Comment thread
robobun marked this conversation as resolved.
Resolved
if let Some(code) = frontmatter(path, contents) {
each(code);
}
// A script ends at the next `</script`, so each part between two of them is searched by itself.
let mut rest = contents;
while !rest.is_empty() {
let end = strings::index_of(rest, b"</script").unwrap_or(rest.len());
scripts_before_end_tag(&rest[..end], &mut each);
rest = &rest[(end + b"</script".len()).min(rest.len())..];
}
}

/// Each `<script` counts, also in a string, in a comment or in prose: a stray one can add an import.
fn scripts_before_end_tag<'a>(part: &'a [u8], each: &mut impl FnMut(&'a [u8])) {
// A script runs to the end of the part, and so does a stray `<script` before it: the part gives twice its text in full.
let mut in_full = part.len() * 2;
// An attribute value can have a `<script`. The open tag then ends after the next one, and the part gives its text once to find that end.
let mut past_next = part.len();
let mut next = strings::index_of(part, b"<script");
let mut rest = part;
while let Some(at) = next {
rest = &rest[at + b"<script".len()..];
next = strings::index_of(rest, b"<script");
Comment thread
robobun marked this conversation as resolved.
Resolved
let until_next = &rest[..next.unwrap_or(rest.len())];
if let Some(tag_end) = end_of_open_tag(until_next) {
let to_end = &rest[tag_end + 1..];
// After that, a script ends at the next `<script`, so the parser gets each byte four times at most for each language.
if to_end.len() <= in_full {
in_full -= to_end.len();
each(to_end);
} else {
each(&until_next[tag_end + 1..]);
}
} else if next.is_some() && rest.len() <= past_next {
past_next -= rest.len();
if let Some(tag_end) = end_of_open_tag(rest) {
each(&rest[tag_end + 1..]);
}
}
}
}

/// The index of the `>` that ends an open tag. An attribute value can have a `>` between quotes.
fn end_of_open_tag(tag: &[u8]) -> Option<usize> {
let mut at = 0;
loop {
at += strings::index_of_any(&tag[at..], b">\"'")?;
if tag[at] == b'>' {
return Some(at);
}
at += 2 + strings::index_of_char_usize(&tag[at + 1..], tag[at])?;
}
}

/// The code between the `---` lines at the top of an Astro component.
fn frontmatter<'a>(path: &[u8], contents: &'a [u8]) -> Option<&'a [u8]> {
if !path.ends_with(b".astro") {
return None;
}
let code = contents.trim_ascii_start().strip_prefix(b"---")?;
Some(&code[..strings::index_of(code, b"\n---")?])
}
1 change: 1 addition & 0 deletions src/bundler/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ pub use bundled_ast::BundledAst;
pub mod barrel_imports;
#[path = "Chunk.rs"]
pub mod chunk;
pub(crate) mod component_scripts;
pub mod defines;
pub mod linker;
#[path = "LinkerGraph.rs"]
Expand Down
5 changes: 5 additions & 0 deletions src/bundler/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1395,6 +1395,9 @@ pub struct BundleOptions<'a> {

pub ignore_module_resolution_errors: bool,

/// `bun test --changed`: read what the `<script>` blocks of a file with the file loader import. A plugin compiles such a file in the test run.
pub component_scripts: bool,

/// Package names whose barrel files should be optimized.
/// When set, barrel files from these packages will only load submodules
/// that are actually imported. Also, any file with sideEffects: false
Expand Down Expand Up @@ -1574,6 +1577,7 @@ impl<'a> BundleOptions<'a> {
supports_multiple_outputs: self.supports_multiple_outputs,
force_node_env: self.force_node_env,
ignore_module_resolution_errors: self.ignore_module_resolution_errors,
component_scripts: self.component_scripts,
optimize_imports: self.optimize_imports,
}
}
Expand Down Expand Up @@ -1826,6 +1830,7 @@ impl<'a> BundleOptions<'a> {
supports_multiple_outputs: true,
force_node_env: ForceNodeEnv::Unspecified,
ignore_module_resolution_errors: false,
component_scripts: false,
optimize_imports: None,
};

Expand Down
20 changes: 11 additions & 9 deletions src/runtime/cli/test/ChangedFilesFilter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ pub(crate) fn filter<'a>(
// The module graph scan is best-effort. A test file that imports
// something unresolved should still be considered, not abort --changed.
scan_transpiler.options.ignore_module_resolution_errors = true;
// The scan runs no plugin, and a plugin of a preload file is what compiles a component.
scan_transpiler.options.component_scripts = true;
Comment thread
robobun marked this conversation as resolved.
Resolved
scan_transpiler.options.output_dir = Box::default();
scan_transpiler.options.tree_shaking = false;
scan_transpiler.configure_linker();
Expand Down Expand Up @@ -192,8 +194,7 @@ pub(crate) fn filter<'a>(
let sources = bundle.graph.input_files.items_source();
let import_records = bundle.graph.ast.items_import_records();

// Map absolute source path -> source index for paths that participate in
// the graph. This lets us look up changed-file paths quickly.
// Map absolute source path -> the first source index of that path: a test file is its own first copy.
let mut path_to_index: StringHashMap<u32> = StringHashMap::new();
path_to_index.reserve(sources.len());

Expand Down Expand Up @@ -222,7 +223,9 @@ pub(crate) fn filter<'a>(
}
// All scanned entry points are absolute, and the resolver emits
// absolute file paths as well.
path_to_index.put_assume_capacity(path_text, u32::try_from(idx).unwrap());
bun_core::handle_oom(
path_to_index.get_or_put_value(path_text, u32::try_from(idx).unwrap()),
);
// Copy out of the bundler's arena so the caller can use these paths
// after the BundleV2 heap is gone.
graph_files.push(Box::<[u8]>::from(path_text));
Expand Down Expand Up @@ -256,12 +259,11 @@ pub(crate) fn filter<'a>(
let mut queue: Vec<u32> = Vec::new();

{
for changed_path in changed_files.keys() {
if let Some(&idx) = path_to_index.get(changed_path.as_ref()) {
if !affected.is_set(idx as usize) {
affected.set(idx as usize);
queue.push(idx);
}
// The graph holds a file once per target (a test and the script of a page can both import it), so every copy is a start.
for (idx, source) in sources.iter().enumerate() {
if source.path.is_file() && changed_files.contains(source.path.text) {
affected.set(idx);
queue.push(u32::try_from(idx).unwrap());
}
}
}
Expand Down
Loading
Loading