From 226a315d53aa4a5fb6d01cabbee07d45d6dbd2e4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:51:16 +0000 Subject: [PATCH 1/2] bun test: list each directory once during discovery --- docs/test/discovery.mdx | 15 ++ src/resolver/fs.rs | 44 +++++ src/resolver/lib.rs | 7 +- src/runtime/cli/test/Scanner.rs | 317 ++++++++++++++++++++++++++------ test/cli/test/bun-test.test.ts | 229 ++++++++++++++++++++++- 5 files changed, 556 insertions(+), 56 deletions(-) diff --git a/docs/test/discovery.mdx b/docs/test/discovery.mdx index f135e1cf88e7..f6e886133c17 100644 --- a/docs/test/discovery.mdx +++ b/docs/test/discovery.mdx @@ -22,6 +22,21 @@ By default, `bun test` ignores: - Hidden directories (those starting with a period `.`) - Files that don't have JavaScript-like extensions (based on available [loaders](/bundler/loaders)) +## Symbolic Links + +`bun test` follows symbolic links to directories, and it scans each directory once. + +- `bun test` does not follow a link to a directory that the scan already covers. A link to a parent directory is one example. +- `bun test` scans a directory that only a link leads to under the real path of that directory. + +Filters and [`pathIgnorePatterns`](/test/configuration#path-ignore-patterns) match the path that the scan recorded. For example, if `latest` is a link to `packages/v2`, then `bun test packages/v2` runs the tests in that directory, and `bun test latest` matches nothing. + +To run the tests behind a link by the name of the link, pass the link as a path: + +```bash terminal icon="terminal" +bun test ./latest +``` + ## Customizing Test Discovery ### Position Arguments as Filters diff --git a/src/resolver/fs.rs b/src/resolver/fs.rs index b7cb32e3608d..b234b543f887 100644 --- a/src/resolver/fs.rs +++ b/src/resolver/fs.rs @@ -114,6 +114,30 @@ pub struct EntryCache { /// don't make it bun.invalid_fd pub fd: Fd, pub(crate) kind: EntryKind, + /// The entry is itself a link (a POSIX symlink, a Windows reparse point). + /// `symlink` alone does not say so: the resolver also fills it for real directories. + pub(crate) is_link: bool, +} + +// `is_link` sits in what was padding after `kind`. +const _: () = { + #[allow(dead_code)] + struct EntryCacheWithoutLinkBit { + symlink: Interned, + fd: Fd, + kind: EntryKind, + } + assert!(core::mem::size_of::() == core::mem::size_of::()); +}; + +/// What a directory walker needs to know about an entry, from one read of its stat cache. +#[derive(Clone, Copy)] +pub struct EntryLink { + pub kind: EntryKind, + pub is_link: bool, + /// Where the link leads, with every link on the way resolved. Empty when + /// the entry is not a link or its target could not be named. + pub real_path: &'static [u8], } // `cache` / `need_stat` are lazily populated by `Entry::kind` / @@ -234,6 +258,25 @@ impl Entry { self.cache().kind } + /// [`Entry::kind`], plus whether the entry is a link and where it leads. + /// + /// # Safety + /// Same contract as [`Entry::kind`]. + pub unsafe fn link(&self, fs: *mut R, store_fd: bool) -> EntryLink { + // SAFETY: forwarded; `kind` runs the stat-on-first-use protocol. + let _ = unsafe { self.kind(fs, store_fd) }; + let cache = self.cache(); + EntryLink { + kind: cache.kind, + is_link: cache.is_link, + real_path: if cache.is_link { + cache.symlink.as_bytes() + } else { + b"" + }, + } + } + /// /// # Safety /// `fs` must point to a live `EntryKindResolver` (the process-global @@ -537,6 +580,7 @@ impl DirEntry { // store an arbitrary kind kind: found_kind.unwrap_or(EntryKind::File), fd: Fd::INVALID, + is_link: false, })); addr_of_mut!((*p).abs_path).write(Interned::EMPTY); p diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 25986a502f8d..e485c50519ee 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -710,8 +710,8 @@ pub mod fs { // Canonical definitions live in `fs.rs` (mounted as `crate::fs_full`). // Re-exported here so the public path `bun_resolver::fs::*` is preserved. pub use crate::fs_full::{ - DirEntry, DirEntryIterator, Entry, EntryCache, EntryKind, EntryKindResolver, EntryLookup, - FilenameStoreAppender, dir_entry, + DirEntry, DirEntryIterator, Entry, EntryCache, EntryKind, EntryKindResolver, EntryLink, + EntryLookup, FilenameStoreAppender, dir_entry, }; use bun_core::Generation; @@ -1377,6 +1377,7 @@ pub mod fs { kind: EntryKind::File, symlink: Interned::EMPTY, fd: Fd::INVALID, + is_link: false, }; let combo: [&[u8]; 2] = [dir_, base]; @@ -1414,6 +1415,7 @@ pub mod fs { if !file.is_reparse_point { return Ok(cache); } + cache.is_link = true; // For the realpath, open the path and let the kernel follow // every hop, then `GetFinalPathNameByHandle` (same as libuv's @@ -1538,6 +1540,7 @@ pub mod fs { } else { EntryKind::File }; + cache.is_link = is_symlink; if !symlink.is_empty() { cache.symlink = Interned::from_static(FilenameStore::instance().append_slice(symlink)?); diff --git a/src/runtime/cli/test/Scanner.rs b/src/runtime/cli/test/Scanner.rs index d6c058fff954..10bf59a9fed7 100644 --- a/src/runtime/cli/test/Scanner.rs +++ b/src/runtime/cli/test/Scanner.rs @@ -34,8 +34,18 @@ pub(crate) struct Scanner<'a> { pub(crate) search_count: usize, /// The directory being iterated; its fd closes once every child `ScanEntry` has been opened. current_dir: Option>, + /// The scan root as the resolver names it. + root_dir: &'static [u8], + /// Real path of `root_dir`, resolved when the first directory link is met. Empty: unknown. + root_real: Option<&'static [u8]>, + /// Real paths of the directories the walk entered through a link. + followed: FollowedDirs, + /// The directory being iterated is in `followed`. + current_followed: bool, } +type FollowedDirs = bun_collections::hashbrown::HashSet<&'static [u8], bun_wyhash::BuildHasher>; + // FIFO queue of scan entries (pop_front / push_back). pub(crate) type Fifo = VecDeque; @@ -48,6 +58,8 @@ pub(crate) struct ScanEntry { pub name: StringOrTinyString, } +const _: () = assert!(core::mem::size_of::() == 56); + #[derive(thiserror::Error, Debug)] pub(crate) enum ScanError { /// The entrypoint does not exist or does not fit a `PathBuffer`; never returned for subdirectories. @@ -89,6 +101,10 @@ impl<'a> Scanner<'a> { has_iterated: false, search_count: 0, current_dir: None, + root_dir: b"", + root_real: None, + followed: FollowedDirs::default(), + current_followed: false, }) } @@ -133,6 +149,8 @@ impl<'a> Scanner<'a> { return Err(ScanError::DoesNotExist); }; + self.root_dir = b""; + self.root_real = None; let root = self .read_dir_with_name(path, None) .map_err(|_| ScanError::OutOfMemory)?; @@ -161,31 +179,11 @@ impl<'a> Scanner<'a> { } } - // you typed "." and we already scanned it - if !self.has_iterated { - if let EntriesOption::Entries(entries) = root { - // Collect first so `self.next(…)` doesn't overlap the - // `entries.data` borrow. - // this branch is taken when the resolver already has - // `path` cached (e.g. `run_env_loader`/`read_dir_info` read the - // cwd before the scanner runs), so `read_directory_with_iterator` - // returned the cached `EntryMap` without invoking `iterator.next`. - // Hash-map iteration order is not stable. Sort by (lowercased) - // base name so test-file discovery order is deterministic — - // regression/issue/26851 relies on `a_*.test` running before - // `b_*.test` under `--bail`. - let mut entry_ptrs: Vec<*mut fs::Entry> = entries.data.values().copied().collect(); - index_sort::sort_slice_by(&mut entry_ptrs, |a, b| { - // SAFETY: `EntryMap` stores `*mut Entry` into the - // process-static `EntryStore`; valid for `'static`. - let (an, bn) = unsafe { ((**a).base_lowercase(), (**b).base_lowercase()) }; - an.cmp(bn) - }); - for entry_ptr in entry_ptrs { - // SAFETY: `EntryMap` stores `*mut Entry` into the - // process-static `EntryStore`; valid for `'static`. - self.next(unsafe { &mut *entry_ptr }); - } + if let EntriesOption::Entries(entries) = root { + self.root_dir = entries.dir; + // you typed "." and we already scanned it + if !self.has_iterated { + self.replay_cached(entries); } } @@ -194,6 +192,7 @@ impl<'a> Scanner<'a> { let Some(path2) = self.fs().abs_buf_checked(&parts2, &mut scan_dir_buf) else { continue; }; + let followed = !self.followed.is_empty() && self.followed.contains(path2); let (parent, rel_path): (Fd, &[u8]) = match &entry.relative_dir { Some(parent) => (parent.fd, entry.name.slice()), None => (Fd::cwd(), path2), @@ -214,7 +213,25 @@ impl<'a> Scanner<'a> { .append_slice(path2) .map_err(|_| ScanError::OutOfMemory)?; self.current_dir = Some(Rc::clone(&child_dir)); + if !followed { + let result = self.read_dir_with_name(path2, Some(child_dir.fd)); + self.current_dir = None; + result.map_err(|_| ScanError::OutOfMemory)?; + continue; + } + // A directory outside the walk's own tree can be one the resolver + // listed before discovery (a parent of the cwd). The read then + // returns that listing and calls nothing. + self.current_followed = true; + let had_iterated = core::mem::replace(&mut self.has_iterated, false); let result = self.read_dir_with_name(path2, Some(child_dir.fd)); + if !self.has_iterated { + if let Ok(EntriesOption::Entries(entries)) = &result { + self.replay_cached(entries); + } + } + self.has_iterated = had_iterated; + self.current_followed = false; self.current_dir = None; result.map_err(|_| ScanError::OutOfMemory)?; } @@ -222,6 +239,28 @@ impl<'a> Scanner<'a> { Ok(()) } + /// Runs `next` over a listing the resolver returned from its cache without + /// calling the iterator (`run_env_loader`/`read_dir_info` read the cwd + /// before the scanner runs). + fn replay_cached(&mut self, entries: &fs::DirEntry) { + // Hash-map iteration order is not stable. Sort by (lowercased) + // base name so test-file discovery order is deterministic — + // regression/issue/26851 relies on `a_*.test` running before + // `b_*.test` under `--bail`. + let mut entry_ptrs: Vec<*mut fs::Entry> = entries.data.values().copied().collect(); + index_sort::sort_slice_by(&mut entry_ptrs, |a, b| { + // SAFETY: `EntryMap` stores `*mut Entry` into the + // process-static `EntryStore`; valid for `'static`. + let (an, bn) = unsafe { ((**a).base_lowercase(), (**b).base_lowercase()) }; + an.cmp(bn) + }); + for entry_ptr in entry_ptrs { + // SAFETY: `EntryMap` stores `*mut Entry` into the + // process-static `EntryStore`; valid for `'static`. + self.next(unsafe { &mut *entry_ptr }); + } + } + /// `handle` stays owned by the caller; the resolver caches the listing but not the fd. fn read_dir_with_name( &mut self, @@ -328,44 +367,191 @@ impl<'a> Scanner<'a> { && !self.matches_path_ignore_pattern(name) } + /// A directory the walk does not enter: a dot-directory, `node_modules`, + /// an excluded name, or a path that an ignore pattern matches. `path` + /// joins to the directory's path and `name_lowercase` is its last part. + #[inline] + fn prunes_dir(&mut self, path: &[&[u8]], name_lowercase: &[u8]) -> bool { + if (!name_lowercase.is_empty() && name_lowercase[0] == b'.') + || name_lowercase == b"node_modules" + { + return true; + } + + debug_assert!(strings::index_of(name_lowercase, bun_paths::NODE_MODULES_NEEDLE).is_none()); + + for exclude_name in self.exclusion_names { + if strings::eql(exclude_name, name_lowercase) { + return true; + } + } + + // Prune ignored directory trees early so we never traverse them. + if !self.path_ignore_patterns.is_empty() { + // reshaped for borrowck — drop the &mut borrow from + // abs_buf and reborrow open_dir_buf immutably so &self methods + // can be called with the slice. + let Some(dir_path_len) = + Self::abs_buf_projected(self.top_level_dir(), path, &mut self.open_dir_buf) + .map(<[u8]>::len) + else { + return true; + }; + let dir_path = &self.open_dir_buf[..dir_path_len]; + if self.matches_path_ignore_pattern(dir_path) { + return true; + } + } + + false + } + + /// Decides a directory entry that is a link, or that sits in a directory + /// the walk entered through a link, so that each directory is listed once. + /// A link to a directory the walk reaches by itself is not followed. Any + /// other directory is entered the first time only, under its real path. + /// Returns `true` when the caller queues the entry as a plain child. + #[cold] + #[inline(never)] + fn enters_linked_dir(&mut self, entry: &fs::Entry, link: fs::EntryLink) -> bool { + if self.root_dir.is_empty() { + // The root is being read: its entries arrive before any other. + self.root_dir = entry.dir; + } + + let mut lexical_buf = bun_paths::path_buffer_pool::get(); + let parts: [&[u8]; 2] = [entry.dir, entry.base()]; + let Some(lexical) = Self::abs_buf_projected(self.top_level_dir(), &parts, &mut lexical_buf) + else { + return true; + }; + + let (real, plain): (&[u8], bool) = if link.is_link { + let real = if link.real_path.is_empty() { + self.real_path_of(lexical) + } else { + self.spell_real(link.real_path) + }; + let Some(real) = real else { + return false; + }; + // A link that names its own place is a plain child, not an alias. + let same_place = if self.current_followed { + real == lexical + } else { + let root_real = self.root_real(); + !root_real.is_empty() + && path_below(lexical, self.root_dir) + .is_some_and(|below| path_below(real, root_real) == Some(below)) + }; + if same_place && !self.current_followed { + return true; + } + (real, same_place) + } else { + // A child of a followed directory: the walk names it by its real path. + (lexical, true) + }; + + if self.walk_owns(real) || self.followed.contains(real) { + return false; + } + let Ok(real) = self.filename_store().append_slice(real) else { + bun_core::out_of_memory(); + }; + self.followed.insert(real); + if plain { + return true; + } + + self.search_count += 1; + let name = bun_paths::basename(real); + self.dirs_to_scan.push_back(ScanEntry { + relative_dir: None, + dir_path: &real[..real.len() - name.len()], + name: StringOrTinyString::init(name), + }); + false + } + + /// The walk lists `real` by itself: it is the scan root, or it sits below + /// the root and no directory on the way is pruned. + fn walk_owns(&mut self, real: &[u8]) -> bool { + let root_real = self.root_real(); + if root_real.is_empty() { + return false; + } + let Some(below) = path_below(real, root_real) else { + return false; + }; + + let root_dir = self.root_dir; + let mut lowercase_buf = [0u8; 256]; + let mut start = 0; + while start < below.len() { + let end = strings::index_of_any(&below[start..], SEPARATORS) + .map_or(below.len(), |at| start + at); + let name = &below[start..end]; + if name.len() > lowercase_buf.len() { + return false; + } + let name_lowercase = strings::copy_lowercase_if_needed(name, &mut lowercase_buf); + if self.prunes_dir(&[root_dir, &below[..end]], name_lowercase) { + return false; + } + start = end + 1; + } + true + } + + fn root_real(&mut self) -> &'static [u8] { + if let Some(real) = self.root_real { + return real; + } + let real = self.real_path_of(self.root_dir).unwrap_or(b""); + self.root_real = Some(real); + real + } + + /// Where `path` leads, from the same call `RealFS::kind` names a link target with. + fn real_path_of(&self, path: &[u8]) -> Option<&'static [u8]> { + #[cfg(not(windows))] + let fd = bun_sys::open_dir_at(Fd::cwd(), path).ok()?; + #[cfg(windows)] + let fd = bun_sys::open_dir_no_renaming_or_deleting_windows(Fd::cwd(), path).ok()?; + let dir = Dir::from_fd(fd); + let mut buf = bun_paths::path_buffer_pool::get(); + let real = bun_sys::get_fd_path(dir.fd, &mut buf).ok()?; + let mut spelled = bun_paths::path_buffer_pool::get(); + let real = Self::abs_buf_projected(self.top_level_dir(), &[&*real], &mut spelled)?; + self.filename_store().append_slice(real).ok() + } + + /// `real` as the walk spells paths. + fn spell_real(&self, real: &'static [u8]) -> Option<&'static [u8]> { + let mut spelled = bun_paths::path_buffer_pool::get(); + let spelled = Self::abs_buf_projected(self.top_level_dir(), &[real], &mut spelled)?; + if spelled == real { + return Some(real); + } + self.filename_store().append_slice(spelled).ok() + } + pub(crate) fn next(&mut self, entry: &mut fs::Entry) { let name = entry.base_lowercase(); self.has_iterated = true; // SAFETY: `self.fs` is the process singleton. let real_fs = unsafe { &raw mut (*self.fs).fs }; // SAFETY: caller holds `entries_mutex`; the direct path is single-threaded. - match unsafe { entry.kind(real_fs, false) } { + let link = unsafe { entry.link(real_fs, false) }; + match link.kind { fs::EntryKind::Dir => { - if (!name.is_empty() && name[0] == b'.') || name == b"node_modules" { + if self.prunes_dir(&[entry.dir, entry.base()], name) { return; } - debug_assert!(strings::index_of(name, bun_paths::NODE_MODULES_NEEDLE).is_none()); - - for exclude_name in self.exclusion_names { - if strings::eql(exclude_name, name) { - return; - } - } - - // Prune ignored directory trees early so we never traverse them. - if !self.path_ignore_patterns.is_empty() { - let parts: [&[u8]; 2] = [entry.dir, entry.base()]; - // reshaped for borrowck — drop the &mut borrow from - // abs_buf and reborrow open_dir_buf immutably so &self methods - // can be called with the slice. - let Some(dir_path_len) = Self::abs_buf_projected( - self.top_level_dir(), - &parts, - &mut self.open_dir_buf, - ) - .map(<[u8]>::len) else { - return; - }; - let dir_path = &self.open_dir_buf[..dir_path_len]; - if self.matches_path_ignore_pattern(dir_path) { - return; - } + if (link.is_link || self.current_followed) && !self.enters_linked_dir(entry, link) { + return; } self.search_count += 1; @@ -423,4 +609,29 @@ impl<'a> Scanner<'a> { } } +const SEPARATORS: &[u8] = if cfg!(windows) { b"/\\" } else { b"/" }; + +/// `path` without its trailing separators. +fn trim_sep(path: &[u8]) -> &[u8] { + let mut end = path.len(); + while end > 1 && bun_paths::is_sep_native(path[end - 1]) { + end -= 1; + } + &path[..end] +} + +/// The part of `path` below `root`, without a leading separator. Empty when +/// both name the same place. +fn path_below<'p>(path: &'p [u8], root: &[u8]) -> Option<&'p [u8]> { + let root = trim_sep(root); + let rest = path.strip_prefix(root)?; + match rest { + [] => Some(rest), + [first, below @ ..] if bun_paths::is_sep_native(*first) => Some(below), + // A filesystem root keeps its separator. + _ if root.last().is_some_and(|c| bun_paths::is_sep_native(*c)) => Some(rest), + _ => None, + } +} + pub(crate) const TEST_NAME_SUFFIXES: [&[u8]; 4] = [b".test", b"_test", b".spec", b"_spec"]; diff --git a/test/cli/test/bun-test.test.ts b/test/cli/test/bun-test.test.ts index 94cfd0e0f584..0a2bc2abcdea 100644 --- a/test/cli/test/bun-test.test.ts +++ b/test/cli/test/bun-test.test.ts @@ -1,7 +1,7 @@ import { spawnSync } from "bun"; import { beforeAll, describe, expect, it, test } from "bun:test"; import { bunEnv, bunExe, isLinux, isWindows, tempDir, tempDirWithFiles, tmpdirSync } from "harness"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve, sep } from "node:path"; describe("bun test", () => { @@ -2119,4 +2119,231 @@ describe.concurrent("test file discovery (scanner)", () => { expect(stderr).toContain(" 1 pass"); expect(exitCode).toBe(0); }); + + // Directory links. The scan lists each directory once. It does not follow a + // link to a directory it covers by itself. It lists any other linked + // directory once, under that directory's real path. + const named = (name: string) => `import { test } from "bun:test"; test("${name}", () => {});`; + + // `links` maps the path of a link to its target, relative to the link's directory. + function linkedTree(files: Record, links: Record) { + const dir = tempDir("scanner-links", files); + for (const [at, target] of Object.entries(links)) { + const link = join(String(dir), at); + mkdirSync(dirname(link), { recursive: true }); + // A junction needs no privilege on Windows. Its target must be absolute. + if (isWindows) symlinkSync(resolve(dirname(link), target), link, "junction"); + else symlinkSync(target, link); + } + return dir; + } + + async function testLinkedTree(cwd: string, args: string[], endless = false) { + const cmd = [bunExe(), "test", ...args]; + await using proc = Bun.spawn({ + // `endless`: a scan that lists a directory once per path to it stops only + // when no file descriptor is left. The limit makes that a fast failure. + cmd: endless && !isWindows ? ["/bin/sh", "-c", 'ulimit -n 64 && exec "$@"', "sh", ...cmd] : cmd, + env: bunEnv, + cwd, + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const ran = stderr.match(/Ran \d+ tests? across \d+ files?\./)?.[0]; + return { stderr, ran, signalCode: proc.signalCode, exitCode }; + } + + // Six directories below a dot-directory. The root holds two links to the + // first, and each directory holds two links to the next. + const stackedFiles: Record = { "t0.test.js": named("t0") }; + const stackedLinks: Record = { a: ".stack/L1", b: ".stack/L1" }; + for (let level = 1; level <= 6; level++) { + stackedFiles[`.stack/L${level}/t${level}.test.js`] = named(`t${level}`); + if (level < 6) { + stackedLinks[`.stack/L${level}/a`] = `../L${level + 1}`; + stackedLinks[`.stack/L${level}/b`] = `../L${level + 1}`; + } + } + + const v2 = { files: { "packages/v2/v2.test.js": named("v2") }, links: { latest: "packages/v2" } }; + const ranOne = "Ran 1 test across 1 file."; + const ranTwo = "Ran 2 tests across 2 files."; + + const linkRows: { + name: string; + files: Record; + links: Record; + cwd?: string; + args?: string[]; + ran: string; + endless?: boolean; + }[] = [ + // Links to a directory the scan covers by itself. + { + name: "two links to the root", + files: { "s.test.js": named("s") }, + links: { x: ".", y: "." }, + ran: ranOne, + endless: true, + }, + { + name: "two links to the root, each two directories down", + files: { "src/s.test.js": named("s") }, + links: { "examples/one/lib": "../..", "examples/two/lib": "../.." }, + ran: ranOne, + endless: true, + }, + { + name: "a link to the root and a link to the directory that holds it", + files: { "src/s.test.js": named("s") }, + links: { "a/loop": "..", alias: "a" }, + ran: ranOne, + endless: true, + }, + { name: "one link to the root", files: { "src/s.test.js": named("s") }, links: { "a/loop": ".." }, ran: ranOne }, + { + name: "a link to the root and a link to its own directory", + files: { "sub/a.test.js": named("a"), "sub/deeper/b.test.js": named("b") }, + links: { "sub/loop": "..", "sub/deeper/self": "." }, + ran: ranTwo, + }, + { + name: "two packages that link to each other", + files: { "packages/a/a.test.js": named("a"), "packages/b/b.test.js": named("b") }, + links: { "packages/a/vendor/b": "../../b", "packages/b/vendor/a": "../../a" }, + ran: ranTwo, + }, + { name: "a link beside its target", ...v2, ran: ranOne }, + { name: "a link beside its target, filter on the real path", ...v2, args: ["packages/v2/"], ran: ranOne }, + { name: "a link beside its target, the link as the root", ...v2, args: ["./latest"], ran: ranOne }, + { + name: "a link beside its target, ignore pattern on the link path", + ...v2, + args: ["--path-ignore-patterns", "latest/*.test.js"], + ran: ranOne, + }, + { + name: "--isolate", + files: { "s.test.js": named("s"), "a/b.test.js": named("b") }, + links: { "a/loop": ".." }, + args: ["--isolate"], + ran: ranTwo, + }, + { + name: "--parallel", + files: { "s.test.js": named("s"), "a/b.test.js": named("b") }, + links: { "a/loop": ".." }, + args: ["--parallel=2"], + ran: ranTwo, + }, + // Links that are the only way to a directory. + { + name: "a link to a directory outside the root", + files: { "outside/o.test.js": named("o") }, + links: { "proj/shared": "../outside" }, + cwd: "proj", + ran: ranOne, + }, + { + name: "a link into a dot-directory", + files: { ".store/pkg/p.test.js": named("p") }, + links: { pkg: ".store/pkg" }, + ran: ranOne, + }, + { + name: "a link inside a linked directory", + files: { "outside/o.test.js": named("o"), "further/f.test.js": named("f") }, + links: { "proj/shared": "../outside", "outside/more": "../further" }, + cwd: "proj", + ran: ranTwo, + }, + { + name: "a link to the parent of the root", + files: { "base/proj/s.test.js": named("s"), "base/sibling/b.test.js": named("b") }, + links: { "base/proj/up": ".." }, + cwd: "base/proj", + ran: ranTwo, + }, + { + name: "the root is a link, with a link to its parent inside", + files: { "packages/v2/v2.test.js": named("v2"), "packages/other/o.test.js": named("o") }, + links: { latest: "packages/v2", "packages/v2/sub/up": "../.." }, + args: ["./latest"], + ran: ranTwo, + }, + { + name: "the root comes from bunfig.toml", + files: { "bunfig.toml": `[test]\nroot = "tests"\n`, "tests/a.test.js": named("a"), "lib/l.test.js": named("l") }, + links: { "tests/up": "..", "tests/lib": "../lib" }, + ran: ranTwo, + }, + { + name: "two roots with a link to the same directory", + files: { "outside/o.test.js": named("o") }, + links: { "a/shared": "../outside", "b/shared": "../outside" }, + args: ["./a", "./b"], + ran: ranOne, + }, + { + name: "two links to each of six directories in a row", + files: stackedFiles, + links: stackedLinks, + ran: "Ran 7 tests across 7 files.", + }, + ]; + + for (const { name, files, links, cwd = ".", args = [], ran, endless } of linkRows) { + test(`directory links: ${name}`, async () => { + using dir = linkedTree(files, links); + const result = await testLinkedTree(join(String(dir), cwd), args, endless); + expect({ ran: result.ran, signalCode: result.signalCode, exitCode: result.exitCode }).toEqual({ + ran, + signalCode: null, + exitCode: 0, + }); + }); + } + + test("directory links: a filter that names only the link path matches nothing", async () => { + using dir = linkedTree(v2.files, v2.links); + const { stderr, signalCode, exitCode } = await testLinkedTree(String(dir), ["latest/"]); + expect(stderr).toContain("did not match any test files"); + expect({ signalCode, exitCode }).toEqual({ signalCode: null, exitCode: 1 }); + }); + + for (const roots of [ + ["./latest", "./packages/v2"], + ["./packages/v2", "./latest"], + ]) { + test(`directory links: a link and its target as two roots (${roots.join(" ")})`, async () => { + using dir = linkedTree(v2.files, v2.links); + const { stderr, signalCode, exitCode } = await testLinkedTree(String(dir), roots); + expect(stderr).toContain(" 1 pass"); + expect({ signalCode, exitCode }).toEqual({ signalCode: null, exitCode: 0 }); + }); + } + + // The order in which a directory lists a link and its target depends on the + // filesystem and on which of the two was created first. + for (const linksFirst of [false, true]) { + test(`directory links: --shard halves run each file once (links created ${linksFirst ? "first" : "last"})`, async () => { + const files = { "b_real/t.test.js": named("t"), "d_real/u.test.js": named("u") }; + const links = { a_link: "b_real", c_link: "d_real" }; + using dir = linksFirst ? linkedTree({}, links) : linkedTree(files, links); + if (linksFirst) { + for (const [path, contents] of Object.entries(files)) { + mkdirSync(join(String(dir), dirname(path))); + writeFileSync(join(String(dir), path), contents); + } + } + const halves = await Promise.all(["1/2", "2/2"].map(shard => testLinkedTree(String(dir), [`--shard=${shard}`]))); + const passed = halves.flatMap(({ stderr }) => [...stderr.matchAll(/^\(pass\) (\S+)/gm)].map(match => match[1])); + expect(passed.sort()).toEqual(["t", "u"]); + expect(halves.map(({ signalCode, exitCode }) => ({ signalCode, exitCode }))).toEqual([ + { signalCode: null, exitCode: 0 }, + { signalCode: null, exitCode: 0 }, + ]); + }); + } }); From 46db8f9420eec9e858899275a6421b4c016a39d6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:53:10 +0000 Subject: [PATCH 2/2] resolver: assert the EntryCache size without a dead struct --- src/resolver/fs.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/resolver/fs.rs b/src/resolver/fs.rs index b234b543f887..4ee8c94794a6 100644 --- a/src/resolver/fs.rs +++ b/src/resolver/fs.rs @@ -120,15 +120,11 @@ pub struct EntryCache { } // `is_link` sits in what was padding after `kind`. -const _: () = { - #[allow(dead_code)] - struct EntryCacheWithoutLinkBit { - symlink: Interned, - fd: Fd, - kind: EntryKind, - } - assert!(core::mem::size_of::() == core::mem::size_of::()); -}; +const _: () = assert!( + size_of::() + == (size_of::() + size_of::() + size_of::()) + .next_multiple_of(align_of::()) +); /// What a directory walker needs to know about an entry, from one read of its stat cache. #[derive(Clone, Copy)]