diff --git a/.github/justfile b/.github/justfile index 9601229430d..c7c07caac1c 100644 --- a/.github/justfile +++ b/.github/justfile @@ -27,7 +27,7 @@ check: actionlint zizmor --offline --quiet .github sh/gh/alert.sh check-coverage - bun test sh/gh/alert.test.ts + bun test ./sh/gh/alert.test.ts bun sh/gh/check-runs.ts python3 sh/gh/test-inventory.py python3 bench/relay.test.py @@ -52,12 +52,12 @@ test: python3 sh/dispatch-query.test.py python3 sh/dispatch-query.test.py --bench python3 sh/gh/test-inventory.test.py - bun test sh/gh/setup.test.ts + bun test ./sh/gh/setup.test.ts bun sh/gh/queue.test.ts python3 sh/gh/install-formula.test.py sh/gh/release.test.sh python3 sh/gh/ffi.test.py - bun test sh/gh/ffi.test.ts sh/gh/cpp.test.ts sh/gh/publish.test.ts + bun test ./sh/gh/ffi.test.ts ./sh/gh/cpp.test.ts ./sh/gh/publish.test.ts python3 sh/rs/package-rename.test.py python3 sh/dart/build.test.py python3 sh/kt/gradle.test.py diff --git a/quest/m0/ci/README.md b/quest/m0/ci/README.md index e0e2926fe64..eac02a3658e 100644 --- a/quest/m0/ci/README.md +++ b/quest/m0/ci/README.md @@ -165,4 +165,3 @@ The historical samples locate candidates; they are not controlled benchmarks. - [The workspace-hack refuses trybuild edges and spares non-media builds](/quest/m0/ci/hakari-followups.md) - `hakari.py` refuses trybuild dev-dependency edges, and non-media Linux test builds stop needing libpipewire and libclang - [A release merge opens no stale release PR](/quest/m0/ci/release-pr-after-release.md) - `just rs release` publishes before it proposes, so a merged release isn't re-proposed - [Dependabot leaves the workspace-hack to hakari](/quest/m0/ci/dependabot-workspace-hack.md) - cargo group PRs stop hand-editing `rs/workspace-hack/Cargo.toml` -- [Bun runs only the test files we name](/quest/m0/ci/bun-test-paths.md) - scripted `bun test` calls pass exact paths, so generated copies never run diff --git a/quest/m0/ci/bun-test-paths.md b/quest/m0/ci/bun-test-paths.md deleted file mode 100644 index 60afffd0baf..00000000000 --- a/quest/m0/ci/bun-test-paths.md +++ /dev/null @@ -1,31 +0,0 @@ -# [XS] Bun runs only the test files we name - -## Goal - -Every scripted `bun test ` passes an exact path. Bun then never runs a -same-named copy elsewhere in the checkout, such as a generated release tree -under `target/` or a scratch directory. A check keeps new bare filters -from coming back. - -## Plan - -Decided 2026-11-03 (maintainer, /quest-plan of the follow-ups from -[moq-dev/moq#7680](https://github.com/moq-dev/moq/pull/7680), first seen in -[moq-dev/moq#7669](https://github.com/moq-dev/moq/pull/7669)): milestone m0, -in the CI epic. - -- Cause: bun treats a bare argument as a substring filter over discovered - files. Only arguments that start with `./` or `/` are exact. The API - checker's generated release tree under the build cache held copies of - `sh/gh/*.test.ts`, and those old tests ran against main's workflows. -- Bare filters on main: `.github/justfile:30,54,59` - (`sh/gh/alert|setup|ffi|cpp|publish.test.ts`), - `test/json-properties.sh:12`, `test/resume-model.sh:12`, - `test/justfile:52,54,277` (check what the harness lists expand to), and - `test/smoke/justfile:122`. Prefix each with `./`. -- Guard: have the test inventory (`sh/gh/test-inventory.py`) or a small - lint refuse a scripted `bun test` argument that ends in `.test.ts` and - lacks `./` or `/`. If no root `bunfig.toml` exists, also consider one - that limits discovery to the source roots. - -Public API: none. Wire: none. diff --git a/sh/gh/test-inventory.py b/sh/gh/test-inventory.py index c7edff90c56..82ee10aa55f 100644 --- a/sh/gh/test-inventory.py +++ b/sh/gh/test-inventory.py @@ -1,4 +1,4 @@ -"""Fail when an opt-in test target has no CI caller. +"""Fail when an opt-in test target has no CI caller or a Bun file filter is ambiguous. nextest, `bun test`, and pytest discover ordinary tests by themselves; these targets run only when something names them: @@ -20,6 +20,9 @@ callers' first argument selects. A local-only target needs an entry in test-inventory.allow naming the quest that owns it. +Scripted Bun test files need `./` or `/`: bare paths are substring filters +that also run same-named tests in generated trees. + `--root DIR` checks another tree, for the tests. """ @@ -40,6 +43,8 @@ SCRIPT = re.compile(r"[\w.$/{}-]*[\w-]\.(?:sh|py|ts|mts|js|mjs)\b") JS = (".ts", ".mts", ".js", ".mjs") +# Every extension Bun discovers as a test file (`*.test.{js,jsx,ts,tsx,mjs,cjs,mts,cts}`). +BUN_TEST = (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts") # A relative module specifier in a static or dynamic import, or a re-export. IMPORT = re.compile(r"""\b(?:from|import)\s*\(?\s*["'](\.\.?/[^"']+)["']""") # Commands that run a test suite. Python sources spell them as argument lists, @@ -1033,6 +1038,34 @@ def discovered(root: Path, graph: Graph) -> set[Path]: return found +def bun_test_paths(root: Path, recipes: list[Recipe]) -> list[str]: + """Reject bare Bun test-file filters in recipes, shell scripts, workflows, and npm scripts.""" + texts = [(recipe.text, recipe_line(root, recipe)) for recipe in recipes] + texts += [(text, where) for text, where, _ in workflow_steps(root)] + texts += [(path.read_text(), str(path.relative_to(root))) for path in tracked(root, "*.sh") if path.is_file()] + for manifest in (path for path in tracked(root, "package.json", "*/package.json") if path.is_file()): + texts += [ + (script, f"{manifest.relative_to(root)} scripts.{name}") + for name, script in json.loads(manifest.read_text()).get("scripts", {}).items() + ] + errors = set() + for text, where in texts: + for words in commands(text): + for index in range(len(words) - 1): + if words[index : index + 2] != ["bun", "test"]: + continue + value = False + for word in words[index + 2 :]: + if value: + value = False + continue + if word in ("-t", "--test-name-pattern", "--preload", "--reporter-outfile", "--coverage-dir"): + value = True + elif word.endswith(BUN_TEST) and literal(word) and not word.startswith(("./", "/", "-")): + errors.add(f"{where}: bun test {word} is a substring filter; prefix the file path with ./") + return sorted(errors) + + def inventory(root: Path) -> tuple[list[tuple[str, str]], list[str]]: """Every orphaned target as (key, where), and errors in the inputs.""" justfiles = load_recipes(root) @@ -1044,7 +1077,8 @@ def inventory(root: Path) -> tuple[list[tuple[str, str]], list[str]]: manifests[package.directory.resolve()] = package.name orphans: list[tuple[str, str]] = [] - errors: list[str] = [] + recipes = {recipe.path: recipe for module in justfiles.values() for recipe in module.recipes.values()} + errors = bun_test_paths(root, list(recipes.values())) selections = [] for run in graph.runs: if any( @@ -1102,7 +1136,6 @@ def inventory(root: Path) -> tuple[list[tuple[str, str]], list[str]]: benches = bench_entries(root, graph) # Recipes that run tests. - recipes = {recipe.path: recipe for module in justfiles.values() for recipe in module.recipes.values()} for _, recipe in sorted(recipes.items()): if recipe.path not in graph.reached and runs_tests(graph, recipe, found, benches): orphans.append((f"recipe {recipe.path}", recipe_line(root, recipe))) @@ -1175,8 +1208,8 @@ def main(argv: list[str]) -> int: errors.append(f"{where}: {key} is not an orphan; drop the entry") if errors: print( - "Every opt-in test target needs a CI caller, or an entry in sh/gh/test-inventory.allow" - " naming the quest that owns it:", + "Test inventory failed (opt-in targets need a CI caller or a quest in sh/gh/test-inventory.allow;" + " Bun test files need exact paths):", file=sys.stderr, ) for error in errors: diff --git a/sh/gh/test-inventory.test.py b/sh/gh/test-inventory.test.py index dce5a05e7ec..ae890ad8924 100644 --- a/sh/gh/test-inventory.test.py +++ b/sh/gh/test-inventory.test.py @@ -350,6 +350,67 @@ def test_a_bun_workspace_covers_its_tests_once_its_runner_is_reached(self): self.write("js/net/package.json", '{"scripts": {"manual-test": "bun test src"}}') self.assertIn("script js/net/src/codec.test.ts", self.orphans()) + def test_bun_file_paths_are_exact_in_every_scripted_caller(self): + self.write( + "justfile", + JUSTFILE + + """ + files := ''' + test/compat.test.ts + test/view.test.tsx + ./test/exact.test.ts + ''' + + local: + cd test && bash ../sh/local.sh bun test {{ replace(files, "\\n", " ") }} +""", + ) + self.write("sh/local.sh", '"$@"\n') + self.write( + "sh/run.sh", + 'bun test \\\n "test/codec.test.ts" test/a.test.jsx test/b.test.cjs ' + "./test/exact.test.ts # bun test commented.test.ts\n", + ) + self.write("package.json", '{"scripts": {"test": "bun test test/codec.spec.ts test/codec.test.cts"}}') + workflow = self.root / ".github/workflows/ci.yml" + self.write(str(workflow.relative_to(self.root)), WORKFLOW + " - run: bun test test/frame.test.ts\n") + _, errors = module.inventory(self.root) + self.assertEqual( + errors, + [ + ".github/workflows/ci.yml (fuzz): bun test test/frame.test.ts is a substring filter;" + " prefix the file path with ./", + "justfile:17: bun test test/compat.test.ts is a substring filter; prefix the file path with ./", + "justfile:17: bun test test/view.test.tsx is a substring filter; prefix the file path with ./", + "package.json scripts.test: bun test test/codec.spec.ts is a substring filter;" + " prefix the file path with ./", + "package.json scripts.test: bun test test/codec.test.cts is a substring filter;" + " prefix the file path with ./", + "sh/run.sh: bun test test/a.test.jsx is a substring filter; prefix the file path with ./", + "sh/run.sh: bun test test/b.test.cjs is a substring filter; prefix the file path with ./", + "sh/run.sh: bun test test/codec.test.ts is a substring filter; prefix the file path with ./", + ], + ) + self.write( + "sh/run.sh", + 'bun test ./test/codec.test.ts /source/test/exact.test.ts src -t "codec.test.ts" --preload setup.ts\n' + "# bun test commented.test.ts\n", + ) + self.assertFalse(any("sh/run.sh:" in error for error in module.inventory(self.root)[1])) + + def test_bun_guard_reads_the_working_tree_after_unstaged_deletions(self): + files = {"sh/run.sh": "bun test ./codec.test.ts\n", "web/package.json": '{"scripts": {"test": "bun test"}}'} + for name, text in files.items(): + self.write(name, text) + subprocess.run(["git", "add", *files], cwd=self.root, check=True) + for name, text in files.items(): + with self.subTest(name=name): + (self.root / name).unlink() + try: + self.orphans() + finally: + self.write(name, text) + def test_a_js_bench_entry_point_needs_a_caller(self): # Nightly's shape: a loop's literal names reach a recipe parameter through a helper's "$@". self.write( diff --git a/test/json-properties.sh b/test/json-properties.sh index 7e340fa1149..ca33fbeda72 100644 --- a/test/json-properties.sh +++ b/test/json-properties.sh @@ -9,6 +9,6 @@ export MOQ_JSON_SEED MOQ_JSON_CASES echo "JSON codec properties: MOQ_JSON_SEED=$MOQ_JSON_SEED MOQ_JSON_CASES=$MOQ_JSON_CASES" bun install --frozen-lockfile just rs test -p moq-json --lib -E 'test(snapshot_codec_property) | test(window_codec_property)' -bun test js/json/src/snapshot/properties.test.ts js/json/src/window/properties.test.ts +bun test ./js/json/src/snapshot/properties.test.ts ./js/json/src/window/properties.test.ts bunx tsc --noEmit --project js/json/bench/tsconfig.json bun js/json/bench/codec.ts diff --git a/test/justfile b/test/justfile index 467497e2ef5..8425cc34443 100644 --- a/test/justfile +++ b/test/justfile @@ -32,8 +32,8 @@ harness_browser_tests := ''' ''' harness_compat_tests := ''' - test/interop/compat/claims.test.ts - test/interop/compat/resolve.test.ts + ./test/interop/compat/claims.test.ts + ./test/interop/compat/resolve.test.ts ''' # List the cross-language suites. @@ -281,7 +281,7 @@ resume-model: qmux: cargo build --locked --quiet --manifest-path ../rs/qmux/Cargo.toml --example interop-server --example interop-client cd .. && bun install --frozen-lockfile - cd .. && QMUX_INTEROP_BIN={{ quote(qmux_examples) }} bun test test/qmux/interop.test.ts + cd .. && QMUX_INTEROP_BIN={{ quote(qmux_examples) }} bun test ./test/qmux/interop.test.ts # Complete MoQ SETUP and transfer a frame over a real browser data-channel pair. p2p: diff --git a/test/resume-model.sh b/test/resume-model.sh index 40678bc9af4..1cdb99d055b 100755 --- a/test/resume-model.sh +++ b/test/resume-model.sh @@ -9,4 +9,4 @@ export MOQ_RESUME_SEED MOQ_RESUME_CASES echo "Resume model: MOQ_RESUME_SEED=$MOQ_RESUME_SEED MOQ_RESUME_CASES=$MOQ_RESUME_CASES" just rs test -p moq-net --lib -E 'test(=model::resume_model::readers_match_the_logical_track)' bun install --frozen-lockfile -bun test js/net/src/resume.model.test.ts +bun test ./js/net/src/resume.model.test.ts diff --git a/test/smoke/justfile b/test/smoke/justfile index b335039c451..5acd8a831ee 100644 --- a/test/smoke/justfile +++ b/test/smoke/justfile @@ -119,7 +119,7 @@ harness-check: # Install the browser clients and Chromium's system libraries. playwright-deps: cd clients/js && bun install - cd clients/js && bun test diagnostics.test.ts + cd clients/js && bun test ./diagnostics.test.ts cd clients/js && bunx playwright install-deps chromium # Compile-check and unit-test the local integrity client against the current