test(FN-3900): add stale mergeActive rescue regression coverage
- Add engine project tests covering stale mergeActive state recovery paths - Verify merge flow can continue when stale merge metadata is detected - Protect against regressions in merge task rescue behavior Fusion-Task-Id: FN-3900
This commit is contained in:
@@ -1991,3 +1991,176 @@ describe("ProjectEngine swallowed error hardening", () => {
|
||||
await engine.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectEngine stale mergeActive rescue (FN-3900)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("task:moved into in-review clears leaked mergeActive entry before enqueue", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||
mockStore.store.getTask.mockResolvedValue({
|
||||
id: "FN-leaked",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
status: null,
|
||||
mergeRetries: 0,
|
||||
});
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const engine = createEngine();
|
||||
const privateEngine = engine as unknown as {
|
||||
mergeQueue: string[];
|
||||
mergeActive: Set<string>;
|
||||
activeMergeTaskId: string | null;
|
||||
internalEnqueueMerge: (taskId: string) => void;
|
||||
};
|
||||
|
||||
await engine.start();
|
||||
privateEngine.mergeActive = new Set(["FN-leaked"]);
|
||||
privateEngine.mergeQueue = [];
|
||||
privateEngine.activeMergeTaskId = null;
|
||||
|
||||
const originalEnqueue = privateEngine.internalEnqueueMerge.bind(privateEngine);
|
||||
const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge").mockImplementation((taskId: string) => {
|
||||
expect(privateEngine.mergeActive.has("FN-leaked")).toBe(false);
|
||||
return originalEnqueue(taskId);
|
||||
});
|
||||
const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => {});
|
||||
|
||||
const taskMovedHandler = mockStore.store.on.mock.calls.find((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");
|
||||
|
||||
await taskMovedHandler({
|
||||
task: { id: "FN-leaked", column: "in-review", paused: false },
|
||||
to: "in-review",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
|
||||
expect(enqueueSpy).toHaveBeenCalledWith("FN-leaked");
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringMatching(/clearing stale mergeActive before enqueue/));
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("FN-leaked"));
|
||||
|
||||
await engine.stop();
|
||||
});
|
||||
|
||||
it("internalEnqueueMerge warns and skips direct leaked mergeActive entries", async () => {
|
||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const engine = createEngine();
|
||||
const privateEngine = engine as unknown as {
|
||||
mergeQueue: string[];
|
||||
mergeActive: Set<string>;
|
||||
activeMergeTaskId: string | null;
|
||||
internalEnqueueMerge: (taskId: string) => void;
|
||||
};
|
||||
const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => {});
|
||||
|
||||
await engine.start();
|
||||
|
||||
privateEngine.mergeActive = new Set(["FN-leaked2"]);
|
||||
privateEngine.mergeQueue = [];
|
||||
privateEngine.activeMergeTaskId = null;
|
||||
|
||||
privateEngine.internalEnqueueMerge("FN-leaked2");
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("mergeActive entry is leaked"));
|
||||
expect(privateEngine.mergeQueue).toEqual([]);
|
||||
expect(mocks.aiMergeTask).not.toHaveBeenCalled();
|
||||
|
||||
await engine.stop();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ scenario: "queued", mergeQueue: ["FN-live"], activeMergeTaskId: null },
|
||||
{ scenario: "active", mergeQueue: [], activeMergeTaskId: "FN-live" },
|
||||
])(
|
||||
"internalEnqueueMerge does not warn for live mergeActive entry ($scenario)",
|
||||
async ({ mergeQueue, activeMergeTaskId }) => {
|
||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const engine = createEngine();
|
||||
const privateEngine = engine as unknown as {
|
||||
mergeQueue: string[];
|
||||
mergeActive: Set<string>;
|
||||
activeMergeTaskId: string | null;
|
||||
internalEnqueueMerge: (taskId: string) => void;
|
||||
};
|
||||
const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => {});
|
||||
|
||||
await engine.start();
|
||||
|
||||
privateEngine.mergeActive = new Set(["FN-live"]);
|
||||
privateEngine.mergeQueue = [...mergeQueue];
|
||||
privateEngine.activeMergeTaskId = activeMergeTaskId;
|
||||
const queueBefore = [...privateEngine.mergeQueue];
|
||||
|
||||
privateEngine.internalEnqueueMerge("FN-live");
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("mergeActive entry is leaked"));
|
||||
expect(privateEngine.mergeQueue).toEqual(queueBefore);
|
||||
|
||||
await engine.stop();
|
||||
},
|
||||
);
|
||||
|
||||
it("task:moved rescue does not clear legitimate active mergeActive entry", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||
mockStore.store.getTask.mockResolvedValue({
|
||||
id: "FN-busy",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
status: null,
|
||||
mergeRetries: 0,
|
||||
});
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const engine = createEngine();
|
||||
const privateEngine = engine as unknown as {
|
||||
mergeQueue: string[];
|
||||
mergeActive: Set<string>;
|
||||
activeMergeTaskId: string | null;
|
||||
internalEnqueueMerge: (taskId: string) => void;
|
||||
};
|
||||
|
||||
await engine.start();
|
||||
privateEngine.mergeActive = new Set(["FN-busy"]);
|
||||
privateEngine.activeMergeTaskId = "FN-busy";
|
||||
privateEngine.mergeQueue = [];
|
||||
|
||||
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
|
||||
| ((payload: { task: { id: string; column: string; paused?: boolean }; to: string }) => Promise<void>)
|
||||
| undefined;
|
||||
if (!taskMovedHandler) throw new Error("task:moved handler was not registered");
|
||||
|
||||
await taskMovedHandler({
|
||||
task: { id: "FN-busy", column: "in-review", paused: false },
|
||||
to: "in-review",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
|
||||
expect(enqueueSpy).toHaveBeenCalledWith("FN-busy");
|
||||
expect(privateEngine.mergeActive.has("FN-busy")).toBe(true);
|
||||
expect(privateEngine.activeMergeTaskId).toBe("FN-busy");
|
||||
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringMatching(/clearing stale mergeActive/));
|
||||
|
||||
await engine.stop();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user