diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 7334449c25..0000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: CI - -# CI auto-trigger disabled per FN-1541 — workflow preserved for manual use via workflow_dispatch -on: - workflow_dispatch: - -# FN-4863: Opt JavaScript actions into Node 24 ahead of GitHub's forced cutover on 2026-06-02. -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node and install dependencies - uses: ./.github/actions/setup-node-pnpm - - - name: Lint - run: pnpm lint - - test-shards: - name: Test shard ${{ matrix.shard }}/3 - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3] - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node and install dependencies - uses: ./.github/actions/setup-node-pnpm - - - name: Test (deterministic shard) - run: pnpm test:ci:shard --shard ${{ matrix.shard }} --total 3 - - build: - name: Build - runs-on: ubuntu-latest - needs: [lint, test-shards] - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node and install dependencies - uses: ./.github/actions/setup-node-pnpm - - - name: Install Bun - uses: oven-sh/setup-bun@v2 - - - name: Build workspace - run: pnpm build - - - name: Run CLI slow lane (opt-in suites) - run: pnpm test:slow-cli - - - name: Build standalone binary - run: pnpm --filter @runfusion/fusion build:exe - - - name: Verify binary exists - run: test -f packages/cli/dist/fn diff --git a/.github/workflows/full-suite.yml b/.github/workflows/full-suite.yml new file mode 100644 index 0000000000..a207d4e0bc --- /dev/null +++ b/.github/workflows/full-suite.yml @@ -0,0 +1,167 @@ +name: Full Suite (non-blocking) + +# The demoted test tier (docs/plans/2026-06-04-001-refactor-fast-trusted-test-gate-plan.md). +# Runs the full sharded suite, the engine slow tier, and the dashboard +# inventory guard on every push to main — post-merge signal only. These jobs +# are NON-BLOCKING by design: they never run on PRs and must never be added +# to branch-protection required checks. A red run here is information, not a +# merge stopper; see docs/testing.md for the quarantine ratchet that keeps +# this tier honest. + +on: + push: + branches: [main] + +concurrency: + group: full-suite-${{ github.ref }} + cancel-in-progress: true + +# FN-4863: Opt JavaScript actions into Node 24 ahead of GitHub's forced cutover on 2026-06-02. +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + test-shards: + name: Test shard ${{ matrix.shard }}/4 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Engine tests run real git operations (merge-base against main, + # case-variant ref checks) that require full history. Shallow + # clones silently break tests like worktree-acquisition's resume + # misbinding path. + fetch-depth: 0 + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + + # Dist-artifact cache (L1): ensureTestArtifacts otherwise rebuilds dist/ + # for 8 packages (~71s) on every shard because CI starts with no dist. + # Key on a stable, pre-build, git-based hash of ALL build packages' source + # inputs. Exact-match only — NO restore-keys: a partial/stale dist hit is + # the exact failure mode this repo has been bitten by (FN-4232/FN-4605), + # and ensureTestArtifacts still validates/rebuilds anything missing-or-stale + # after restore, so a miss is safe but a wrong-content hit would not be. + # NEVER add node_modules here (breaks Windows pnpm junctions elsewhere). + - name: Compute dist source hash + id: dist-hash + run: echo "hash=$(node scripts/ensure-test-artifacts.mjs --print-source-hash)" >> "$GITHUB_OUTPUT" + + - name: Cache built dist artifacts + id: dist-cache + uses: actions/cache@v4 + with: + path: | + packages/core/dist + packages/dashboard/dist + packages/engine/dist + packages/plugin-sdk/dist + plugins/fusion-plugin-dependency-graph/dist + plugins/fusion-plugin-hermes-runtime/dist + plugins/fusion-plugin-openclaw-runtime/dist + plugins/fusion-plugin-paperclip-runtime/dist + key: dist-${{ runner.os }}-${{ steps.dist-hash.outputs.hash }} + + # On a cache HIT, restored dist files carry their save-time mtimes while + # checkout rewrites src mtimes to "now" (src newer than dist), which would + # make ensureTestArtifacts' mtime fallback rebuild everything and defeat + # the cache. Seed the per-package content-hash cache so its content-hash + # short-circuit fires instead. ensureTestArtifacts still runs (inside + # test:ci:shard) and rebuilds anything genuinely missing/changed. + - name: Seed artifact hash-cache on cache hit + if: steps.dist-cache.outputs.cache-hit == 'true' + run: node scripts/ensure-test-artifacts.mjs --seed-artifact-cache + + - name: Test (deterministic shard) + run: pnpm test:ci:shard --shard ${{ matrix.shard }} --total 4 + + # Each shard emits per-file vitest JSON timing reporter output under + # .timings/. Upload as an artifact so the timing snapshot can be + # refreshed locally/from the default branch via + # `node scripts/ci-test-shard.mjs --write-timings`. We do NOT commit the + # snapshot automatically — refresh is manual/scheduled only. + - name: Upload per-shard test timings + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-timings-shard-${{ matrix.shard }} + # Relative outputFile paths mean each package writes its own + # /.timings/ file — glob the whole tree, not just the root. + path: | + .timings/timings-*.json + packages/*/.timings/timings-*.json + plugins/*/.timings/timings-*.json + plugins/examples/*/.timings/timings-*.json + if-no-files-found: ignore + retention-days: 14 + + # The dashboard quality gate used to enumerate its test files by hand, so any + # unenumerated app/ or src/ test file ran in NO project. This guard fails when + # a dashboard test file is neither executed by a quality project (curated + + # backfill lanes) nor on the reviewed skip-list. Cheap: it only runs + # `vitest list`, not the tests. + test-inventory-guard: + name: Dashboard curated-gate guard + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + + # Same dist-artifact cache as test-shards (L1): the curated-gate guard runs + # `vitest list`, whose config resolution can touch built dist, so it also + # pays the cold-dist rebuild. Exact-match key on the pre-build source hash; + # NO restore-keys (stale dist is the failure mode), NO node_modules. + - name: Compute dist source hash + id: dist-hash + run: echo "hash=$(node scripts/ensure-test-artifacts.mjs --print-source-hash)" >> "$GITHUB_OUTPUT" + + - name: Cache built dist artifacts + id: dist-cache + uses: actions/cache@v4 + with: + path: | + packages/core/dist + packages/dashboard/dist + packages/engine/dist + packages/plugin-sdk/dist + plugins/fusion-plugin-dependency-graph/dist + plugins/fusion-plugin-hermes-runtime/dist + plugins/fusion-plugin-openclaw-runtime/dist + plugins/fusion-plugin-paperclip-runtime/dist + key: dist-${{ runner.os }}-${{ steps.dist-hash.outputs.hash }} + + - name: Seed artifact hash-cache on cache hit + if: steps.dist-cache.outputs.cache-hit == 'true' + run: node scripts/ensure-test-artifacts.mjs --seed-artifact-cache + + - name: Assert every dashboard test file is gated or skip-listed + run: node scripts/check-test-inventory.mjs --dashboard-curated + + # The engine-slow tier (src/**/*.slow.test.ts) runs here with a non-empty + # execution assertion, so a glob/config drift that silently empties the tier + # fails this workflow instead of passing vacuously. Engine slow tests do real + # git operations, so a full clone (fetch-depth: 0) is required. + test-slow: + name: Engine slow tier + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + + - name: Run engine-slow with non-empty-execution assertion + run: node scripts/assert-engine-slow-nonempty.mjs diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 4468206a2a..fb8321ca39 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -1,12 +1,21 @@ name: PR Checks +# The thin trusted merge gate (docs/plans/2026-06-04-001-refactor-fast-trusted-test-gate-plan.md). +# Blocking checks are exactly: Lint, Typecheck, Build, Gate. +# +# BRANCH-PROTECTION CUTOVER: required status checks are matched by job name. +# When this file changes job names, update the repo's branch-protection +# required checks to exactly [Lint, Typecheck, Build, Gate] — a stale required +# name (e.g. "Test shard 1/4") that no longer reports will block every PR +# with "Expected — waiting for status". Open PRs must rebase onto main after +# the cutover so they run this workflow shape. +# +# Everything that used to run here as shards / slow tier / inventory guard is +# non-blocking and lives in full-suite.yml (push to main). + on: pull_request: branches: [main] - # Also run on every push to main so post-merge regressions surface - # immediately instead of being discovered on the next PR. - push: - branches: [main] concurrency: group: pr-checks-${{ github.ref }} @@ -62,94 +71,13 @@ jobs: - name: Build run: pnpm build - test-shards: - name: Test shard ${{ matrix.shard }}/4 - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3, 4] - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - # Engine tests run real git operations (merge-base against main, - # case-variant ref checks) that require full history. Shallow - # clones silently break tests like worktree-acquisition's resume - # misbinding path. - fetch-depth: 0 - - - name: Setup Node.js and pnpm - uses: ./.github/actions/setup-node-pnpm - - # Dist-artifact cache (L1): ensureTestArtifacts otherwise rebuilds dist/ - # for 8 packages (~71s) on every shard because CI starts with no dist. - # Key on a stable, pre-build, git-based hash of ALL build packages' source - # inputs. Exact-match only — NO restore-keys: a partial/stale dist hit is - # the exact failure mode this repo has been bitten by (FN-4232/FN-4605), - # and ensureTestArtifacts still validates/rebuilds anything missing-or-stale - # after restore, so a miss is safe but a wrong-content hit would not be. - # NEVER add node_modules here (breaks Windows pnpm junctions elsewhere). - - name: Compute dist source hash - id: dist-hash - run: echo "hash=$(node scripts/ensure-test-artifacts.mjs --print-source-hash)" >> "$GITHUB_OUTPUT" - - - name: Cache built dist artifacts - id: dist-cache - uses: actions/cache@v4 - with: - path: | - packages/core/dist - packages/dashboard/dist - packages/engine/dist - packages/plugin-sdk/dist - plugins/fusion-plugin-dependency-graph/dist - plugins/fusion-plugin-hermes-runtime/dist - plugins/fusion-plugin-openclaw-runtime/dist - plugins/fusion-plugin-paperclip-runtime/dist - key: dist-${{ runner.os }}-${{ steps.dist-hash.outputs.hash }} - - # On a cache HIT, restored dist files carry their save-time mtimes while - # checkout rewrites src mtimes to "now" (src newer than dist), which would - # make ensureTestArtifacts' mtime fallback rebuild everything and defeat - # the cache. Seed the per-package content-hash cache so its content-hash - # short-circuit fires instead. ensureTestArtifacts still runs (inside - # test:ci:shard) and rebuilds anything genuinely missing/changed. - - name: Seed artifact hash-cache on cache hit - if: steps.dist-cache.outputs.cache-hit == 'true' - run: node scripts/ensure-test-artifacts.mjs --seed-artifact-cache - - - name: Test (deterministic shard) - run: pnpm test:ci:shard --shard ${{ matrix.shard }} --total 4 - - # U1 (R4): each shard emits per-file vitest JSON timing reporter output - # under .timings/. Upload as an artifact so the timing snapshot can be - # refreshed locally/from the default branch via - # `node scripts/ci-test-shard.mjs --write-timings`. We do NOT commit the - # snapshot from PR branches — refresh is manual/scheduled only. - - name: Upload per-shard test timings - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-timings-shard-${{ matrix.shard }} - # Relative outputFile paths mean each package writes its own - # /.timings/ file — glob the whole tree, not just the root. - path: | - .timings/timings-*.json - packages/*/.timings/timings-*.json - plugins/*/.timings/timings-*.json - plugins/examples/*/.timings/timings-*.json - if-no-files-found: ignore - retention-days: 14 - - # Plan U2 / R7: the dashboard quality gate used to enumerate its test files by - # hand, so any unenumerated app/ or src/ test file ran in NO project. This - # guard fails when a dashboard test file is neither executed by a quality - # project (curated + backfill lanes) nor on the reviewed skip-list. Cheap: - # it only runs `vitest list`, not the tests. - test-inventory-guard: - name: Dashboard curated-gate guard + # The only merge-blocking TEST signal (R3). Runs the boot smoke (the app + # starts and serves) plus the curated engine-core suite and the CI-shape + # test — see `test:gate` in the root package.json. Gate membership is the + # explicit allow-list in packages/engine/vitest.config.ts (engine-core + # project); a flaky gate test is evicted by removing it from that list. + gate: + name: Gate runs-on: ubuntu-latest steps: - name: Checkout @@ -158,10 +86,12 @@ jobs: - name: Setup Node.js and pnpm uses: ./.github/actions/setup-node-pnpm - # Same dist-artifact cache as test-shards (L1): the curated-gate guard runs - # `vitest list`, whose config resolution can touch built dist, so it also - # pays the cold-dist rebuild. Exact-match key on the pre-build source hash; - # NO restore-keys (stale dist is the failure mode), NO node_modules. + - name: Install Bun + uses: oven-sh/setup-bun@v2 + + # Dist-artifact cache (same contract as full-suite.yml): exact-match + # key only, NO restore-keys (stale dist is the known failure mode, + # FN-4232/FN-4605), NEVER node_modules (breaks Windows pnpm junctions). - name: Compute dist source hash id: dist-hash run: echo "hash=$(node scripts/ensure-test-artifacts.mjs --print-source-hash)" >> "$GITHUB_OUTPUT" @@ -185,25 +115,13 @@ jobs: if: steps.dist-cache.outputs.cache-hit == 'true' run: node scripts/ensure-test-artifacts.mjs --seed-artifact-cache - - name: Assert every dashboard test file is gated or skip-listed - run: node scripts/check-test-inventory.mjs --dashboard-curated + # Boot smoke needs the full built workspace (CLI dist is not in the + # cache list above); cached packages make this incremental-fast. + - name: Build + run: pnpm build - # Plan U2 / R8: the engine-slow tier (src/**/*.slow.test.ts) previously ran in - # NO automated gate — only via the local `test:full`. This job runs it and - # asserts a non-empty execution, so a glob/config drift that silently empties - # the tier fails CI instead of passing vacuously. Engine slow tests do real - # git operations, so a full clone (fetch-depth: 0) is required. - test-slow: - name: Engine slow tier - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - name: Boot smoke (app starts and serves) + run: node scripts/boot-smoke.mjs - - name: Setup Node.js and pnpm - uses: ./.github/actions/setup-node-pnpm - - - name: Run engine-slow with non-empty-execution assertion - run: node scripts/assert-engine-slow-nonempty.mjs + - name: Gate tests (curated engine-core + CI-shape) + run: pnpm test:gate diff --git a/docs/contributing.md b/docs/contributing.md index 78c21adf59..f97cec31a2 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -63,11 +63,13 @@ FUSION_DEV_PREBUILD=full pnpm dev dashboard # production-like full workspace pr pnpm dev:ui # dashboard dev server only pnpm dev:hmr # dashboard API + Vite HMR UI, with no startup prebuild pnpm lint # lint all packages -pnpm test # changed-only workspace tests (falls back to full suite in safety contexts) -pnpm test:full # full workspace quality gate (clean-worktree compatible) +pnpm test # merge-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 test:full # full workspace suite (explicit opt-in; clean-worktree compatible) pnpm build # workspace builds (excludes desktop/mobile) pnpm build:all # full workspace build (includes desktop/mobile) -pnpm verify:workspace # canonical lint -> test -> build verification gate +pnpm verify:workspace # deep opt-in verification: lint -> test:full -> build pnpm typecheck # workspace typechecks ``` @@ -80,20 +82,21 @@ Fusion codifies workspace verification as a deterministic contract: - Root test entrypoints (`pnpm test` via `scripts/test-changed.mjs` and `pnpm test:ci:shard` via `scripts/ci-test-shard.mjs`) call `scripts/ensure-test-artifacts.mjs`, which deterministically builds only missing/stale required workspace dist artifacts (`@fusion/core`, `@fusion/dashboard`, `@fusion/engine`, `@fusion/plugin-sdk`, and `@fusion-plugin-examples/{dependency-graph,hermes-runtime,openclaw-runtime,paperclip-runtime}`). - Package-scoped hooks now mirror this bootstrap for fresh worktrees where needed: `@fusion/dashboard` and `@fusion-plugin-examples/dependency-graph` run `pretest: node ../../scripts/ensure-test-artifacts.mjs`. - This includes clean states where those required dist directories are absent. -- `pnpm verify:workspace` is the canonical pre-merge gate and runs in strict order: +- `pnpm test:gate` is the merge gate: the curated `engine-core` suite plus the CI-shape test. CI blocks PRs on exactly Lint, Typecheck, Build, and Gate (boot smoke + `pnpm test:gate`) — see `.github/workflows/pr-checks.yml` and docs/testing.md. +- `pnpm verify:workspace` is the deep opt-in verification (not the merge gate) and runs in strict order: 1. `pnpm lint` 2. `pnpm test:full` 3. `pnpm build` -GitHub Actions now runs deterministic test sharding via `pnpm test:ci:shard --shard --total ` in both PR checks and manual CI, while keeping local semantics unchanged: +GitHub Actions runs deterministic test sharding via `pnpm test:ci:shard --shard --total ` in the non-blocking `full-suite.yml` workflow (push to main only — never a PR gate), while keeping local semantics unchanged: -- `pnpm test` remains changed-only local iteration. -- `pnpm test:full` remains the canonical workspace quality gate; dashboard exhaustive coverage is explicit via `pnpm --filter @fusion/dashboard test:deep`. -- `pnpm verify:workspace` remains the canonical local lint -> test -> build gate. +- `pnpm test` remains gate + changed-only local iteration. +- `pnpm test:full` remains the explicit full workspace suite; dashboard exhaustive coverage is explicit via `pnpm --filter @fusion/dashboard test:deep`. +- `pnpm verify:workspace` remains the deep opt-in lint -> test -> build verification. `test:ci:shard` is a CI-focused entrypoint (`scripts/ci-test-shard.mjs`) that deterministically balances workspace packages with `test` scripts by counting package-local `**/__tests__/**/*.test.{ts,tsx,mjs}` files, auto-splitting oversized packages into virtual shard entries (`{ name, shardIndex, shardCount }`), then assigning entries in descending weight order with best-fit placement for unsplit entries (closest under-budget fit, otherwise minimum overshoot) while keeping slices of the same package on different shards when possible. Whole entries run as grouped `pnpm --filter test` calls, and virtual entries run one-by-one via `pnpm --filter test -- --shard /`. This keeps coverage reproducible while improving shard balance. -`pnpm test` now uses a changed-only entrypoint (`scripts/test-changed.mjs`) for faster local iteration. It resolves the comparison base from `.changeset/config.json` (`baseBranch`) and runs only affected workspaces from `pnpm-workspace.yaml` (both `packages/*` and `plugins/**`) using safe package-first filtering (`pnpm --filter test`). It automatically falls back to the full suite when the run is forced (CI / `--full`), the git comparison base or diff cannot be resolved, no changes are detected, shared/root test infrastructure changes, or changed workspace paths cannot be resolved to a workspace package (fail-safe coverage behavior). +`pnpm test` now uses a changed-only entrypoint (`scripts/test-changed.mjs`) for faster local iteration. It resolves the comparison base from `.changeset/config.json` (`baseBranch`) and runs only affected workspaces from `pnpm-workspace.yaml` (both `packages/*` and `plugins/**`) using safe package-first filtering (`pnpm --filter test`). It runs the merge-gate suite first, then the affected set. The full suite runs only on explicit opt-in (`--full` / `pnpm test:full`); shared-infrastructure changes and unresolvable diffs widen the affected set but never escalate to an implicit full-suite run (the old escalation was the local OOM path). Root test entrypoints (`pnpm test`, `pnpm test:full`, and `pnpm test:ci:shard`) now use a shared CPU-aware default worker budget instead of fixed low values. By default, Fusion sets `FUSION_TEST_TOTAL_WORKERS` to `max(4, min(12, cpuCount - 1))` and `FUSION_TEST_CONCURRENCY` to `2` (clamped to the total budget), while still honoring explicit overrides from `VITEST_MAX_WORKERS`, `FUSION_TEST_TOTAL_WORKERS`, and `FUSION_TEST_CONCURRENCY`. @@ -113,7 +116,8 @@ If you add or change test entrypoints, keep this isolation guard path intact and Before submitting changes, verify: -- [ ] `pnpm verify:workspace` — canonical lint → test → build gate +- [ ] `pnpm test:gate` — the merge gate (curated engine-core suite + CI-shape test) +- [ ] `pnpm verify:workspace` — deep opt-in lint → test:full → build verification - [ ] `pnpm typecheck` — type checking passes ## Realtime/SSE change note @@ -160,7 +164,7 @@ pnpm build:exe:all # build multi-target executables Default workspace verification stays lean and deterministic: - `pnpm test` runs the standard suite and does **not** require Bun cross-build integration tests. -- `pnpm verify:workspace` remains the canonical `lint -> test -> build` gate. +- `pnpm verify:workspace` remains the deep opt-in `lint -> test -> build` verification. Slow/pre-release CLI coverage is explicit and opt-in: diff --git a/packages/cli/src/__tests__/ci-workflow.test.ts b/packages/cli/src/__tests__/ci-workflow.test.ts index 236b6fa9e5..3fa71994b9 100644 --- a/packages/cli/src/__tests__/ci-workflow.test.ts +++ b/packages/cli/src/__tests__/ci-workflow.test.ts @@ -27,12 +27,10 @@ function findCompositeSetupStep(steps: any[]) { return steps.find((step) => step.uses === "./.github/actions/setup-node-pnpm"); } -describe("CI workflow (.github/workflows/ci.yml)", () => { +describe("Merge gate (.github/workflows/pr-checks.yml)", () => { let workflow: any; let content: string; let compositeAction: any; - let buildSteps: any[]; - let testShardJob: any; let contributingContent: string; let readmeContent: string; let cliPackageJsonContent: string; @@ -41,12 +39,10 @@ describe("CI workflow (.github/workflows/ci.yml)", () => { let buildExeSuiteContent: string; beforeAll(() => { - const result = loadWorkflow("ci.yml"); + const result = loadWorkflow("pr-checks.yml"); workflow = result.parsed; content = result.content; compositeAction = loadYamlFile(".github", "actions", "setup-node-pnpm", "action.yml").parsed; - buildSteps = workflow.jobs?.build?.steps ?? []; - testShardJob = workflow.jobs?.["test-shards"]; contributingContent = readFileSync(join(workspaceRoot, "docs", "contributing.md"), "utf-8"); readmeContent = readFileSync(join(workspaceRoot, "README.md"), "utf-8"); cliPackageJsonContent = readFileSync(join(workspaceRoot, "packages", "cli", "package.json"), "utf-8"); @@ -64,134 +60,56 @@ describe("CI workflow (.github/workflows/ci.yml)", () => { ); }); - const findBuildStepByRun = (runSnippet: string) => - buildSteps.find((step) => typeof step.run === "string" && step.run.includes(runSnippet)); - it("is valid YAML", () => { expect(workflow).toBeDefined(); expect(typeof workflow).toBe("object"); }); - it("uses workflow_dispatch trigger (auto CI disabled)", () => { - expect(workflow.on).toHaveProperty("workflow_dispatch"); + it("runs on pull requests targeting main and ONLY there", () => { + expect(workflow.on?.pull_request?.branches).toContain("main"); + // Post-merge signal lives in full-suite.yml; the gate workflow must not + // double-run on push (that conflates blocking and non-blocking surfaces). + expect(workflow.on?.push).toBeUndefined(); }); - it("does not auto-trigger on push/pull_request", () => { - expect(workflow.on.push).toBeUndefined(); - expect(workflow.on.pull_request).toBeUndefined(); + it("blocks PRs on exactly lint, typecheck, build, and gate", () => { + expect(Object.keys(workflow.jobs ?? {}).sort()).toEqual(["build", "gate", "lint", "typecheck"]); }); - it("pins dependency bootstrap to frozen lockfile", () => { - const jobs = [workflow.jobs?.lint, workflow.jobs?.["test-shards"], workflow.jobs?.build]; - for (const job of jobs) { - expect(findCompositeSetupStep(job?.steps ?? [])).toBeDefined(); + it("contains no shard matrix or full-suite invocation (demoted to full-suite.yml)", () => { + expect(workflow.jobs?.["test-shards"]).toBeUndefined(); + expect(workflow.jobs?.["test-slow"]).toBeUndefined(); + expect(workflow.jobs?.["test-inventory-guard"]).toBeUndefined(); + expect(content).not.toContain("test:ci:shard"); + expect(content).not.toContain("run: pnpm test\n"); + expect(content).not.toContain("pnpm verify:workspace"); + }); + + it("gate job runs boot smoke and the dedicated test:gate command", () => { + const gateSteps = workflow.jobs?.gate?.steps ?? []; + expect( + gateSteps.some( + (step: any) => typeof step.run === "string" && step.run.includes("node scripts/boot-smoke.mjs"), + ), + ).toBe(true); + // The gate must use the dedicated command — `pnpm test` routes through + // scripts/test-changed.mjs whose selection semantics are for local runs. + expect( + gateSteps.some( + (step: any) => typeof step.run === "string" && step.run.includes("pnpm test:gate"), + ), + ).toBe(true); + }); + + it("pins dependency bootstrap to frozen lockfile in every job", () => { + for (const jobName of ["lint", "typecheck", "build", "gate"]) { + expect(findCompositeSetupStep(workflow.jobs?.[jobName]?.steps ?? [])).toBeDefined(); } expect(content).not.toContain("run: pnpm install\n"); expect(content).not.toContain("--no-frozen-lockfile"); expect(compositeAction.inputs?.["install-args"]?.default).toBe("--frozen-lockfile"); }); - it("uses deterministic test sharding and keeps lint/build as explicit jobs", () => { - expect(workflow.jobs?.lint).toBeDefined(); - expect(testShardJob).toBeDefined(); - expect(workflow.jobs?.build).toBeDefined(); - - expect(testShardJob.strategy?.matrix?.shard).toEqual([1, 2, 3]); - expect(content).toContain("pnpm test:ci:shard --shard ${{ matrix.shard }} --total 3"); - expect(content).not.toContain("pnpm verify:workspace"); - }); - - it("runs build job after lint and sharded tests, then executes slow lane and binary packaging", () => { - expect(workflow.jobs?.build?.needs).toEqual(["lint", "test-shards"]); - expect(findBuildStepByRun("pnpm build")).toBeDefined(); - expect(findBuildStepByRun("pnpm test:slow-cli")).toBeDefined(); - expect(findBuildStepByRun("build:exe")).toBeDefined(); - }); - - it("keeps contributing docs aligned with verification and slow-lane contracts", () => { - expect(contributingContent).toContain("pnpm test:full` must be runnable in a clean worktree without requiring a prior `pnpm build`."); - expect(contributingContent).toContain("`pnpm verify:workspace` is the canonical pre-merge gate"); - expect(contributingContent).toContain("1. `pnpm lint`"); - expect(contributingContent).toContain("2. `pnpm test:full`"); - expect(contributingContent).toContain("3. `pnpm build`"); - expect(contributingContent).toContain("`pnpm test` now uses a changed-only entrypoint"); - - expect(contributingContent).toContain("pnpm test:slow-cli"); - expect(contributingContent).toContain("test:pre-release"); - expect(contributingContent).toContain("test:extension-integration"); - }); - - it("keeps docs aligned with default and explicit build commands", () => { - expect(readmeContent).toContain("pnpm build # Build default workspace packages (excludes desktop/mobile)"); - expect(readmeContent).toContain("pnpm build:all # Build all packages (including desktop/mobile)"); - - expect(contributingContent).toContain("pnpm build # default build (excludes desktop/mobile)"); - expect(contributingContent).toContain("pnpm build:all # full recursive build including desktop/mobile"); - }); - - it("includes binary build step", () => { - expect(content).toContain("build:exe"); - }); - - it("keeps explicit gating for audited CLI integration suites", () => { - expect(cliPackageJsonContent).toContain('"test:slow-cli"'); - expect(cliPackageJsonContent).toContain("FUSION_TEST_SLOW_CLI=1"); - expect(cliPackageJsonContent).toContain('"test:extension-integration"'); - expect(cliPackageJsonContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION=1"); - expect(cliPackageJsonContent).toContain("extension-integration.test.ts"); - expect(cliPackageJsonContent).toContain('"test:build-exe"'); - expect(cliPackageJsonContent).toContain("FUSION_TEST_BUILD_EXE=1"); - - expect(extensionSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)"); - expect(extensionSuiteContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION"); - expect(extensionSuiteContent).toContain("dist/extension.js"); - - expect(agentExportSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_SLOW_CLI)"); - expect(agentExportSuiteContent).toContain("FUSION_TEST_SLOW_CLI"); - - expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "1"'); - expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "true"'); - expect(buildExeSuiteContent).not.toContain("Boolean(process.env.FUSION_TEST_BUILD_EXE)"); - }); - - it("includes Bun setup", () => { - expect(content).toContain("oven-sh/setup-bun"); - }); - - it("verifies binary exists after build", () => { - expect(content).toContain("test -f packages/cli/dist/fn"); - }); -}); - -describe("PR checks workflow (.github/workflows/pr-checks.yml)", () => { - let workflow: any; - let content: string; - - beforeAll(() => { - const result = loadWorkflow("pr-checks.yml"); - workflow = result.parsed; - content = result.content; - }); - - it("is valid YAML", () => { - expect(workflow).toBeDefined(); - expect(typeof workflow).toBe("object"); - }); - - it("runs on pull requests targeting main", () => { - expect(workflow.on?.pull_request?.branches).toContain("main"); - }); - - it("uses the same deterministic test sharding command as manual CI", () => { - expect(workflow.jobs?.lint).toBeDefined(); - expect(workflow.jobs?.typecheck).toBeDefined(); - expect(workflow.jobs?.build).toBeDefined(); - expect(workflow.jobs?.["test-shards"]).toBeDefined(); - expect(workflow.jobs?.["test-shards"]?.strategy?.matrix?.shard).toEqual([1, 2, 3, 4]); - expect(content).toContain("pnpm test:ci:shard --shard ${{ matrix.shard }} --total 4"); - expect(content).not.toContain("run: pnpm test\n"); - }); - it("keeps lint as install + lint only, without Bun/setup build coupling", () => { const lintSteps = workflow.jobs?.lint?.steps ?? []; expect( @@ -229,7 +147,99 @@ describe("PR checks workflow (.github/workflows/pr-checks.yml)", () => { ).toBe(true); }); - it("does not spend PR action minutes on a pre-test workspace build", () => { + it("keeps contributing docs aligned with the gate contract", () => { + expect(contributingContent).toContain("pnpm test:full` must be runnable in a clean worktree without requiring a prior `pnpm build`."); + expect(contributingContent).toContain("`pnpm test:gate` is the merge gate"); + expect(contributingContent).toContain("`pnpm verify:workspace` is the deep opt-in verification (not the merge gate)"); + expect(contributingContent).toContain("1. `pnpm lint`"); + expect(contributingContent).toContain("2. `pnpm test:full`"); + expect(contributingContent).toContain("3. `pnpm build`"); + expect(contributingContent).toContain("`pnpm test` now uses a changed-only entrypoint"); + + expect(contributingContent).toContain("pnpm test:slow-cli"); + expect(contributingContent).toContain("test:pre-release"); + expect(contributingContent).toContain("test:extension-integration"); + }); + + it("keeps docs aligned with default and explicit build commands", () => { + expect(readmeContent).toContain("pnpm build # Build default workspace packages (excludes desktop/mobile)"); + expect(readmeContent).toContain("pnpm build:all # Build all packages (including desktop/mobile)"); + + expect(contributingContent).toContain("pnpm build # default build (excludes desktop/mobile)"); + expect(contributingContent).toContain("pnpm build:all # full recursive build including desktop/mobile"); + }); + + it("keeps explicit gating for audited CLI integration suites", () => { + expect(cliPackageJsonContent).toContain('"test:slow-cli"'); + expect(cliPackageJsonContent).toContain("FUSION_TEST_SLOW_CLI=1"); + expect(cliPackageJsonContent).toContain('"test:extension-integration"'); + expect(cliPackageJsonContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION=1"); + expect(cliPackageJsonContent).toContain("extension-integration.test.ts"); + expect(cliPackageJsonContent).toContain('"test:build-exe"'); + expect(cliPackageJsonContent).toContain("FUSION_TEST_BUILD_EXE=1"); + + expect(extensionSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)"); + expect(extensionSuiteContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION"); + expect(extensionSuiteContent).toContain("dist/extension.js"); + + expect(agentExportSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_SLOW_CLI)"); + expect(agentExportSuiteContent).toContain("FUSION_TEST_SLOW_CLI"); + + expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "1"'); + expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "true"'); + expect(buildExeSuiteContent).not.toContain("Boolean(process.env.FUSION_TEST_BUILD_EXE)"); + }); + + it("the deleted manual CI workflow stays deleted", () => { + // ci.yml was the trigger-disabled (FN-1541) 3-shard manual workflow; the + // merge-gate redesign removed it. Reintroducing it would resurrect a + // second, drift-prone definition of the test pipeline. + expect(() => loadWorkflow("ci.yml")).toThrow(); + }); +}); + +describe("Full suite workflow (.github/workflows/full-suite.yml)", () => { + let workflow: any; + let content: string; + + beforeAll(() => { + const result = loadWorkflow("full-suite.yml"); + workflow = result.parsed; + content = result.content; + }); + + it("is valid YAML", () => { + expect(workflow).toBeDefined(); + expect(typeof workflow).toBe("object"); + }); + + it("runs ONLY on push to main — never as a PR gate", () => { + expect(workflow.on?.push?.branches).toEqual(["main"]); + expect(workflow.on?.pull_request).toBeUndefined(); + }); + + it("carries the demoted tier: 4-way shards, engine slow, inventory guard", () => { + expect(workflow.jobs?.["test-shards"]?.strategy?.matrix?.shard).toEqual([1, 2, 3, 4]); + expect(content).toContain("pnpm test:ci:shard --shard ${{ matrix.shard }} --total 4"); + expect(workflow.jobs?.["test-slow"]).toBeDefined(); + expect(workflow.jobs?.["test-inventory-guard"]).toBeDefined(); + }); + + it("keeps full clones where real-git tests need history", () => { + const shardSteps = workflow.jobs?.["test-shards"]?.steps ?? []; + const slowSteps = workflow.jobs?.["test-slow"]?.steps ?? []; + for (const steps of [shardSteps, slowSteps]) { + expect( + steps.some((step: any) => step.uses?.includes("actions/checkout") && step.with?.["fetch-depth"] === 0), + ).toBe(true); + } + }); + + it("still uploads per-shard timing artifacts for snapshot refresh", () => { + expect(content).toContain("test-timings-shard-${{ matrix.shard }}"); + }); + + it("does not spend action minutes on a pre-test workspace build", () => { const testSteps = workflow.jobs?.["test-shards"]?.steps ?? []; expect( testSteps.some( diff --git a/packages/cli/src/__tests__/package-config.test.ts b/packages/cli/src/__tests__/package-config.test.ts index 9c6ed4b801..3d34449432 100644 --- a/packages/cli/src/__tests__/package-config.test.ts +++ b/packages/cli/src/__tests__/package-config.test.ts @@ -299,10 +299,16 @@ describe("Workspace bootstrap script contract", () => { }); describe("Workflow YAML validity", () => { - it("ci.yml is valid YAML", () => { - const parsed = loadWorkflowYaml("ci.yml"); + it("pr-checks.yml is valid YAML", () => { + const parsed = loadWorkflowYaml("pr-checks.yml"); expect(parsed).toBeDefined(); - expect(parsed.name).toBe("CI"); + expect(parsed.name).toBe("PR Checks"); + }); + + it("full-suite.yml is valid YAML", () => { + const parsed = loadWorkflowYaml("full-suite.yml"); + expect(parsed).toBeDefined(); + expect(parsed.name).toBe("Full Suite (non-blocking)"); }); it("version.yml is valid YAML", () => {