FN-8937: rescue project engine test quarantine
Rescue the project engine suite by making subprocess watchdog behavior deterministic. - Capture real timer APIs for subprocess watchdogs and isolate failure ownership. - Mock integration-branch resolution to prevent host git during lifecycle tests. - Add watchdog regression coverage and remove the expired quarantine exclusion. Files changed: docs/testing.md | 3 + packages/core/src/__test-utils__/vitest-setup.ts | 74 ++++++++++- .../__tests__/subprocess-guard-fake-timers.test.ts | 140 +++++++++++++++++++++ .../engine/src/__tests__/project-engine.test.ts | 63 +++++++--- packages/engine/vitest.config.ts | 12 +- scripts/lib/test-quarantine.json | 8 +- 6 files changed, 265 insertions(+), 35 deletions(-) Fusion-Task-Id: FN-8937 Fusion-Task-Lineage: 9fe166b5-b101-4683-bb2b-4855ee73df10 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -395,6 +395,9 @@ Flags:
|
||||
<!-- FNXC:DashboardTestQuarantine 2026-08-10-05:53: FN-8936 rescued PlanningModeModal's high-value planning-flow suite before its 2026-08-20 deadline. A resumed plan can replace the newly discovered Proceed action during hydration, so each direct-create test now settles that commit and re-queries the live action before dispatch; do not replace this structural fix with waits, retries, or weaker assertions. -->
|
||||
**2026-08-10 Planning Mode disposition (FN-8936):** Rescued `PlanningModeModal.planning-flow.test.tsx` before its 2026-08-20 deadline. Investigation found no product state-machine race: Proceed snapshots stable session/summary refs and takes its single-flight guard before create. The loaded failure was a test harness detached-node race when session hydration replaced an action-bar button returned by `findByRole`. All unsafe direct Proceed handoffs now settle hydration and query a live button before clicking, while retaining strict create arguments, task-created/onTaskCreated, desktop/mobile handoff, claim-retry, retry, and multi-task assertions. Exact and loaded-file runs passed, and the matching ledger entry and dashboard Vitest exclude were removed together without changing timeouts or adding retries.
|
||||
|
||||
<!-- FNXC:TestSubprocessGuard 2026-08-10-09:35: FN-8937 rescues project-engine.test.ts by fixing real-git and virtual-watchdog seam defects, without treating a guard budget as a scheduling interval. -->
|
||||
**2026-08-10 project-engine disposition (FN-8937):** Rescued `project-engine.test.ts` before its 2026-08-20 deadline. The suite's un-mocked `exec`-based integration-branch probe spawned real git, while the shared subprocess guard watchdog used fakeable timers; a duplicate-registration path could also orphan a watchdog handle. The resolver is now a deterministic suite seam, and watchdogs use captured real timers with owner-scoped failure draining, preserving sibling-test failure ownership. The ledger claim that runtime schedules 120s was a misread of `FUSION_TEST_SUBPROCESS_TIMEOUT_MS`: production retains its correct 60s ladder at `packages/engine/src/project-engine.ts:4551`, and the test correctly forbids its uncapped 120000ms rung. Thus the request to update the assertion to 120s is a documented deviation; no timeout was widened, retry added, or assertion weakened. The ledger/config exclusions were removed together, while the file remains outside `engine-core` pending separate gate-admission evidence.
|
||||
|
||||
### Validate before excluding and preserve timeout budgets
|
||||
|
||||
Capture **full runner output** before recording or filing a ledger entry—for example, tee it to a file. Never pipe a dot reporter through `tail`: the summary remains but the `FAIL` identity lines needed for evidence are truncated.
|
||||
|
||||
@@ -88,6 +88,14 @@ const DEFAULT_TEST_SUBPROCESS_TIMEOUT_MS = Math.max(
|
||||
1_000,
|
||||
Number.parseInt(process.env.FUSION_TEST_SUBPROCESS_TIMEOUT_MS ?? "30000", 10) || 30_000,
|
||||
);
|
||||
let currentSubprocessTimeoutMs = DEFAULT_TEST_SUBPROCESS_TIMEOUT_MS;
|
||||
/*
|
||||
FNXC:TestSubprocessGuard 2026-08-10-09:35:
|
||||
FN-8937 requires watchdogs to measure real elapsed time even when a test advances
|
||||
Vitest's virtual clock. Capture timer APIs before tests can install fake timers.
|
||||
*/
|
||||
const realSetTimeout = globalThis.setTimeout.bind(globalThis);
|
||||
const realClearTimeout = globalThis.clearTimeout.bind(globalThis);
|
||||
const BLOCKED_TEST_CLI_PATTERN =
|
||||
/(^|[\s"'\\/])(?:claude|droid|paperclipai|hermes|openclaw)(?:\.(?:cmd|bat|ps1|exe))?(?=$|[\s"'\\/])/i;
|
||||
|
||||
@@ -885,7 +893,7 @@ function withDefaultTimeout<T extends { timeout?: number | undefined }>(options:
|
||||
}
|
||||
return {
|
||||
...(options ?? {}),
|
||||
timeout: DEFAULT_TEST_SUBPROCESS_TIMEOUT_MS,
|
||||
timeout: currentSubprocessTimeoutMs,
|
||||
} as T;
|
||||
}
|
||||
|
||||
@@ -893,13 +901,19 @@ function cleanupTrackedSubprocess(proc: ChildProcess): void {
|
||||
const tracked = trackedSubprocesses.get(proc);
|
||||
if (!tracked) return;
|
||||
if (tracked.timeoutTimer) {
|
||||
clearTimeout(tracked.timeoutTimer);
|
||||
realClearTimeout(tracked.timeoutTimer);
|
||||
tracked.timeoutTimer = null;
|
||||
}
|
||||
trackedSubprocesses.delete(proc);
|
||||
}
|
||||
|
||||
function registerTrackedSubprocess(proc: ChildProcess, commandLine: string): void {
|
||||
/*
|
||||
FNXC:TestSubprocessGuard 2026-08-10-09:35:
|
||||
FN-8937 requires duplicate registration to cancel its prior watchdog; map size
|
||||
alone cannot expose the otherwise orphaned timer that later fabricates a timeout.
|
||||
*/
|
||||
cleanupTrackedSubprocess(proc);
|
||||
const tracked: TrackedSubprocess = {
|
||||
commandLine,
|
||||
startedAt: Date.now(),
|
||||
@@ -909,18 +923,19 @@ function registerTrackedSubprocess(proc: ChildProcess, commandLine: string): voi
|
||||
};
|
||||
trackedSubprocesses.set(proc, tracked);
|
||||
|
||||
tracked.timeoutTimer = setTimeout(() => {
|
||||
tracked.timeoutTimer = realSetTimeout(() => {
|
||||
tracked.timedOut = true;
|
||||
completedSubprocessFailures.push({
|
||||
ownerTestName: tracked.testName,
|
||||
message: `Timed out after ${DEFAULT_TEST_SUBPROCESS_TIMEOUT_MS}ms: ${tracked.commandLine}${tracked.testName ? ` (${tracked.testName})` : ""}`,
|
||||
message: `Timed out after ${currentSubprocessTimeoutMs}ms: ${tracked.commandLine}${tracked.testName ? ` (${tracked.testName})` : ""}`,
|
||||
});
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch {
|
||||
// Ignore — the process may have already exited.
|
||||
}
|
||||
}, DEFAULT_TEST_SUBPROCESS_TIMEOUT_MS);
|
||||
}, currentSubprocessTimeoutMs);
|
||||
tracked.timeoutTimer.unref?.();
|
||||
|
||||
const finish = () => cleanupTrackedSubprocess(proc);
|
||||
proc.once("close", finish);
|
||||
@@ -1195,6 +1210,55 @@ function removeSelfMintedWorkerRootWithRetry(
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TestSubprocessGuard 2026-08-10-09:35:
|
||||
FN-8937 exposes a deliberately narrow owner-scoped harness: fake timers must not
|
||||
fabricate timeouts, real hangs must still report, and sibling failures stay queued
|
||||
for their own afterEach. Never add a blanket drain or suppression path here.
|
||||
*/
|
||||
export const __fusionSubprocessGuardTestHooks = {
|
||||
getSubprocessTimeoutMs: (): number => currentSubprocessTimeoutMs,
|
||||
setSubprocessTimeoutMsForTests: (ms: number | null): void => {
|
||||
currentSubprocessTimeoutMs = ms ?? DEFAULT_TEST_SUBPROCESS_TIMEOUT_MS;
|
||||
},
|
||||
takeOwnedSubprocessFailures: (): string[] => {
|
||||
const ownerTestName = currentTestName();
|
||||
const owned: string[] = [];
|
||||
const remaining: CompletedSubprocessFailure[] = [];
|
||||
for (const failure of completedSubprocessFailures) {
|
||||
if (failure.ownerTestName === ownerTestName) owned.push(failure.message);
|
||||
else remaining.push(failure);
|
||||
}
|
||||
completedSubprocessFailures.length = 0;
|
||||
completedSubprocessFailures.push(...remaining);
|
||||
return owned;
|
||||
},
|
||||
peekForeignSubprocessFailureCount: (): number =>
|
||||
completedSubprocessFailures.filter((failure) => failure.ownerTestName !== currentTestName()).length,
|
||||
listForeignSubprocessFailureMessages: (): readonly string[] =>
|
||||
completedSubprocessFailures
|
||||
.filter((failure) => failure.ownerTestName !== currentTestName())
|
||||
.map((failure) => failure.message),
|
||||
recordSubprocessFailureForTests: (ownerTestName: string | null, message: string): void => {
|
||||
completedSubprocessFailures.push({ ownerTestName, message });
|
||||
},
|
||||
/*
|
||||
FNXC:TestSubprocessGuard 2026-08-10-09:35:
|
||||
This removes only synthetic entries staged by the calling test so cleanup never
|
||||
discards a real recorded guard failure or leaks a foreign entry to another test.
|
||||
*/
|
||||
removeStagedFailureForTests: (message: string): boolean => {
|
||||
const index = completedSubprocessFailures.findIndex((failure) => failure.message === message);
|
||||
if (index < 0) return false;
|
||||
completedSubprocessFailures.splice(index, 1);
|
||||
return true;
|
||||
},
|
||||
registerTrackedSubprocessForTests: (proc: ChildProcess, commandLine: string): void => {
|
||||
registerTrackedSubprocess(proc, commandLine);
|
||||
},
|
||||
getTrackedSubprocessCount: (): number => trackedSubprocesses.size,
|
||||
};
|
||||
|
||||
export const __fusionWorkerRootCleanupTestHooks = {
|
||||
removeSelfMintedWorkerRootWithRetry,
|
||||
writeWorkerRootOwnerMarker,
|
||||
|
||||
140
packages/core/src/__tests__/subprocess-guard-fake-timers.test.ts
Normal file
140
packages/core/src/__tests__/subprocess-guard-fake-timers.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { exec, execFile, fork, spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { __fusionSubprocessGuardTestHooks as guard } from "../__test-utils__/vitest-setup";
|
||||
|
||||
const temporaryPaths: string[] = [];
|
||||
|
||||
function waitForClose(proc: ChildProcess): Promise<void> {
|
||||
return once(proc, "close").then(() => undefined);
|
||||
}
|
||||
|
||||
function waitForRealTime(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function startShortChild(): ChildProcess {
|
||||
return spawn(process.execPath, ["-e", "0"]);
|
||||
}
|
||||
|
||||
function startHangingChild(): ChildProcess {
|
||||
return spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"]);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
guard.setSubprocessTimeoutMsForTests(null);
|
||||
expect(guard.peekForeignSubprocessFailureCount()).toBe(0);
|
||||
expect(guard.takeOwnedSubprocessFailures()).toEqual([]);
|
||||
for (const path of temporaryPaths.splice(0)) rmSync(path, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("subprocess guard watchdog timers", () => {
|
||||
it.each([
|
||||
["spawn", () => spawn(process.execPath, ["-e", "0"])],
|
||||
["exec", () => exec(`${JSON.stringify(process.execPath)} -e "0"`)],
|
||||
["execFile", () => execFile(process.execPath, ["-e", "0"])],
|
||||
["fork", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "fn-subprocess-guard-"));
|
||||
temporaryPaths.push(directory);
|
||||
const modulePath = join(directory, "child.cjs");
|
||||
writeFileSync(modulePath, "process.exit(0);\n");
|
||||
return fork(modulePath);
|
||||
}],
|
||||
])("S1 ignores virtual elapsed time for %s", async (_name, start) => {
|
||||
vi.useFakeTimers();
|
||||
const before = vi.getTimerCount();
|
||||
const proc = start();
|
||||
const after = vi.getTimerCount();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(guard.getSubprocessTimeoutMs() * 2);
|
||||
vi.useRealTimers();
|
||||
await waitForClose(proc);
|
||||
|
||||
expect(guard.takeOwnedSubprocessFailures()).toEqual([]);
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it("S2 ignores virtual elapsed time installed after spawn", async () => {
|
||||
const proc = startShortChild();
|
||||
vi.useFakeTimers();
|
||||
await vi.advanceTimersByTimeAsync(guard.getSubprocessTimeoutMs() * 2);
|
||||
vi.useRealTimers();
|
||||
await waitForClose(proc);
|
||||
|
||||
expect(guard.takeOwnedSubprocessFailures()).toEqual([]);
|
||||
expect(guard.getTrackedSubprocessCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("S3 retains the real-timer baseline", async () => {
|
||||
const proc = startShortChild();
|
||||
await waitForClose(proc);
|
||||
|
||||
expect(guard.takeOwnedSubprocessFailures()).toEqual([]);
|
||||
expect(guard.getTrackedSubprocessCount()).toBe(0);
|
||||
expect(guard.getSubprocessTimeoutMs()).toSatisfy((ms: number) => Number.isFinite(ms) && ms >= 1_000);
|
||||
});
|
||||
|
||||
it("S4 reports and kills a genuinely hung child", async () => {
|
||||
guard.setSubprocessTimeoutMsForTests(150);
|
||||
const proc = startHangingChild();
|
||||
await waitForClose(proc);
|
||||
|
||||
expect(guard.takeOwnedSubprocessFailures()).toEqual([expect.stringMatching(/Timed out after 150ms/)]);
|
||||
expect(proc.signalCode).toBe("SIGKILL");
|
||||
expect(guard.getTrackedSubprocessCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("S5 cleanup cancels its real watchdog", async () => {
|
||||
guard.setSubprocessTimeoutMsForTests(150);
|
||||
const proc = startShortChild();
|
||||
await waitForClose(proc);
|
||||
await waitForRealTime(450);
|
||||
|
||||
expect(guard.takeOwnedSubprocessFailures()).toEqual([]);
|
||||
expect(guard.getTrackedSubprocessCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("S6 duplicate registration does not orphan a watchdog", async () => {
|
||||
guard.setSubprocessTimeoutMsForTests(150);
|
||||
const proc = startShortChild();
|
||||
guard.registerTrackedSubprocessForTests(proc, "duplicate-registration-probe");
|
||||
await waitForClose(proc);
|
||||
await waitForRealTime(450);
|
||||
|
||||
// The map is keyed by proc, so only absence of a fabricated failure proves cleanup.
|
||||
expect(guard.takeOwnedSubprocessFailures()).toEqual([]);
|
||||
expect(guard.getTrackedSubprocessCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("S7 leaves a foreign-only failure queued", () => {
|
||||
expect(guard.takeOwnedSubprocessFailures()).toEqual([]);
|
||||
expect(guard.peekForeignSubprocessFailureCount()).toBe(0);
|
||||
|
||||
const message = "Timed out after 1ms: synthetic-foreign";
|
||||
guard.recordSubprocessFailureForTests("FN-8937 synthetic sibling test", message);
|
||||
expect(guard.takeOwnedSubprocessFailures()).toEqual([]);
|
||||
expect(guard.peekForeignSubprocessFailureCount()).toBe(1);
|
||||
expect(guard.removeStagedFailureForTests(message)).toBe(true);
|
||||
expect(guard.peekForeignSubprocessFailureCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("S8 drains owned failures while preserving foreign order", async () => {
|
||||
guard.setSubprocessTimeoutMsForTests(150);
|
||||
guard.recordSubprocessFailureForTests("FN-8937 synthetic sibling A", "synthetic-F1");
|
||||
const proc = startHangingChild();
|
||||
await waitForClose(proc);
|
||||
expect(guard.peekForeignSubprocessFailureCount()).toBe(1);
|
||||
guard.recordSubprocessFailureForTests("FN-8937 synthetic sibling B", "synthetic-F2");
|
||||
|
||||
expect(guard.takeOwnedSubprocessFailures()).toEqual([expect.stringMatching(/Timed out after 150ms/)]);
|
||||
expect(guard.peekForeignSubprocessFailureCount()).toBe(2);
|
||||
expect(guard.listForeignSubprocessFailureMessages()).toEqual(["synthetic-F1", "synthetic-F2"]);
|
||||
expect(guard.removeStagedFailureForTests("synthetic-F1")).toBe(true);
|
||||
expect(guard.removeStagedFailureForTests("synthetic-F2")).toBe(true);
|
||||
expect(guard.peekForeignSubprocessFailureCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -132,6 +132,19 @@ vi.mock("node:child_process", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:EngineTests 2026-08-10-09:35:
|
||||
FN-8937 seals the exec-based integration-branch probe so this ProjectEngine suite
|
||||
never spawns host git while fake timers exercise workspace dispatch. Resolver data
|
||||
states remain owned by integration-branch.test.ts; this seam supplies only a stable branch.
|
||||
*/
|
||||
vi.mock("../merge/integration-branch.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../merge/integration-branch.js")>()),
|
||||
resolveIntegrationBranch: vi.fn().mockResolvedValue("main"),
|
||||
resolveIntegrationBranchSync: vi.fn().mockReturnValue("main"),
|
||||
__resetIntegrationBranchCacheForTests: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../merge/pr-monitor.js", () => ({
|
||||
PrMonitor: vi.fn().mockImplementation(function () {
|
||||
return {
|
||||
@@ -2119,7 +2132,12 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
||||
|
||||
await engine.start();
|
||||
|
||||
const taskMovedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:moved")?.[1] as
|
||||
/*
|
||||
FNXC:EngineTests 2026-08-10-10:34:
|
||||
FN-8937 must invoke the auto-merge listener, not spec-drift's earlier observer;
|
||||
the latest registration owns the merge handoff assertions below.
|
||||
*/
|
||||
const taskMovedHandler = mockStore.store.on.mock.calls.findLast((c: unknown[]) => c[0] === "task:moved")?.[1] as
|
||||
| ((payload: { task: { id: string; column: string; paused?: boolean }; to: string }) => Promise<void>)
|
||||
| undefined;
|
||||
|
||||
@@ -2145,7 +2163,7 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
||||
await engine.start();
|
||||
enqueueSpy.mockClear();
|
||||
|
||||
const taskUpdatedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:updated")?.[1] as
|
||||
const taskUpdatedHandler = mockStore.store.on.mock.calls.findLast((c: unknown[]) => c[0] === "task:updated")?.[1] as
|
||||
| ((task: { id: string; column: string; paused?: boolean; status?: string | null }) => Promise<void>)
|
||||
| undefined;
|
||||
if (!taskUpdatedHandler) throw new Error("task:updated handler was not registered");
|
||||
@@ -2703,7 +2721,7 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
||||
expect(mocks.runAiMerge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const taskUpdatedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:updated")?.[1] as
|
||||
const taskUpdatedHandler = mockStore.store.on.mock.calls.findLast((c: unknown[]) => c[0] === "task:updated")?.[1] as
|
||||
| ((task: { id: string; column: string; paused?: boolean }) => void)
|
||||
| undefined;
|
||||
if (!taskUpdatedHandler) throw new Error("task:updated handler was not registered");
|
||||
@@ -2731,8 +2749,15 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
||||
{ id: "FN-paused", column: "in-review", paused: true, mergeRetries: 0, status: null },
|
||||
{ id: "FN-ready", column: "in-review", paused: false, mergeRetries: 0, status: null },
|
||||
];
|
||||
// Critical stale-status cleanup reads first; deferred startup then evaluates eligibility.
|
||||
mockStore.store.listTasks.mockResolvedValueOnce([]).mockResolvedValueOnce(inReviewTasks);
|
||||
/*
|
||||
FNXC:EngineTests 2026-08-10-10:34:
|
||||
FN-8937 keeps this rescue suite aligned with the startup ownership contract:
|
||||
spec-drift seeds first, stale-status cleanup reads second, then deferred merge admission reads the candidates.
|
||||
*/
|
||||
mockStore.store.listTasks
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce(inReviewTasks);
|
||||
mocks.currentStore = mockStore.store;
|
||||
const engine = createEngine();
|
||||
const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void };
|
||||
@@ -2768,8 +2793,14 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
||||
{ id: "FN-opted-in", column: "in-review", paused: false, mergeRetries: 0, status: null, autoMerge: true, branchContext: { assignmentMode: "shared", groupId: "BG-5819", source: "planning" } },
|
||||
{ id: "FN-plain", column: "in-review", paused: false, mergeRetries: 0, status: null },
|
||||
];
|
||||
// Critical stale-status cleanup reads first; deferred startup then evaluates eligibility.
|
||||
mockStore.store.listTasks.mockResolvedValueOnce([]).mockResolvedValueOnce(inReviewTasks);
|
||||
/*
|
||||
FNXC:EngineTests 2026-08-10-10:34:
|
||||
FN-8937 preserves the three startup readers: spec-drift seed, stale-status cleanup, then deferred merge admission.
|
||||
*/
|
||||
mockStore.store.listTasks
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce(inReviewTasks);
|
||||
mocks.currentStore = mockStore.store;
|
||||
const engine = createEngine();
|
||||
const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void };
|
||||
@@ -2799,7 +2830,7 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
||||
|
||||
await engine.start();
|
||||
enqueueSpy.mockClear();
|
||||
const movedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:moved")?.[1] as
|
||||
const movedHandler = mockStore.store.on.mock.calls.findLast((c: unknown[]) => c[0] === "task:moved")?.[1] as
|
||||
| ((event: { task: Task; to: string }) => void)
|
||||
| undefined;
|
||||
if (!movedHandler) throw new Error("task:moved handler was not registered");
|
||||
@@ -3247,11 +3278,12 @@ describe("ProjectEngine swallowed error hardening", () => {
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(2));
|
||||
// Spec-drift's initial scan precedes stale-status cleanup and deferred merge admission.
|
||||
await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(3));
|
||||
|
||||
mockStore.store.getSettings.mockRejectedValueOnce(new Error("db locked"));
|
||||
|
||||
const handler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:moved")?.[1] as
|
||||
const handler = mockStore.store.on.mock.calls.findLast((c: unknown[]) => c[0] === "task:moved")?.[1] as
|
||||
| ((payload: { task: { id: string; column: string }; to: string }) => Promise<void>)
|
||||
| undefined;
|
||||
expect(handler).toBeTypeOf("function");
|
||||
@@ -3279,8 +3311,9 @@ describe("ProjectEngine swallowed error hardening", () => {
|
||||
it("warns when startup merge sweep fails", async () => {
|
||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||
mocks.currentStore = mockStore.store;
|
||||
// The critical stale-status read precedes the deferred enqueue sweep.
|
||||
// Spec-drift scans first, stale-status cleanup is second, and deferred admission must fail third.
|
||||
mockStore.store.listTasks
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockRejectedValueOnce(new Error("connection lost"));
|
||||
|
||||
@@ -3301,7 +3334,7 @@ describe("ProjectEngine swallowed error hardening", () => {
|
||||
mocks.currentStore = mockStore.store;
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(2));
|
||||
await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(3));
|
||||
warnSpy.mockClear();
|
||||
|
||||
mockStore.store.listTasks.mockRejectedValueOnce(new Error("sweep db error"));
|
||||
@@ -3319,7 +3352,7 @@ describe("ProjectEngine swallowed error hardening", () => {
|
||||
mocks.currentStore = mockStore.store;
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(2));
|
||||
await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(3));
|
||||
warnSpy.mockClear();
|
||||
|
||||
mockStore.store.getSettings
|
||||
@@ -3546,7 +3579,7 @@ describe("ProjectEngine stale mergeActive rescue (FN-3900)", () => {
|
||||
});
|
||||
const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => {});
|
||||
|
||||
const taskMovedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:moved")?.[1] as
|
||||
const taskMovedHandler = mockStore.store.on.mock.calls.findLast((c: unknown[]) => c[0] === "task:moved")?.[1] as
|
||||
| ((payload: { task: { id: string; column: string; paused?: boolean }; to: string }) => Promise<void>)
|
||||
| undefined;
|
||||
if (!taskMovedHandler) throw new Error("task:moved handler was not registered");
|
||||
@@ -3658,7 +3691,7 @@ describe("ProjectEngine stale mergeActive rescue (FN-3900)", () => {
|
||||
const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge");
|
||||
const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => {});
|
||||
|
||||
const taskMovedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:moved")?.[1] as
|
||||
const taskMovedHandler = mockStore.store.on.mock.calls.findLast((c: unknown[]) => c[0] === "task:moved")?.[1] as
|
||||
| ((payload: { task: { id: string; column: string; paused?: boolean }; to: string }) => Promise<void>)
|
||||
| undefined;
|
||||
if (!taskMovedHandler) throw new Error("task:moved handler was not registered");
|
||||
|
||||
@@ -250,11 +250,10 @@ export default defineConfig({
|
||||
"src/__tests__/merger-landed-files-capture.test.ts",
|
||||
"src/__tests__/branch-attribution.test.ts",
|
||||
/*
|
||||
FNXC:EngineTests 2026-08-06-00:04:
|
||||
FN-8811 quarantines project-engine.test.ts after its workspace-busy case
|
||||
contradicted its 60s cap with a real 120s retry and left a timed-out git
|
||||
subprocess. The paired ledger entry owns its 14-day deletion ratchet; keep
|
||||
this gate allow-list free of the file until a root-cause fix rescues it.
|
||||
FNXC:EngineTests 2026-08-10-09:35:
|
||||
FN-8937 rescued project-engine.test.ts into engine-default by sealing its
|
||||
git resolver seam and using real guard watchdog timers. It remains outside
|
||||
engine-core: merge-gate admission requires separate deterministic evidence.
|
||||
*/
|
||||
/*
|
||||
FNXC:EngineTests 2026-07-28-21:05 (#2520 review — greptile P1):
|
||||
@@ -388,9 +387,6 @@ export default defineConfig({
|
||||
Quarantined on sight per AGENTS.md; mirrored in scripts/lib/test-quarantine.json.
|
||||
*/
|
||||
// SQLite-path gate test evicted + quarantined (see engine-core comment + ledger).
|
||||
// FNXC:EngineTests 2026-08-06-00:04: paired with the ledger's FN-8811
|
||||
// workspace-busy quarantine; do not appease its timing assertion.
|
||||
"src/__tests__/project-engine.test.ts",
|
||||
"node_modules/**",
|
||||
"dist/**",
|
||||
// FNXC:PgMigrationQuarantine 2026-07-18-04:30: FN-8270 rescued the final seven VAL-REMOVAL-005 holdouts by awaiting PG audit reads and modeling async collaborators. Their paired ledger entries and excludes were removed only after targeted green runs.
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
{
|
||||
"$comment": "Flaky-test quarantine ledger (deletion ratchet — see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date — the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config is the enforcement.",
|
||||
"entries": [
|
||||
{
|
||||
"file": "packages/engine/src/__tests__/project-engine.test.ts",
|
||||
"reason": "FN-8811 observed B4/B5 workspace busy contention asserting a stale 60s cap while runtime schedules 120s, then the subprocess guard timed out on git remote; reproduced in isolation on 2026-08-06. Do not widen timing or weaken the assertion; restore only with a root-cause fix before the deletion deadline.",
|
||||
"quarantinedAt": "2026-08-06"
|
||||
}
|
||||
]
|
||||
"entries": []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user