Skip to content

fix(test): run spawned CLIs from a per-run snapshot of dist/ - #439

Merged
theCodeDrift merged 1 commit into
mainfrom
fix/test-dist-snapshot
Oct 2, 2026
Merged

theCodeDrift merged 1 commit into
mainfrom
fix/test-dist-snapshot

Conversation

@theCodeDrift

Copy link
Copy Markdown
Member

Two spawning tests failed once on a full-suite run and passed on an immediate rerun (#262). The cause: vitest run does not build, so every spawned CLI reads whatever dist/ holds at that moment, and a build that starts mid-run (pnpm lint builds first) empties dist/ before rewriting it.

Measured by spawning auth login --anonymous --json in a loop:

Condition Spawns Failed
6 builds running alongside 343 22
No build 414 0

Every failure was exit code 1, empty stdout, and Cannot find module …/dist/index.js (or a chunk) on stderr, which is exactly what both reported assertions choke on. Because the exit code is a real number, the spawn-cli.ts helper added for #262 reported it as a CLI contract failure, and its comment pointed at fork pressure instead.

What changed

  • test/support/distribution-snapshot.ts: vitest global setup. It copies dist/ once per run into packages/cli/tmp/dist-test/<pid>-* and retries if a build changes dist/ mid-copy. Teardown removes the copy, and snapshots whose owning process has exited are swept on the next run, never a live one. If dist/index.js is missing it fails with "run pnpm build first".
  • test/support/built-cli.ts: builtCli() / builtDirectory(). Every test that spawned or imported ../dist (33 files) now reads the snapshot.
  • test/support/spawn-cli.ts: a missing built module is reported as "the CLI never ran". Node's ESM loader names the realpath and the CJS loader the given path, so both are matched. There are two new real-spawn tests.

The snapshot lives inside the package, not os.tmpdir(), because the bundle finds tsx, ast-grep and Vale relative to its own file through createRequire(import.meta.url). From a tmpdir copy both tsx and @ast-grep/cli were unresolvable. tmp is already gitignored.

Verification

  • Full suite while dist/ was rebuilt in a loop underneath it: 1790/1790 passed.
  • Stale-snapshot sweep and the missing-dist/ error were both checked by hand.
  • pnpm typecheck, eslint and prettier are clean.

Test-only change, so no changeset.

Fixes #262

@theCodeDrift theCodeDrift added the skip-changeset PR intentionally ships no release note (bypasses the changeset requirement) label Oct 1, 2026
@theCodeDrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Claude finished @theCodeDrift's task in 1m 36s —— View job


Review mode: incremental — read 0 prior review thread(s) before reviewing.

.prior-review.json contains no reviewThreads, reviews, or comments beyond the triggering @claude /review — this is the first review of this PR. Nothing to mark as already addressed.

Checklist

  • Read .prior-review.json (empty — first review)
  • Fetch PR diff and metadata
  • Review new support files (distribution-snapshot.ts, built-cli.ts, spawn-cli.ts)
  • Review vite.config.ts change
  • Spot-check the 33 mechanically-updated test files
  • Post final review

Assessment

This is a well-targeted fix with good self-documenting rationale in the comments. I read the full diff plus the three new/changed support files and vite.config.ts in full, and spot-checked several of the mechanically-updated test files for leftover unused imports (resolve) or stray ../dist references. Found none — the migration to builtCli()/builtDirectory() is complete and consistent across all 33 files, and a repo-wide grep confirms no remaining test still resolves ../dist directly, so no spawning test was left exposed to the race this PR fixes.

Correctness of the snapshot mechanism (test/support/distribution-snapshot.ts):

  • The copy-then-refingerprint-and-retry approach to detect a build racing the copy is sound: it compares a full (path, size, mtime) fingerprint of dist/ before and after cp, and only accepts the copy if nothing changed in that window.
  • sweepStaleSnapshots correctly distinguishes a live owner (process.kill(pid, 0), treating EPERM as "exists but not mine") from a dead one, so it never removes another concurrently-running suite's snapshot.
  • The snapshot is deliberately placed under packages/cli/tmp/ rather than os.tmpdir(), with the reasoning (bundle resolves tsx/ast-grep relative to its own file via createRequire) given inline and backed by a concrete measurement in the comment.
  • Minor, non-blocking observation: if dist/index.js never existed at all (fresh checkout, no build yet), the loop still burns COPY_ATTEMPTS * COPY_RETRY_MS (~2.5s) before surfacing the "run pnpm build first" error, since existsSync is checked only inside the loop. Not a correctness issue, just a slightly slower failure message in that one case.

spawn-cli.ts: the new namesMissingModuleIn check correctly handles both the ESM (realpath) and CJS (given path) spellings node uses for "Cannot find module", and is ordered after the existing string-vs-number code check, so it only activates for a genuine numeric exit-1 case. The two new spawn-cli-support.test.ts cases exercise both failure shapes (missing entry, missing chunk behind the entry) against a real spawned node process rather than a hand-built rejection object, consistent with the file's existing "assert against reality" style.

vite.config.ts: globalSetup is added at the right scope and the inline comment correctly cross-references the new file. No issues with how it interacts with the existing projects: [cli, nightly] split — both projects inherit the same globalSetup/env via extends: true, and builtCli()/builtDirectory() reads back whatever provide("cliDist", …) each project's own setup instance stored, so the two projects don't share or clobber one cliDist value.

No correctness, security, or test-adequacy issues found. This is test-only infrastructure as described, and the skip-changeset label is appropriate.

`vitest run` does not build, so every spawned CLI read whatever dist/
held at that moment, and a build that started mid-run (`pnpm lint`
builds first) emptied dist/ before rewriting it. Measured: 22 of 343
spawns failed with builds running alongside, each exit 1 with empty
stdout and `Cannot find module …/dist/index.js`; 0 of 414 without.

A global setup now copies dist/ into packages/cli/tmp/dist-test/<pid>-*
once per run, retrying if a build changes it mid-copy, and every test
reads from there. It stays inside the package because the bundle finds
tsx, ast-grep and Vale relative to its own file. Snapshots whose owning
process has exited are swept on the next run.

spawn-cli.ts now reports a missing built module as the CLI never
having run, rather than as a contract failure.

Fixes #262
@theCodeDrift
theCodeDrift force-pushed the fix/test-dist-snapshot branch from c6ed7ab to 6c4f7ba Compare October 2, 2026 00:20
@theCodeDrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Minor, non-blocking observation: if dist/index.js never existed at all (fresh checkout, no build yet), the loop still burns…"
#439 (comment)

Declined: the ~2.5s delay only happens on a checkout with no build at all, and that case still ends in a clear "run pnpm build first" error, so this isn't worth changing.

— AI Coding Agent

@theCodeDrift
theCodeDrift merged commit d244640 into main Oct 2, 2026
4 checks passed
@theCodeDrift
theCodeDrift deleted the fix/test-dist-snapshot branch October 2, 2026 00:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changeset PR intentionally ships no release note (bypasses the changeset requirement)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flaky: two subprocess-spawning tests fail intermittently on a full-suite run

1 participant