FN-7026: cap scoped affected test watchdogs

Keep changed-only affected test lanes from outliving the workspace verification timeout.

- Add a scoped affected watchdog ceiling below the default executor timeout.
- Use the ceiling for scoped dashboard and engine affected Vitest lanes while leaving full fallback budget unchanged.
- Cover the timeout derivation and dashboard-only package selection in script tests.
- Document the changed-only watchdog behavior for future test infrastructure changes.

Files changed:
 docs/testing.md                         |  6 +++--
 scripts/__tests__/test-changed.test.mjs | 48 +++++++++++++++++++++++++++++++++
 scripts/test-changed.mjs                | 18 ++++++++++---
 3 files changed, 66 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-7026

Fusion-Task-Lineage: 774519e0-505b-4e40-8309-5d2250da1eba
This commit is contained in:
gsxdsm
2026-06-25 19:23:54 -07:00
parent 775a1f8e9a
commit 8754e28868
3 changed files with 66 additions and 6 deletions

View File

@@ -97,8 +97,10 @@ not re-run `scripts/ensure-test-artifacts.mjs`.
<!-- FNXC:TestInfrastructure 2026-06-21-12:21: FN-6854 applies the dashboard heap-runner pattern to the engine affected-package lane because a wide `vitest --changed` fan-out selected hundreds of real-git-heavy engine files and could be OS-SIGKILLed by heap pressure before Vitest returned a verdict. Keep the engine lane isolated, heap-capped, and lower-worker rather than raising concurrency or widening timeouts.
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. -->
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.
<!-- FNXC:TestInfrastructure 2026-06-25-14:30: wide `vitest --changed` fan-out guard. The 1-worker envelope above fixed OOM but NOT wall-clock: `vitest --changed <base>` does unbounded transitive graph expansion, so one hub source edit (measured: a `packages/engine/src/self-healing.ts` change selected 8393 matched test entries; just *listing* them took ~79s) runs ~the full suite at one worker and blows past the engine's 15-min per-task verification timeout (`VERIFICATION_TIMEOUT_WORKSPACE_MS=900_000`). The engine then SIGKILLs `pnpm test` mid-run and restarts the task, stacking 15-min timeouts (~9 observed in one task). The fan-out only triggers when a NON-test source file in the package's module graph changes, so the guard keeps the bounded contract: for engine/dashboard, a changed source file in-graph makes the lane run ONLY the directly-changed test files and delegate wider coverage to the merge-gate suite (which runs first in changed mode), mirroring the reverse-dependent blast cap; a test-file-only diff still runs `vitest --changed`. `pnpm test:full` is the explicit full sweep. Implemented via `changedSourceFilesAffectingPackage` in `scripts/test-changed.mjs`; do not "fix" this by raising workers or widening the watchdog. -->
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.

View File

@@ -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,

View File

@@ -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(", ")}`,
});
}