diff --git a/src/runtime/cli/test/Scanner.rs b/src/runtime/cli/test/Scanner.rs index 570bb95b40ce..565516467c75 100644 --- a/src/runtime/cli/test/Scanner.rs +++ b/src/runtime/cli/test/Scanner.rs @@ -161,34 +161,6 @@ 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 }); - } - } - } - while let Some(entry) = self.dirs_to_scan.pop_front() { let parts2: [&[u8]; 2] = [entry.dir_path, entry.name.slice()]; let Some(path2) = self.fs().abs_buf_checked(&parts2, &mut scan_dir_buf) else { @@ -222,18 +194,40 @@ impl<'a> Scanner<'a> { Ok(()) } + /// Passes every entry of `name` to `next`. /// `handle` stays owned by the caller; the resolver caches the listing but not the fd. fn read_dir_with_name( &mut self, name: &[u8], handle: Option, ) -> crate::Result<&'static mut EntriesOption> { + self.has_iterated = false; let fs_ptr = self.fs; let iter = ScannerDirIter(std::ptr::from_mut::>(self)); // SAFETY: borrows only the `fs` field; re-entrant access is serialised by `RealFS.entries_mutex`. - unsafe { &mut (*fs_ptr).fs } - .read_directory_with_iterator(name, handle, 0, false, iter) - .map_err(Into::into) + let listing = unsafe { &mut (*fs_ptr).fs } + .read_directory_with_iterator(name, handle, 0, false, iter)?; + + // On a cache hit the resolver returns the listing without calling the iterator. + if !self.has_iterated { + if let EntriesOption::Entries(entries) = &*listing { + // Sorted: hash-map order is not stable, and #26851 needs a deterministic order. + 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 }); + } + } + } + + Ok(listing) } pub(crate) fn could_be_test_file(&self, name: &[u8]) -> bool { diff --git a/test/cli/test/bun-test.test.ts b/test/cli/test/bun-test.test.ts index 49a4905e9b14..9ea4e59ce241 100644 --- a/test/cli/test/bun-test.test.ts +++ b/test/cli/test/bun-test.test.ts @@ -1945,6 +1945,82 @@ describe.concurrent("test file discovery (scanner)", () => { expect(exitCode).toBe(0); }); + // The resolver lists the cwd and its parents before the scanner runs, and it + // keeps the listing of every directory an earlier path argument walked. The + // scanner has to walk those listings too. + const ranTest = (name: string) => + `import { test } from "bun:test"; test("${name}", () => { console.log("RAN ${name}"); });`; + async function discover(cwd: string, args: readonly string[]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", ...args], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { + ran: stdout + .split(/\r?\n/) + .filter(line => line.startsWith("RAN ")) + .sort(), + summary: stderr.match(/^Ran \d+ tests? across \d+ files?\./m)?.[0] ?? stderr, + exitCode, + }; + } + + test.each(["./nested ./", "./ ./nested"])('path arguments "%s" run every test below both, once', async order => { + using dir = tempDir("scanner-two-paths", { + "root_only.test.ts": ranTest("root"), + "nested/inner.test.ts": ranTest("inner"), + "nested/deeper/most.test.ts": ranTest("most"), + "other/other.test.ts": ranTest("other"), + }); + + expect(await discover(String(dir), order.split(" "))).toEqual({ + ran: ["RAN inner", "RAN most", "RAN other", "RAN root"], + summary: "Ran 4 tests across 4 files.", + exitCode: 0, + }); + }); + + describe.each([ + { how: "a path argument", args: ["../../"], bunfig: {} }, + { how: "the bunfig test root", args: [], bunfig: { "pkg/app/bunfig.toml": `[test]\nroot = "../../"\n` } }, + ])("$how that is a parent of the cwd", ({ args, bunfig }) => { + test("runs the tests in every directory", async () => { + using dir = tempDir("scanner-parent-of-cwd", { + ...bunfig, + "top.test.ts": ranTest("top"), + "sibling/sibling.test.ts": ranTest("sibling"), + "pkg/pkg.test.ts": ranTest("pkg"), + "pkg/lib/lib.test.ts": ranTest("lib"), + "pkg/app/app.test.ts": ranTest("app"), + "pkg/app/deeper/deeper.test.ts": ranTest("deeper"), + }); + + expect(await discover(join(String(dir), "pkg", "app"), args)).toEqual({ + ran: ["RAN app", "RAN deeper", "RAN lib", "RAN pkg", "RAN sibling", "RAN top"], + summary: "Ran 6 tests across 6 files.", + exitCode: 0, + }); + }); + + // Without the walk this run finds no test file and exits 1. + test("finds a test that is only below the cwd", async () => { + using dir = tempDir("scanner-parent-of-cwd-only", { + ...bunfig, + "pkg/app/app.test.ts": ranTest("app"), + }); + + expect(await discover(join(String(dir), "pkg", "app"), args)).toEqual({ + ran: ["RAN app"], + summary: "Ran 1 test across 1 file.", + exitCode: 0, + }); + }); + }); + // The scanner builds every absolute path in a PathBuffer of MAX_PATH_BYTES: // 4096 on Linux, 1024 on every other POSIX (src/bun_core/util.rs). On Windows // it is 32767*3+1 bytes, more than a command line or an NT path can hold, so