fix(test): dependency-aware and dirty-aware test cache invalidation

- cache key v2 = own hash + sorted transitive workspace dep hashes + shared inputs (lockfile, tsconfig.base, core __test-utils__ tree)
- working-tree-dirty files hashed by content (fixes false cache HIT on unstaged edits)
- core __test-utils__ folded globally: 16+ packages import it without a workspace dep on core
- dep folding adds ~5ms to the inner loop (memoized own-hashes)
- docs: cache semantics, --no-cache / FUSION_TEST_NO_CACHE, TTL rationale
This commit is contained in:
gsxdsm
2026-06-03 18:06:05 -07:00
parent 211c0fd557
commit 2c41695e1f
3 changed files with 707 additions and 81 deletions

View File

@@ -113,6 +113,65 @@ For a single Vitest file, use package-local `exec vitest`:
pnpm --filter @fusion/core exec vitest run src/__tests__/central-db.test.ts --silent=passed-only --reporter=dot
```
## Changed-only test cache (`pnpm test`)
`pnpm test` runs `scripts/test-changed.mjs`, which selects only the workspace
packages affected by your branch diff (plus their reverse-dependents) and skips
packages whose content hasn't changed since they last passed. A per-package
pass-cache lives at `node_modules/.cache/fusion/test-cache.json`.
### What a cache entry's hash covers (dependency-aware invalidation)
Each package's cache hash (`computePackageHash`) folds in, so any of these
changing forces that package to re-run:
- **The package's own tracked files**, hashed via the **working-tree bytes** for
any file that is dirty (unstaged/uncommitted edits) or untracked-not-ignored,
and via git's index blob SHA only when the file is fully clean. This means an
**unstaged edit to a tracked file busts the cache** — no false HIT on a stale
index blob.
- **Every transitive workspace dependency's own hash.** A change to `@fusion/core`
invalidates the cache entries of `engine`, `dashboard`, `cli`, and everything
else that (transitively) depends on it, even when the dependent's own files are
untouched. This is the R11 correctness fix: a dependent is never cache-skipped
when a dependency it consumes has changed.
- **Shared inputs folded into *every* package**: `pnpm-lock.yaml`,
`tsconfig.base.json`, and the shared `packages/core/src/__test-utils__` tree.
The test-utils tree is imported by nearly every package's vitest config via a
relative cross-package path, including packages that have **no** `@fusion/core`
workspace dependency (mobile, droid-cli, pi-\*, and the plugins). Folding it in
globally (like `tsconfig.base.json`) guarantees an edit there invalidates the
whole workspace.
The hash carries a version prefix (`HASH_VERSION_PREFIX`). Bumping it (done in U4:
`v1` → `v2`) invalidates every pre-existing entry exactly once; old-format cache
files are discarded gracefully rather than crashed on.
### Escape hatches
If you suspect a stale or wrong cache result (e.g. a flaky test that happened to
pass got cached, or you want to force a clean re-run), bypass the cache:
```bash
pnpm test --no-cache # bypass cache reads AND writes for this run
FUSION_TEST_NO_CACHE=1 pnpm test
```
`--no-cache` re-runs every selected package without consulting or clearing the
cache file; a subsequent normal `pnpm test` still hits the cache. `pnpm test:full`
already passes `--no-cache` (a full run means full). These flags already exist;
this section documents them.
### TTL rationale (7-day expiry)
Entries older than **7 days** are treated as a MISS even on a hash match
(`CACHE_MAX_AGE_MS`). The TTL is intentionally retained even though dep-aware
hashing makes content-staleness impossible: it guards against **environmental
drift** that the content hash cannot see — toolchain/Node upgrades, OS or native
dependency changes, and other host-level shifts that can change test outcomes
without changing any hashed file. Seven days bounds that blind spot while keeping
the cache useful across a normal work week.
## Engine test helper convention
`packages/engine/src/__tests__/executor-test-helpers.ts` defaults both `isUsableTaskWorktree` to `true` and `classifyTaskWorktree` to `{ ok: true }` via a helper-level `worktree-pool` mock. To test failure paths, override with `vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValueOnce({ ok: false, classification: "unregistered", reason: "..." })` (or `isUsableTaskWorktree` for legacy call sites). Production liveness assertions in `executor.ts` are unchanged.