diff --git a/docs/testing.md b/docs/testing.md index 98349ca953..e7144edec4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -97,8 +97,10 @@ not re-run `scripts/ensure-test-artifacts.mjs`. -When `scripts/test-changed.mjs` runs affected-package `vitest --changed` scopes, `@fusion/engine` and `@fusion/dashboard` are each split out from other scopable packages into their own dedicated memory-envelope run: `NODE_OPTIONS=--max-old-space-size=6144` plus `FUSION_TEST_TOTAL_WORKERS=1`, `FUSION_TEST_CONCURRENCY=1`, and `VITEST_MAX_WORKERS=1`. All other scopable packages remain in the shared non-envelope group, and packages without a Vitest config still fall back to their package `test` scripts. The envelopes preserve the `runWithWatchdog` changed-class wall-clock budget so the expected failure mode is a normal Vitest pass/fail or watchdog timeout, not raw pnpm `SIGKILL`. Re-measure with a wide changed selection (for example a dirty `packages/core/src/index.ts` boundary edit for engine, or an App/jsdom-affecting dashboard diff) before changing either envelope. +FNXC:TestInfrastructure 2026-06-21-16:28: FN-6877 applies the same changed-mode envelope to the dashboard scoped affected lane because FN-6874 showed App/jsdom changed runs could be OS-OOM-killed even with inbound test concurrency already set to 1. Keep the per-lane watchdog finite and outside the env; the envelope is a heap-pressure guard, not a hang-budget increase. + +FNXC:TestInfrastructure 2026-06-25-18:58: FN-7026 caps scoped affected watchdogs below the default 15min workspace verification timeout. A stale comparison base can make `vitest --changed` select thousands of live, git-heavy engine tests; the script watchdog must fail first with diagnostics instead of letting the executor kill root `pnpm test` and restart the whole sweep. --> +When `scripts/test-changed.mjs` runs affected-package `vitest --changed` scopes, `@fusion/engine` and `@fusion/dashboard` are each split out from other scopable packages into their own dedicated memory-envelope run: `NODE_OPTIONS=--max-old-space-size=6144` plus `FUSION_TEST_TOTAL_WORKERS=1`, `FUSION_TEST_CONCURRENCY=1`, and `VITEST_MAX_WORKERS=1`. All other scopable packages remain in the shared non-envelope group, and packages without a Vitest config still fall back to their package `test` scripts. The envelopes use a scoped affected watchdog ceiling below the default workspace verification timeout, so the expected failure mode is a normal Vitest pass/fail or script watchdog timeout, not raw pnpm `SIGKILL` or executor timeout restart. Re-measure with a wide changed selection (for example a dirty `packages/core/src/index.ts` boundary edit for engine, or an App/jsdom-affecting dashboard diff) before changing either envelope. For the heavy envelope packages this lane is additionally bounded against wide `vitest --changed` graph fan-out: when a changed non-test source file falls in the package's own dir or any transitive workspace-dependency dir, the affected lane runs only the directly-changed test file(s) and delegates the rest to the merge-gate suite, so a single hub edit can no longer expand into a near-full suite that exceeds the 15-min verification timeout. Pure test-file changes still run the normal `vitest --changed` scope. diff --git a/scripts/__tests__/test-changed.test.mjs b/scripts/__tests__/test-changed.test.mjs index 3b08603696..20de784a59 100644 --- a/scripts/__tests__/test-changed.test.mjs +++ b/scripts/__tests__/test-changed.test.mjs @@ -45,6 +45,8 @@ import { partitionScopedAffectedPackages, isTestFilePath, changedSourceFilesAffectingPackage, + deriveScopedAffectedBudgetMs, + SCOPED_AFFECTED_BUDGET_CEILING_MS, } from "../test-changed.mjs"; import { deriveBudgetMs } from "../lib/run-vitest-watchdog.mjs"; @@ -380,6 +382,18 @@ test("createEngineScopedAffectedEnv: preserves existing engine envelope contract assert.equal(budgetMs > 0, true); }); +test("deriveScopedAffectedBudgetMs: scoped affected lanes fail before workspace verification timeout", () => { + const workspaceVerificationTimeoutMs = 900_000; + + assert.equal(deriveBudgetMs({ klass: "changed" }), 1_200_000); + assert.equal(deriveScopedAffectedBudgetMs(), SCOPED_AFFECTED_BUDGET_CEILING_MS); + assert.equal(deriveScopedAffectedBudgetMs() < workspaceVerificationTimeoutMs, true); + assert.equal(deriveScopedAffectedBudgetMs() < deriveBudgetMs({ klass: "changed" }), true); + + const freshTightBudget = deriveScopedAffectedBudgetMs({ expectedDurationMs: 10_000, timingsFresh: true }); + assert.equal(freshTightBudget, deriveBudgetMs({ klass: "changed", expectedDurationMs: 10_000, timingsFresh: true })); +}); + // --------------------------------------------------------------------------- // decideExecutionPlan // --------------------------------------------------------------------------- @@ -476,6 +490,40 @@ test("decideExecutionPlan: only package files changed → changed mode", () => { assert.deepEqual(plan.packages, ["@fusion/engine"]); }); +test("decideExecutionPlan: dashboard test-only diff does not select engine scoped lane", () => { + const packageNameByDir = pkgMap([ + ["packages/dashboard", "@fusion/dashboard"], + ["packages/engine", "@fusion/engine"], + ["packages/cli", "@runfusion/fusion"], + ["packages/desktop", "@fusion/desktop"], + ["plugins/reports", "@fusion-plugin-examples/reports"], + ]); + const reverseDependencyMap = new Map([ + ["@fusion/dashboard", ["@runfusion/fusion", "@fusion/desktop", "@fusion-plugin-examples/reports"]], + ["@runfusion/fusion", []], + ["@fusion/desktop", []], + ["@fusion-plugin-examples/reports", []], + ["@fusion/engine", []], + ]); + + const plan = decideExecutionPlan({ + forceFullSuite: false, + comparisonBase: "abc123", + changedFiles: ["packages/dashboard/app/components/__tests__/SettingsModal.test.tsx"], + packageNameByDir, + reverseDependencyMap, + }); + + assert.equal(plan.mode, "changed"); + assert.deepEqual(plan.packages, [ + "@fusion/dashboard", + "@runfusion/fusion", + "@fusion/desktop", + "@fusion-plugin-examples/reports", + ]); + assert.equal(plan.packages.includes("@fusion/engine"), false); +}); + test("decideExecutionPlan: expands changed packages with reverse dependents", () => { const plan = decideExecutionPlan({ forceFullSuite: false, diff --git a/scripts/test-changed.mjs b/scripts/test-changed.mjs index 141d7007c6..c1bd6f1b63 100644 --- a/scripts/test-changed.mjs +++ b/scripts/test-changed.mjs @@ -15,6 +15,16 @@ import { deriveBudgetMs, runWithWatchdog } from "./lib/run-vitest-watchdog.mjs"; /** Generous local full-suite budget (60min): far above a real full run, far below an infinite hang. */ const FULL_SUITE_BUDGET_MS = 60 * 60 * 1000; +/* +FNXC:TestInfrastructure 2026-06-25-18:58: +Scoped affected memory-envelope lanes run inside Fusion's root `pnpm test` verification command, whose workspace default timeout is 15min. Keep their own watchdog below that outer timeout so a broad `vitest --changed` engine/dashboard lane fails with script diagnostics instead of being killed by the executor and restarted from the beginning. +*/ +export const SCOPED_AFFECTED_BUDGET_CEILING_MS = 14 * 60 * 1000; + +export function deriveScopedAffectedBudgetMs(options = {}) { + return Math.min(deriveBudgetMs({ klass: "changed", ...options }), SCOPED_AFFECTED_BUDGET_CEILING_MS); +} + const currentFilePath = fileURLToPath(import.meta.url); const scriptDir = path.dirname(currentFilePath); const checkIsolationScript = path.join(scriptDir, "check-test-isolation.mjs"); @@ -1745,10 +1755,10 @@ export async function main(argv = process.argv.slice(2)) { ? createScopedAffectedMemoryEnvelopeEnv(memoryEnvelopePackage, isolatedHomeEnv) : isolatedHomeEnv, onBeforeAfterCheck: cleanupIsolatedHome, - // Scoped runs are proportional to the diff, so the tight "changed" ceiling - // applies; a hang fails fast instead of blocking the 60-min full backstop. - // Full fallback runs keep the generous backstop. - budgetMs: mode === "scoped" ? deriveBudgetMs({ klass: "changed" }) : FULL_SUITE_BUDGET_MS, + // Scoped runs are proportional to the diff, so the scoped affected ceiling + // stays below the executor's default workspace verification timeout. Full + // fallback runs keep the generous backstop. + budgetMs: mode === "scoped" ? deriveScopedAffectedBudgetMs() : FULL_SUITE_BUDGET_MS, label: `affected (${mode}): ${packages.join(", ")}`, }); }