diff --git a/AGENTS.md b/AGENTS.md index 6f74dac68e..61da3d0d63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,7 +107,7 @@ The merge gate is thin and trusted: CI blocks PRs on exactly Lint, Typecheck, Bu pnpm test # gate suite + changed-only affected tests (bounded; never full-suite) pnpm test:gate # the merge gate: curated engine-core suite + CI-shape test pnpm smoke:boot # boot smoke: CLI --help + real serve /api/health -pnpm verify:fast # TEST-FREE verification: typecheck + build (scoped to changed packages) + boot smoke; recommended non-test verification/testCommand. Additive — changes no default +pnpm verify:fast # TEST-FREE verification: artifact bootstrap + scoped typecheck/build + CLI build + boot smoke; recommended non-test verification/testCommand. Additive — changes no default pnpm test:velocity # weekly report-only test velocity baseline; use -- --measure --write-report to refresh pnpm test:full # full workspace suite — explicit opt-in only pnpm lint @@ -115,7 +115,7 @@ pnpm build pnpm verify:workspace # deep opt-in verification (lint -> test:full -> build); NOT the merge gate ``` -`pnpm verify:fast` is the recommended **test-free verification** path: typecheck + build scoped to the changed packages (it reuses `pnpm test`'s changed-package resolution) plus the boot smoke once, with **no test run**. It is deterministic and flake-free, suitable as a project `testCommand`/verification command when you want non-test verification; the full suite stays available and runs non-blocking. It is additive and does not change `pnpm test`, the gate, or CI. See `docs/testing.md`. +`pnpm verify:fast` is the recommended **test-free verification** path: bootstrap missing/stale workspace dist artifacts, typecheck + build scoped to the changed packages (it reuses `pnpm test`'s changed-package resolution), an always-on `@runfusion/fusion` CLI build required by the source-checkout boot smoke, plus the boot smoke once, with **no test run**. It is deterministic and flake-free, suitable as a project `testCommand`/verification command when you want non-test verification; the full suite stays available and runs non-blocking. It is additive and does not change `pnpm test`, the gate, or CI. See `docs/testing.md`. ### Standing Rule: Flaky Tests Are Quarantined on Sight (Deletion Ratchet) diff --git a/docs/testing.md b/docs/testing.md index e7144edec4..60651b20a9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -32,7 +32,7 @@ Use the narrowest command that exercises the behavior you changed, then broaden pnpm test # gate suite + changed-only affected tests (bounded; never full-suite) pnpm test:gate # the merge gate: curated engine-core suite + CI-shape test pnpm smoke:boot # boot smoke: CLI --help + real serve /api/health -pnpm verify:fast # TEST-FREE verification: typecheck + build (scoped to changed packages) + boot smoke +pnpm verify:fast # TEST-FREE verification: artifact bootstrap + scoped typecheck/build + CLI build + boot smoke pnpm test:full # full workspace suite — explicit opt-in only pnpm lint # lint all packages pnpm build # build workspace packages (excludes desktop/mobile) @@ -40,7 +40,8 @@ pnpm verify:workspace # deep opt-in verification: lint -> test:full -> build (N ``` -`pnpm verify:fast` (`scripts/verify-fast.mjs`) is the recommended **test-free verification** command: it runs **typecheck + build scoped to the changed packages** (reusing the same git-diff / changed-package resolution as `pnpm test`) followed by the existing **boot smoke** once — and runs **no test suite**. It gives deterministic, flake-free signal in seconds, so it is a sound project `testCommand`/verification command when you want non-test verification. With no affected package (root/docs-only diff) it runs the boot smoke only. Each step is bounded by the shared `runWithWatchdog` (class `changed`) so a hang fails fast, and it exits nonzero on the first failing step. This is purely additive: it does not change `pnpm test`, the merge gate, or CI, and the full suite stays available (`pnpm test:full`, non-blocking on push to main). + +`pnpm verify:fast` (`scripts/verify-fast.mjs`) is the recommended **test-free verification** command: it bootstraps missing/stale workspace dist artifacts, runs **typecheck + build scoped to the changed packages** (reusing the same git-diff / changed-package resolution as `pnpm test`), always builds the `@runfusion/fusion` CLI package required by the source-checkout boot smoke, then runs the existing **boot smoke** once — and runs **no test suite**. It gives deterministic, flake-free signal in seconds, so it is a sound project `testCommand`/verification command when you want non-test verification. With no affected package (root/docs-only diff) it runs only the artifact bootstrap, CLI prerequisite build, and boot smoke. Each step is bounded by the shared `runWithWatchdog` (class `changed`) so a hang fails fast, and it exits nonzero on the first failing step. This is purely additive: it does not change `pnpm test`, the merge gate, or CI, and the full suite stays available (`pnpm test:full`, non-blocking on push to main). `pnpm test:full` runs each package's default test script with capped worker fanout (`FUSION_TEST_TOTAL_WORKERS=4 FUSION_TEST_CONCURRENCY=2 pnpm -r --workspace-concurrency=2 test`). Do not casually raise worker counts; dashboard/jsdom and integration-heavy packages destabilize when oversubscribed. Use `VITEST_MAX_WORKERS=` only for targeted package-level investigation. diff --git a/scripts/__tests__/verify-fast.test.mjs b/scripts/__tests__/verify-fast.test.mjs index 91dbbcd3b9..f0e3a3f55f 100644 --- a/scripts/__tests__/verify-fast.test.mjs +++ b/scripts/__tests__/verify-fast.test.mjs @@ -15,13 +15,16 @@ import { buildTypecheckStep, buildBuildStep, buildBootSmokeStep, + buildArtifactBootstrapStep, buildVerifyPlan, VERIFY_EXCLUDED_PACKAGES, + BOOT_SMOKE_REQUIRED_BUILD_PACKAGES, } from "../verify-fast.mjs"; import { resolveAffectedPackages } from "../test-changed.mjs"; const SMOKE = "/repo/scripts/boot-smoke.mjs"; +const BOOTSTRAP = "/repo/scripts/ensure-test-artifacts.mjs"; const NODE = "/usr/bin/node"; function stepIds(plan) { @@ -53,7 +56,7 @@ test("buildTypecheckStep: defaults to the tsc fallback when meta omitted", () => }); // --------------------------------------------------------------------------- -// buildBuildStep / buildBootSmokeStep +// buildBuildStep / buildBootSmokeStep / buildArtifactBootstrapStep // --------------------------------------------------------------------------- test("buildBuildStep: scoped pnpm build for the package", () => { @@ -69,14 +72,22 @@ test("buildBootSmokeStep: runs the boot-smoke script via node", () => { assert.equal(step.kind, "boot-smoke"); }); +test("buildArtifactBootstrapStep: runs the artifact bootstrap script via node", () => { + const step = buildArtifactBootstrapStep(BOOTSTRAP, NODE); + assert.equal(step.command, NODE); + assert.deepEqual(step.args, [BOOTSTRAP]); + assert.equal(step.kind, "bootstrap-artifacts"); +}); + // --------------------------------------------------------------------------- // buildVerifyPlan // --------------------------------------------------------------------------- -test("buildVerifyPlan: no packages -> boot smoke only", () => { +test("buildVerifyPlan: no packages -> CLI prerequisite build then boot smoke", () => { const plan = buildVerifyPlan({ packages: [], bootSmokeScriptPath: SMOKE, nodeBin: NODE }); - assert.deepEqual(stepIds(plan), ["boot-smoke"]); + assert.deepEqual(stepIds(plan), ["bootstrap-artifacts", "build:@runfusion/fusion", "boot-smoke"]); assert.deepEqual(plan.eligiblePackages, []); + assert.deepEqual(plan.requiredBootBuildPackages, ["@runfusion/fusion"]); }); test("buildVerifyPlan: typecheck for all eligible, then builds, then boot smoke (ordered)", () => { @@ -86,10 +97,12 @@ test("buildVerifyPlan: typecheck for all eligible, then builds, then boot smoke ]); const plan = buildVerifyPlan({ packages: ["@fusion/engine", "@fusion/core"], packageMeta, bootSmokeScriptPath: SMOKE, nodeBin: NODE }); assert.deepEqual(stepIds(plan), [ + "bootstrap-artifacts", "typecheck:@fusion/engine", "typecheck:@fusion/core", "build:@fusion/engine", "build:@fusion/core", + "build:@runfusion/fusion", "boot-smoke", ]); }); @@ -101,9 +114,11 @@ test("buildVerifyPlan: a package without a build script gets a typecheck step bu ]); const plan = buildVerifyPlan({ packages: ["@fusion/engine", "@fusion/test-only"], packageMeta, bootSmokeScriptPath: SMOKE, nodeBin: NODE }); assert.deepEqual(stepIds(plan), [ + "bootstrap-artifacts", "typecheck:@fusion/engine", "typecheck:@fusion/test-only", "build:@fusion/engine", + "build:@runfusion/fusion", "boot-smoke", ]); // The test-only package's typecheck uses the tsc fallback (no typecheck script). @@ -125,7 +140,7 @@ test("buildVerifyPlan: desktop/mobile are excluded from scoped steps but boot sm }); assert.deepEqual(plan.eligiblePackages, ["@fusion/engine"]); assert.deepEqual(plan.excludedPackages.sort(), ["@fusion/desktop", "@fusion/mobile"]); - assert.deepEqual(stepIds(plan), ["typecheck:@fusion/engine", "build:@fusion/engine", "boot-smoke"]); + assert.deepEqual(stepIds(plan), ["bootstrap-artifacts", "typecheck:@fusion/engine", "build:@fusion/engine", "build:@runfusion/fusion", "boot-smoke"]); }); test("VERIFY_EXCLUDED_PACKAGES mirrors the root build/typecheck exclusions", () => { @@ -133,6 +148,10 @@ test("VERIFY_EXCLUDED_PACKAGES mirrors the root build/typecheck exclusions", () assert.ok(VERIFY_EXCLUDED_PACKAGES.has("@fusion/mobile")); }); +test("BOOT_SMOKE_REQUIRED_BUILD_PACKAGES includes the source-checkout CLI", () => { + assert.deepEqual(BOOT_SMOKE_REQUIRED_BUILD_PACKAGES, ["@runfusion/fusion"]); +}); + // --------------------------------------------------------------------------- // Integration: reuse test-changed's resolveAffectedPackages to scope the plan // --------------------------------------------------------------------------- @@ -151,5 +170,5 @@ test("buildVerifyPlan: scopes to exactly the packages resolveAffectedPackages se const packageMeta = new Map([["@fusion/engine", { hasTypecheck: true, hasBuild: true }]]); const plan = buildVerifyPlan({ packages: affected, packageMeta, bootSmokeScriptPath: SMOKE, nodeBin: NODE }); - assert.deepEqual(stepIds(plan), ["typecheck:@fusion/engine", "build:@fusion/engine", "boot-smoke"]); + assert.deepEqual(stepIds(plan), ["bootstrap-artifacts", "typecheck:@fusion/engine", "build:@fusion/engine", "build:@runfusion/fusion", "boot-smoke"]); }); diff --git a/scripts/verify-fast.mjs b/scripts/verify-fast.mjs index f12de223cb..b96410456e 100644 --- a/scripts/verify-fast.mjs +++ b/scripts/verify-fast.mjs @@ -3,10 +3,18 @@ FNXC:TestInfrastructure 2026-06-25-00:00: verify:fast is the opt-in, TEST-FREE verification command. It gives deterministic, flake-free signal in seconds without running the test suite, by doing exactly: - 1. typecheck — scoped to the changed packages (their `typecheck` script, or + 1. bootstrap — rebuild missing/stale workspace dist prerequisites used by package builds. + 2. typecheck — scoped to the changed packages (their `typecheck` script, or `pnpm --filter exec tsc --noEmit -p .` when none exists). - 2. build — scoped to the changed packages (`pnpm --filter build`). - 3. boot smoke — once (scripts/boot-smoke.mjs: CLI --help + real serve /api/health). + 3. build — scoped to the changed packages, plus the CLI package needed by boot smoke. + 4. boot smoke — once (scripts/boot-smoke.mjs: CLI --help + real serve /api/health). + +FNXC:TestInfrastructure 2026-06-26-00:49: +A fresh worktree can have no plugin runtime dist artifacts or `packages/cli/dist/bin.js`. +Since package builds import those plugin artifacts and boot-smoke invokes the +source-checkout CLI wrapper, verify:fast must bootstrap workspace dist artifacts +and always build @runfusion/fusion before the smoke so the command proves a +runnable checkout instead of failing on a missing prerequisite. Rationale: docs/testing.md observes the broad test gate "caught no recalled real bugs while consuming ~70% of shipping time in flake triage." typecheck+build+boot @@ -42,6 +50,7 @@ import { deriveBudgetMs, runWithWatchdog } from "./lib/run-vitest-watchdog.mjs"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, ".."); const bootSmokeScriptPath = path.join(scriptDir, "boot-smoke.mjs"); +const artifactBootstrapScriptPath = path.join(scriptDir, "ensure-test-artifacts.mjs"); /* FNXC:TestInfrastructure 2026-06-25-00:00: @@ -50,6 +59,7 @@ scripts (heavy native/electron + RN toolchains), so verify:fast mirrors that policy and skips them with a note rather than failing on an unbuildable filter. */ export const VERIFY_EXCLUDED_PACKAGES = new Set(["@fusion/desktop", "@fusion/mobile"]); +export const BOOT_SMOKE_REQUIRED_BUILD_PACKAGES = ["@runfusion/fusion"]; /** * Build the scoped typecheck step for a package. Prefers the package's own @@ -96,35 +106,73 @@ export function buildBootSmokeStep(smokeScriptPath, nodeBin = process.execPath) }; } +/** + * Build the prerequisite artifact bootstrap step. It is intentionally first so + * fresh worktrees have the bundled plugin/runtime dist outputs before scoped + * package builds (especially @runfusion/fusion) import them. + * + * @param {string} bootstrapScriptPath + * @param {string} [nodeBin] + */ +export function buildArtifactBootstrapStep(bootstrapScriptPath, nodeBin = process.execPath) { + return { + id: "bootstrap-artifacts", + kind: "bootstrap-artifacts", + pkg: null, + label: "bootstrap workspace dist artifacts", + command: nodeBin, + args: [bootstrapScriptPath], + klass: "changed", + }; +} + /** * Pure planner: turn the affected package set into an ordered step list. - * typecheck (all eligible) → build (eligible with a build script) → boot smoke. - * With no eligible packages this is just the boot-smoke step, satisfying the - * "no packages changed ⇒ boot smoke only" contract. + * bootstrap missing/stale dist artifacts → typecheck (all eligible) → build + * (eligible with a build script) → required boot-smoke build prerequisites → + * boot smoke. With no eligible packages this still builds the source-checkout + * CLI before the smoke so fresh worktrees have `packages/cli/dist/bin.js`. * * @param {object} opts * @param {string[]} [opts.packages] affected package names * @param {Map} [opts.packageMeta] * @param {string} opts.bootSmokeScriptPath + * @param {string} [opts.artifactBootstrapScriptPath] * @param {string} [opts.nodeBin] - * @returns {{ eligiblePackages: string[], excludedPackages: string[], steps: object[] }} + * @returns {{ eligiblePackages: string[], excludedPackages: string[], requiredBootBuildPackages: string[], steps: object[] }} */ -export function buildVerifyPlan({ packages = [], packageMeta = new Map(), bootSmokeScriptPath: smokeScriptPath, nodeBin = process.execPath } = {}) { +export function buildVerifyPlan({ packages = [], packageMeta = new Map(), bootSmokeScriptPath: smokeScriptPath, artifactBootstrapScriptPath: bootstrapScriptPath = artifactBootstrapScriptPath, nodeBin = process.execPath } = {}) { const eligiblePackages = packages.filter((pkg) => !VERIFY_EXCLUDED_PACKAGES.has(pkg)); const excludedPackages = packages.filter((pkg) => VERIFY_EXCLUDED_PACKAGES.has(pkg)); - const steps = []; + const steps = [buildArtifactBootstrapStep(bootstrapScriptPath, nodeBin)]; for (const pkg of eligiblePackages) { steps.push(buildTypecheckStep(pkg, packageMeta.get(pkg) ?? {})); } + + const builtPackages = new Set(); for (const pkg of eligiblePackages) { const meta = packageMeta.get(pkg) ?? {}; // Only build packages that declare a build script; pure test/config packages // have nothing to emit and a `pnpm --filter build` would error. - if (meta.hasBuild !== false) steps.push(buildBuildStep(pkg)); + if (meta.hasBuild !== false) { + steps.push(buildBuildStep(pkg)); + builtPackages.add(pkg); + } } + + const requiredBootBuildPackages = []; + for (const pkg of BOOT_SMOKE_REQUIRED_BUILD_PACKAGES) { + if (builtPackages.has(pkg) || VERIFY_EXCLUDED_PACKAGES.has(pkg)) continue; + const meta = packageMeta.get(pkg) ?? { hasBuild: true }; + if (meta.hasBuild === false) continue; + requiredBootBuildPackages.push(pkg); + steps.push(buildBuildStep(pkg)); + builtPackages.add(pkg); + } + steps.push(buildBootSmokeStep(smokeScriptPath, nodeBin)); - return { eligiblePackages, excludedPackages, steps }; + return { eligiblePackages, excludedPackages, requiredBootBuildPackages, steps }; } /** @@ -173,21 +221,21 @@ export function resolveAffectedForVerify() { const packageDirByName = buildPackageDirByName(workspacePackages); if (!comparisonBase) { - return { packages: [], packageDirByName, note: `could not resolve merge-base with ${baseBranch}; running boot smoke only` }; + return { packages: [], packageDirByName, note: `could not resolve merge-base with ${baseBranch}; running boot-smoke prerequisite build only` }; } const changedFiles = changedFilesSince(comparisonBase); if (changedFiles === null) { - return { packages: [], packageDirByName, note: "failed to read git diff; running boot smoke only" }; + return { packages: [], packageDirByName, note: "failed to read git diff; running boot-smoke prerequisite build only" }; } if (changedFiles.length === 0) { - return { packages: [], packageDirByName, note: "no changes detected against base; running boot smoke only" }; + return { packages: [], packageDirByName, note: "no changes detected against base; running boot-smoke prerequisite build only" }; } const affected = resolveAffectedPackages(changedFiles, packageNameByDir); if (affected === null) { - return { packages: [], packageDirByName, note: "changed file did not map to a workspace package; running boot smoke only" }; + return { packages: [], packageDirByName, note: "changed file did not map to a workspace package; running boot-smoke prerequisite build only" }; } if (affected.length === 0) { - return { packages: [], packageDirByName, note: "no affected workspace package (root/docs-only changes); running boot smoke only" }; + return { packages: [], packageDirByName, note: "no affected workspace package (root/docs-only changes); running boot-smoke prerequisite build only" }; } return { packages: affected, packageDirByName, note: `affected packages: ${affected.join(", ")}` }; } @@ -224,26 +272,30 @@ export async function runStep(step, { spawnFn = spawn, log = console.log, errLog export async function main() { const overallStart = Date.now(); - console.log("[verify:fast] test-free verification: typecheck + build (scoped to changed packages) + boot smoke."); + console.log("[verify:fast] test-free verification: scoped typecheck/build + CLI build + boot smoke."); const { packages, packageDirByName, note } = resolveAffectedForVerify(); console.log(`[verify:fast] ${note}`); - const packageMeta = readPackageMeta(packages, packageDirByName); - const { eligiblePackages, excludedPackages, steps } = buildVerifyPlan({ + const packageMeta = readPackageMeta([...new Set([...packages, ...BOOT_SMOKE_REQUIRED_BUILD_PACKAGES])], packageDirByName); + const { eligiblePackages, excludedPackages, requiredBootBuildPackages, steps } = buildVerifyPlan({ packages, packageMeta, bootSmokeScriptPath, + artifactBootstrapScriptPath, }); if (excludedPackages.length > 0) { console.log(`[verify:fast] skipping excluded packages (also excluded from root build/typecheck): ${excludedPackages.join(", ")}`); } if (eligiblePackages.length === 0) { - console.log("[verify:fast] no scoped packages to verify; running boot smoke only."); + console.log("[verify:fast] no scoped packages to verify; running boot-smoke prerequisite build and boot smoke only."); } else { console.log(`[verify:fast] scoped to: ${eligiblePackages.join(", ")}`); } + if (requiredBootBuildPackages.length > 0) { + console.log(`[verify:fast] boot-smoke prerequisite build: ${requiredBootBuildPackages.join(", ")}`); + } console.log(`[verify:fast] plan: ${steps.map((s) => s.id).join(" -> ")}`); for (const step of steps) {