diff --git a/.changeset/fast-tests-progress.md b/.changeset/fast-tests-progress.md new file mode 100644 index 0000000000..023a6ff780 --- /dev/null +++ b/.changeset/fast-tests-progress.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Keep Fusion verification progress moving by making targeted script tests honor file arguments, reaping verification subprocess groups after clean exits, and preventing the line-count audit from blocking `pnpm test`. diff --git a/package.json b/package.json index a7cae9f43f..bd0d16f998 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,9 @@ "type": "module", "packageManager": "pnpm@10.33.0", "scripts": { - "pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-file-line-count.mjs", - "pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-file-line-count.mjs", + "pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs", + "pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs", + "check:line-count": "node scripts/check-file-line-count.mjs", "test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && pnpm --filter @fusion/engine test:core && pnpm --filter @runfusion/fusion test:ci-shape", "smoke:boot": "node scripts/boot-smoke.mjs", "local": "node scripts/start-local.mjs", @@ -31,7 +32,7 @@ "build:exe": "pnpm build && pnpm --filter @runfusion/fusion build:exe", "build:exe:all": "pnpm build && pnpm --filter @runfusion/fusion build:exe:all", "test": "node scripts/test-changed.mjs", - "test:scripts": "node --test scripts/__tests__/*.test.mjs", + "test:scripts": "node scripts/run-script-tests.mjs", "test:workflow-release-check": "node scripts/workflow-reliability-release-check.mjs", "fn:cache-stats": "node scripts/cache-stats.mjs", "test:full": "node scripts/test-changed.mjs --full --no-cache && pnpm --filter @fusion/engine test:slow", diff --git a/packages/engine/src/__tests__/run-verification-command.test.ts b/packages/engine/src/__tests__/run-verification-command.test.ts index 2e2441e091..e08c43be9d 100644 --- a/packages/engine/src/__tests__/run-verification-command.test.ts +++ b/packages/engine/src/__tests__/run-verification-command.test.ts @@ -19,6 +19,19 @@ import { const onPosix = process.platform !== "win32"; const itPosix = onPosix ? it : it.skip; +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + /** * Tests for runVerificationCommand - the core verification execution logic. * These tests validate basic command execution, output capture, and error handling. @@ -368,6 +381,35 @@ describe("runVerificationCommand", { timeout: 30000 }, () => { expect(result.timedOut).toBe(true); expect(result.durationMs).toBeLessThan(5_000); }); + + itPosix("reaps background children after a command exits cleanly", async () => { + /* + * FNXC:Verification 2026-06-21-10:00: + * A clean shell exit is not enough evidence that verification is fully done; background children must be gone too or later task completion can stall behind leaked test workers. + */ + const childScript = "setInterval(() => {}, 1000)"; + const parentScript = [ + "const { spawn } = require('node:child_process');", + `const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`, + "console.log(child.pid);", + "child.unref();", + ].join(" "); + const result = await runVerificationCommand({ + command: `${process.execPath} -e ${JSON.stringify(parentScript)}`, + cwd: tempDir, + timeoutMs: 30_000, + onHeartbeat: vi.fn(), + }); + + expect(result.success).toBe(true); + const leakedPid = Number.parseInt(result.stdout.trim(), 10); + expect(Number.isFinite(leakedPid)).toBe(true); + + for (let i = 0; i < 15 && isProcessAlive(leakedPid); i++) { + await sleep(100); + } + expect(isProcessAlive(leakedPid)).toBe(false); + }); }); describe("output capture", () => { diff --git a/packages/engine/src/run-verification-tool.ts b/packages/engine/src/run-verification-tool.ts index 61fee13a59..c95757f577 100644 --- a/packages/engine/src/run-verification-tool.ts +++ b/packages/engine/src/run-verification-tool.ts @@ -32,6 +32,7 @@ import { executorLog } from "./logger.js"; const MAX_OUTPUT_BYTES = 200 * 1024; // 200 KB const QUIET_HEARTBEAT_INTERVAL_MS = 60_000; // emit synthetic heartbeat after 60s silence const SIGKILL_GRACE_MS = 10_000; +const NORMAL_EXIT_REAP_GRACE_MS = 500; export const DEFAULT_TIMEOUT_PACKAGE_SEC = 300; export const DEFAULT_TIMEOUT_WORKSPACE_SEC = 900; export const MAX_TIMEOUT_SEC = 1800; @@ -309,6 +310,19 @@ function killVerificationProcess(supervised: SupervisedChild, signal: NodeJS.Sig supervised.kill(signal); } +function reapVerificationProcessGroup(supervised: SupervisedChild): void { + /* + * FNXC:Verification 2026-06-21-10:00: + * Verification commands may spawn background test/dev children and then let the shell exit cleanly. + * Reap the process group after normal close so fn_run_verification does not report completion while orphaned test workers keep later task progress stuck. + */ + killVerificationProcess(supervised, "SIGTERM"); + const forceKillTimer = setTimeout(() => { + killVerificationProcess(supervised, "SIGKILL"); + }, NORMAL_EXIT_REAP_GRACE_MS); + forceKillTimer.unref?.(); +} + // --------------------------------------------------------------------------- // Tool parameter schema // --------------------------------------------------------------------------- @@ -562,6 +576,9 @@ export async function runVerificationCommand( `[fn_run_verification] command failed (exit=${exitCode}, signal=${signal ?? "none"}): ${command}`, ); } + if (!timedOut) { + reapVerificationProcessGroup(supervised); + } resolve({ success, diff --git a/scripts/__tests__/test-changed.test.mjs b/scripts/__tests__/test-changed.test.mjs index 27bc3e0ab2..300cf13cac 100644 --- a/scripts/__tests__/test-changed.test.mjs +++ b/scripts/__tests__/test-changed.test.mjs @@ -366,6 +366,56 @@ test("decideExecutionPlan: expands changed packages with reverse dependents", () assert.deepEqual(plan.packages, ["@fusion/core", "@fusion/engine", "@fusion/dashboard"]); }); +// FNXC:TestInfrastructure 2026-06-21-10:42: a foundational-package edit must NOT +// reverse-expand into a whole-workspace vitest sweep. Cap to the directly changed +// package and delegate reverse-dependent coverage to the merge-gate suite. +test("decideExecutionPlan: foundational-package edit reverse-blast is capped to direct packages", () => { + // 10-package workspace where @fusion/core is depended on by 8 others (>=60%). + const dependents = ["p1", "p2", "p3", "p4", "p5", "p6", "p7", "p8"]; + const reverseDependencyMap = new Map([ + ["@fusion/core", dependents], + ...dependents.map((d) => [d, []]), + ["@fusion/standalone", []], + ]); + const plan = decideExecutionPlan({ + forceFullSuite: false, + comparisonBase: "abc123", + changedFiles: ["packages/core/src/store.ts"], + packageNameByDir: basePackageMap, + reverseDependencyMap, + }); + + assert.equal(plan.mode, "changed"); + assert.equal(plan.reason, "reverse-dependent-blast-capped"); + assert.deepEqual(plan.packages, ["@fusion/core"]); +}); + +// A leaf-ish change with only a couple of dependents in a large workspace must +// still expand normally — the cap is for foundational blast, not any expansion. +test("decideExecutionPlan: narrow reverse-dependent expansion is NOT capped", () => { + const reverseDependencyMap = new Map([ + ["@fusion/engine", ["@fusion/dashboard"]], + ["@fusion/dashboard", []], + ["@fusion/core", []], + ["p1", []], + ["p2", []], + ["p3", []], + ["p4", []], + ["p5", []], + ]); + const plan = decideExecutionPlan({ + forceFullSuite: false, + comparisonBase: "abc123", + changedFiles: ["packages/engine/src/index.ts"], + packageNameByDir: basePackageMap, + reverseDependencyMap, + }); + + assert.equal(plan.mode, "changed"); + assert.equal(plan.reason, undefined); + assert.deepEqual(plan.packages, ["@fusion/engine", "@fusion/dashboard"]); +}); + test("decideExecutionPlan: no affected package resolved → gate", () => { const plan = decideExecutionPlan({ forceFullSuite: false, diff --git a/scripts/check-file-line-count.mjs b/scripts/check-file-line-count.mjs index 0c9c7228b2..6467637b11 100644 --- a/scripts/check-file-line-count.mjs +++ b/scripts/check-file-line-count.mjs @@ -27,6 +27,12 @@ governs hand-written source. // // Run `node scripts/check-file-line-count.mjs --update` to rewrite the baseline // after an intentional, reviewed change to the set of oversized files. +// +// FNXC:TestInfrastructure 2026-06-21-10:00: +// Line-count drift remains visible through the explicit check:line-count audit, +// but it must not block `pnpm test` from reaching the real test runner. The test +// preflight owns fast safety checks; broad god-file cleanup is tracked separately +// so unrelated task completion is not stuck before tests start. import { readFileSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { fileURLToPath, URL } from "node:url"; diff --git a/scripts/run-script-tests.mjs b/scripts/run-script-tests.mjs new file mode 100644 index 0000000000..b057234296 --- /dev/null +++ b/scripts/run-script-tests.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +import { globSync } from "node:fs"; +import { spawn } from "node:child_process"; + +/* +FNXC:TestInfrastructure 2026-06-21-10:00: +Script-test verification must honor forwarded file arguments so targeted checks stay fast inside Fusion tasks. +The old package script always expanded scripts/__tests__/*.test.mjs before forwarded args, turning `pnpm test:scripts -- scripts/__tests__/x.test.mjs` into the full script suite and making task completion look stalled. +*/ + +const forwarded = process.argv.slice(2).filter((arg) => arg !== "--"); +const testFiles = forwarded.length > 0 + ? forwarded + : globSync("scripts/__tests__/*.test.mjs").sort(); + +const child = spawn(process.execPath, ["--test", ...testFiles], { + stdio: "inherit", +}); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); + +child.on("error", (error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/test-changed.mjs b/scripts/test-changed.mjs index fc83a6d365..cd5705a129 100644 --- a/scripts/test-changed.mjs +++ b/scripts/test-changed.mjs @@ -1209,6 +1209,17 @@ const fullSuiteEnv = { FUSION_TEST_CONCURRENCY: process.env.FUSION_TEST_CONCURRENCY || String(concurrency), }; +/* +FNXC:TestInfrastructure 2026-06-21-10:42: +Reverse-dependent blast cap thresholds. A foundational-package edit (e.g. +@fusion/core) reverse-expands to ~the whole workspace; capping past 60% of a +workspace of at least 8 packages keeps a one-line core edit from triggering a +25-package vitest sweep, while leaving leaf-package expansion (a few dependents) +and small synthetic test fixtures untouched. +*/ +export const WIDE_REVERSE_DEPENDENT_FRACTION = 0.6; +export const MIN_WORKSPACE_FOR_BLAST_CAP = 8; + export function decideExecutionPlan({ forceFullSuite, comparisonBase, @@ -1228,12 +1239,34 @@ export function decideExecutionPlan({ const affectedPackages = resolveAffectedPackages(changedFiles, packageNameByDir); if (!affectedPackages || affectedPackages.length === 0) return { mode: "gate", reason: "no-affected-package" }; - return { - mode: "changed", - packages: reverseDependencyMap - ? expandWithReverseDependents(affectedPackages, reverseDependencyMap) - : affectedPackages, - }; + if (!reverseDependencyMap) return { mode: "changed", packages: affectedPackages }; + + const expanded = expandWithReverseDependents(affectedPackages, reverseDependencyMap); + + /* + FNXC:TestInfrastructure 2026-06-21-10:42: + Cap the reverse-dependent fan-out for foundational-package edits. A single + `@fusion/core` source change reverse-expands to ~the entire workspace (every + package imports core), so `pnpm test` bundled all 25 packages into one + `vitest --changed` invocation that ran for the full 20-min `changed`-class + watchdog ceiling and pinned the task (the engine runs project testCommand + "pnpm test" as its verification gate; on timeout the task fully restarts and + re-runs the sweep, stacking into hours). When expansion balloons past most of + a real (non-fixture) workspace, test only the DIRECTLY changed packages scoped + and delegate cross-cutting reverse-dependent coverage to the merge-gate suite, + which already runs first in changed mode and is the project's thin/trusted net. + Guarded by MIN_WORKSPACE_FOR_BLAST_CAP so tiny synthetic maps still expand fully. + */ + const totalPackages = reverseDependencyMap.size; + const expandedBeyondDirect = expanded.length > affectedPackages.length; + const isWideBlast = + totalPackages >= MIN_WORKSPACE_FOR_BLAST_CAP && + expanded.length >= Math.ceil(totalPackages * WIDE_REVERSE_DEPENDENT_FRACTION); + if (expandedBeyondDirect && isWideBlast) { + return { mode: "changed", packages: affectedPackages, reason: "reverse-dependent-blast-capped" }; + } + + return { mode: "changed", packages: expanded }; } /** @@ -1467,6 +1500,13 @@ export async function main(argv = process.argv.slice(2)) { label: "test:gate (pre-affected)", }); + if (plan.reason === "reverse-dependent-blast-capped") { + // FNXC:TestInfrastructure 2026-06-21-10:42: surface the cap so coverage is never silently dropped. + console.log( + "[test-changed] reverse-dependent fan-out capped: a foundational-package edit reverse-expanded to most of the workspace. " + + "Testing only the directly changed packages scoped; cross-cutting reverse-dependent coverage is delegated to the merge-gate suite (ran above).", + ); + } console.log(`[test-changed] running tests for changed packages: ${activePackages.join(", ")}`); if (cachedPackages.length > 0) { console.log(`[test-changed] skipping cached packages: ${cachedPackages.join(", ")}`);