test(FN-4379): complete Step 4 — orphan-only scope unit coverage
Fusion-Task-Id: FN-4379 Fusion-Task-Lineage: 88ee0f49-1db2-40e6-8781-7ecbc4dcbdc8
This commit is contained in:
@@ -0,0 +1,192 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("node:child_process", async () => {
|
||||||
|
const { promisify: utilPromisify } = await import("node:util");
|
||||||
|
const execSyncFn = vi.fn();
|
||||||
|
const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => {
|
||||||
|
const callback = typeof opts === "function" ? opts : cb;
|
||||||
|
const options = typeof opts === "object" && opts !== null ? opts : {};
|
||||||
|
try {
|
||||||
|
const out = execSyncFn(cmd, { ...options, stdio: ["pipe", "pipe", "pipe"] });
|
||||||
|
const stdout = out === undefined ? "" : out.toString();
|
||||||
|
if (typeof callback === "function") callback(null, stdout, "");
|
||||||
|
} catch (err) {
|
||||||
|
if (typeof callback === "function") {
|
||||||
|
const error = err as { stdout?: string; stderr?: string };
|
||||||
|
callback(err, error?.stdout?.toString?.() ?? "", error?.stderr?.toString?.() ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
execFn[utilPromisify.custom] = (cmd: string, opts?: any) => new Promise((resolve, reject) => {
|
||||||
|
execFn(cmd, opts, (err: any, stdout: string, stderr: string) => {
|
||||||
|
if (err) {
|
||||||
|
(err as Record<string, unknown>).stdout = stdout;
|
||||||
|
(err as Record<string, unknown>).stderr = stderr;
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve({ stdout, stderr });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { execSync: execSyncFn, exec: execFn };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import type { Settings, Task, TaskStore } from "@fusion/core";
|
||||||
|
import { SelfHealingManager } from "../self-healing.js";
|
||||||
|
|
||||||
|
const mockedExecSync = vi.mocked(execSync);
|
||||||
|
|
||||||
|
function createMockStore(overrides: Record<string, unknown> = {}): TaskStore & EventEmitter {
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
return Object.assign(emitter, {
|
||||||
|
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false } as Settings),
|
||||||
|
listTasks: vi.fn().mockResolvedValue([]),
|
||||||
|
updateTask: vi.fn().mockResolvedValue({} as Task),
|
||||||
|
moveTask: vi.fn().mockResolvedValue(undefined),
|
||||||
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||||
|
...overrides,
|
||||||
|
}) as unknown as TaskStore & EventEmitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function failedReviewTask(overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
id: "FN-4350",
|
||||||
|
column: "in-review",
|
||||||
|
status: "failed",
|
||||||
|
paused: false,
|
||||||
|
scopeOverride: false,
|
||||||
|
mergeDetails: undefined,
|
||||||
|
branch: "fusion/fn-4350",
|
||||||
|
baseBranch: "main",
|
||||||
|
steps: [],
|
||||||
|
log: [],
|
||||||
|
...overrides,
|
||||||
|
} as Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DETAIL = [
|
||||||
|
"taskId: FN-4350",
|
||||||
|
"declaredScope:",
|
||||||
|
"- packages/dashboard/app/components/QuickChatFAB.tsx",
|
||||||
|
"stagedFiles:",
|
||||||
|
"- packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
describe("recoverOrphanOnlyScopeViolations (FN-4379 / FN-4350)", () => {
|
||||||
|
let store: TaskStore & EventEmitter;
|
||||||
|
let manager: SelfHealingManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = createMockStore();
|
||||||
|
manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
|
mockedExecSync.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
manager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers orphan-only FileScopeViolationError when task work is on main (FN-4350)", async () => {
|
||||||
|
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([failedReviewTask()]);
|
||||||
|
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([{ type: "tool_error", detail: DETAIL }]);
|
||||||
|
mockedExecSync.mockImplementation((command: string | Buffer) => {
|
||||||
|
if (String(command).includes("Fusion-Task-Id: FN-4350")) return "abc123456789\n" as any;
|
||||||
|
return "" as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
const recovered = await manager.recoverOrphanOnlyScopeViolations();
|
||||||
|
|
||||||
|
expect(recovered).toBe(1);
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith("FN-4350", "done");
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-4350", expect.objectContaining({
|
||||||
|
mergeDetails: expect.objectContaining({ mergeConfirmed: true, resolutionStrategy: "orphan-discard-no-op" }),
|
||||||
|
}));
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith("FN-4350", expect.stringContaining("Auto-recovered: FileScopeViolationError treated as orphan-only"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT recover when landed commit cannot be verified (FN-4280)", async () => {
|
||||||
|
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([failedReviewTask()]);
|
||||||
|
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([{ type: "tool_error", detail: DETAIL }]);
|
||||||
|
mockedExecSync.mockImplementation(() => "" as any);
|
||||||
|
|
||||||
|
const recovered = await manager.recoverOrphanOnlyScopeViolations();
|
||||||
|
|
||||||
|
expect(recovered).toBe(0);
|
||||||
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
|
expect(store.updateTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT recover mixed staging that overlaps declared scope", async () => {
|
||||||
|
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([failedReviewTask()]);
|
||||||
|
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([{ type: "tool_error", detail: [
|
||||||
|
"taskId: FN-4350",
|
||||||
|
"declaredScope:",
|
||||||
|
"- packages/dashboard/app/components/QuickChatFAB.tsx",
|
||||||
|
"stagedFiles:",
|
||||||
|
"- .changeset/fn-4379.md",
|
||||||
|
"- packages/dashboard/app/components/QuickChatFAB.tsx",
|
||||||
|
].join("\n") }]);
|
||||||
|
|
||||||
|
const recovered = await manager.recoverOrphanOnlyScopeViolations();
|
||||||
|
|
||||||
|
expect(recovered).toBe(0);
|
||||||
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips scopeOverride tasks entirely", async () => {
|
||||||
|
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([failedReviewTask({ scopeOverride: true })]);
|
||||||
|
|
||||||
|
const recovered = await manager.recoverOrphanOnlyScopeViolations();
|
||||||
|
|
||||||
|
expect(recovered).toBe(0);
|
||||||
|
expect(store.getAgentLogs).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 when global pause or engine pause is active", async () => {
|
||||||
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ globalPause: true, enginePaused: false });
|
||||||
|
expect(await manager.recoverOrphanOnlyScopeViolations()).toBe(0);
|
||||||
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ globalPause: false, enginePaused: true });
|
||||||
|
expect(await manager.recoverOrphanOnlyScopeViolations()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent across runs", async () => {
|
||||||
|
(store.listTasks as ReturnType<typeof vi.fn>)
|
||||||
|
.mockResolvedValueOnce([failedReviewTask()])
|
||||||
|
.mockResolvedValueOnce([]);
|
||||||
|
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([{ type: "tool_error", detail: DETAIL }]);
|
||||||
|
mockedExecSync.mockImplementation((command: string | Buffer) => {
|
||||||
|
if (String(command).includes("Fusion-Task-Id: FN-4350")) return "abc123456789\n" as any;
|
||||||
|
return "" as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await manager.recoverOrphanOnlyScopeViolations()).toBe(1);
|
||||||
|
expect(await manager.recoverOrphanOnlyScopeViolations()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips empty declared scope payload", async () => {
|
||||||
|
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([failedReviewTask()]);
|
||||||
|
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([{ type: "tool_error", detail: [
|
||||||
|
"taskId: FN-4350",
|
||||||
|
"declaredScope:",
|
||||||
|
"stagedFiles:",
|
||||||
|
"- packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx",
|
||||||
|
].join("\n") }]);
|
||||||
|
|
||||||
|
const recovered = await manager.recoverOrphanOnlyScopeViolations();
|
||||||
|
|
||||||
|
expect(recovered).toBe(0);
|
||||||
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips paused failed tasks", async () => {
|
||||||
|
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([failedReviewTask({ paused: true })]);
|
||||||
|
|
||||||
|
const recovered = await manager.recoverOrphanOnlyScopeViolations();
|
||||||
|
|
||||||
|
expect(recovered).toBe(0);
|
||||||
|
expect(store.getAgentLogs).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user