test(FN-5003): add post-finalize noop status-write race coverage
Fusion-Task-Id: FN-5003 Fusion-Task-Lineage: c5f1f326-934b-4cfc-8ba9-a2ac70a4f4f4
This commit is contained in:
committed by
gsxdsm
parent
9ca7a5b90e
commit
18e827dc81
@@ -0,0 +1,167 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Settings, Task, TaskStore } from "@fusion/core";
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
aiMergeTask: vi.fn(),
|
||||
currentStore: null as (TaskStore & EventEmitter) | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../merger.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../merger.js")>();
|
||||
return {
|
||||
...actual,
|
||||
aiMergeTask: testState.aiMergeTask,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../runtimes/in-process-runtime.js", () => ({
|
||||
InProcessRuntime: vi.fn().mockImplementation(() => ({
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => undefined),
|
||||
getTaskStore: () => testState.currentStore,
|
||||
getAgentStore: vi.fn(),
|
||||
getMessageStore: vi.fn(),
|
||||
getRoutineStore: vi.fn(),
|
||||
getRoutineRunner: vi.fn(),
|
||||
getHeartbeatMonitor: vi.fn(),
|
||||
getTriggerScheduler: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { ProjectEngine } from "../../project-engine.js";
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-5003",
|
||||
title: "t",
|
||||
description: "d",
|
||||
column: "in-review",
|
||||
status: "merging",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
verificationFailureCount: 0,
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function createStore(task: Task, sequence: Task[]) {
|
||||
const emitter = new EventEmitter();
|
||||
const logs: string[] = [];
|
||||
const audits: Array<{ mutationType: string; metadata?: Record<string, unknown> }> = [];
|
||||
let taskIdx = 0;
|
||||
const store = Object.assign(emitter, {
|
||||
getSettings: vi.fn(async () => ({
|
||||
autoMerge: true,
|
||||
autoResolveConflicts: true,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
pollIntervalMs: 15_000,
|
||||
} as Settings)),
|
||||
listTasks: vi.fn(async () => [task]),
|
||||
getTask: vi.fn(async () => {
|
||||
const current = sequence[Math.min(taskIdx, sequence.length - 1)] ?? task;
|
||||
taskIdx += 1;
|
||||
return current;
|
||||
}),
|
||||
updateTask: vi.fn(async () => undefined),
|
||||
addTaskComment: vi.fn(async () => undefined),
|
||||
moveTask: vi.fn(async () => undefined),
|
||||
logEntry: vi.fn(async (_id: string, message: string) => {
|
||||
logs.push(message);
|
||||
}),
|
||||
getActiveMergingTask: vi.fn(() => null),
|
||||
createTask: vi.fn(async () => ({ id: "FN-CHILD" })),
|
||||
on: emitter.on.bind(emitter),
|
||||
off: emitter.off.bind(emitter),
|
||||
walCheckpoint: () => ({ busy: 0, log: 0, checkpointed: 0 }),
|
||||
archiveTaskAndCleanup: async () => ({}),
|
||||
clearStaleExecutionStartBranchReferences: () => [],
|
||||
updateSettings: async () => ({}),
|
||||
mergeTask: async () => undefined,
|
||||
getRootDir: () => "",
|
||||
recordRunAuditEvent: vi.fn(async (input: { mutationType: string; metadata?: Record<string, unknown> }) => {
|
||||
audits.push({ mutationType: input.mutationType, metadata: input.metadata });
|
||||
}),
|
||||
}) as unknown as TaskStore & EventEmitter;
|
||||
|
||||
return { store, logs, audits };
|
||||
}
|
||||
|
||||
async function runMergeCycle(engine: ProjectEngine, taskId: string): Promise<void> {
|
||||
const privateEngine = engine as unknown as {
|
||||
mergeQueue: string[];
|
||||
mergeActive: Set<string>;
|
||||
drainMergeQueue: () => Promise<void>;
|
||||
};
|
||||
privateEngine.mergeActive.add(taskId);
|
||||
privateEngine.mergeQueue.push(taskId);
|
||||
await privateEngine.drainMergeQueue();
|
||||
}
|
||||
|
||||
describe("post-finalize verification noop status-write guard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.aiMergeTask.mockReset();
|
||||
testState.currentStore = null;
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "under-cap", failureCount: 1, blockedStatus: "merging-fix" },
|
||||
{ name: "at-cap", failureCount: 2, blockedStatus: "failed" },
|
||||
])("keeps done task unchanged on $name write path", async ({ failureCount, blockedStatus }) => {
|
||||
const verificationError = new Error("Deterministic test verification failed: no-op race");
|
||||
verificationError.name = "VerificationError";
|
||||
testState.aiMergeTask.mockRejectedValueOnce(verificationError);
|
||||
|
||||
const inReviewTask = makeTask({ verificationFailureCount: failureCount });
|
||||
const doneTask = makeTask({
|
||||
column: "done",
|
||||
status: null,
|
||||
verificationFailureCount: failureCount,
|
||||
mergeDetails: { mergeConfirmed: true, commitSha: "abcdef1234567890" },
|
||||
});
|
||||
|
||||
const { store, logs, audits } = createStore(inReviewTask, [inReviewTask, inReviewTask, inReviewTask, doneTask]);
|
||||
testState.currentStore = store;
|
||||
|
||||
const engine = new ProjectEngine(
|
||||
{
|
||||
projectId: "proj_test",
|
||||
workingDirectory: process.cwd(),
|
||||
isolationMode: "in-process",
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 1,
|
||||
},
|
||||
{} as never,
|
||||
{ skipNotifier: true },
|
||||
);
|
||||
|
||||
await runMergeCycle(engine, inReviewTask.id);
|
||||
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith(
|
||||
inReviewTask.id,
|
||||
expect.objectContaining({ status: blockedStatus }),
|
||||
);
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith(inReviewTask.id, "in-progress");
|
||||
expect(store.createTask).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: expect.objectContaining({ sourceType: "recovery" }) }),
|
||||
);
|
||||
|
||||
const noopLogs = logs.filter((entry) =>
|
||||
entry.includes("[verification] post-finalize VerificationError on already-done task — no action"),
|
||||
);
|
||||
expect(noopLogs).toHaveLength(1);
|
||||
|
||||
const noopAudits = audits.filter((event) => event.mutationType === "task:post-finalize-verification-no-op");
|
||||
expect(noopAudits).toHaveLength(1);
|
||||
expect(noopAudits[0]?.metadata).toEqual(expect.objectContaining({
|
||||
failedCommand: null,
|
||||
exitCode: null,
|
||||
}));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user