/** * agentSSEDispatch.test.ts — MX16-ABORT regression suite * * Verifica abort_reason enum "user_stop" vs "system": * R1: user_stop → toast.success "✓ Task fermato" (non toast.info) * R2: user_stop → eventRuntime.emit NON chiamato * R3: system abort → toast.info + eventRuntime.emit chiamato * R4: abort_reason assente → default user_stop (no eventRuntime) * R5: store.updateStatus("CANCELLED") chiamato sempre * * Pattern mock: vi.mock() + vi.stubGlobal — identico a backendSchedulerHealth.test.ts */ import { describe, it, expect, vi, beforeEach } from "vitest"; // ─── Mock dipendenze PRIMA degli import ────────────────────────────────────── vi.mock("sonner", () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), info: vi.fn(), error: vi.fn(), }), })); vi.mock("@/core/events", () => ({ eventRuntime: { emit: vi.fn() }, })); vi.mock("@/lib/streamManager", () => ({ streamManager: { cancel: vi.fn(), init: vi.fn() }, })); vi.mock("@/lib/checkpointManager", () => ({ onTaskEnd: vi.fn(), onStepDone: vi.fn(), })); vi.mock("@/lib/buildFailureHandler", () => ({ handleBuildFailure: vi.fn(), })); vi.mock("@/lib/sseActionInferer", () => ({ inferActionType: vi.fn(() => "tool_call"), inferActionLabel: vi.fn(() => "Tool"), inferActionDetail: vi.fn(() => undefined), })); vi.mock("@/lib/agentTelegram", () => ({ tgNotify: { taskStart: vi.fn(), taskComplete: vi.fn(), taskError: vi.fn() }, })); // agentSSEState: Maps vuote — _abGoal sarà "" → no auto-retry setTimeout vi.mock("@/lib/agentSSEState", () => ({ _taskParams: new Map(), _taskStarted: new Map(), _connectAt: new Map(), _thinkingTimers: new Map(), _taskTimers: new Map(), _streamingBuffer: new Map(), _bufferTimestamps: new Map(), })); // useTaskStore: mock singleton per le chiamate interne a handleEvent vi.mock("@/store/taskStore", () => ({ useTaskStore: { getState: vi.fn(() => ({ tasks: [], updateStatus: vi.fn(), addLog: vi.fn(), addAction: vi.fn(), updateStep: vi.fn(), })), }, })); // ─── Import dopo i mock ────────────────────────────────────────────────────── import { handleEvent } from "@/lib/agentSSEDispatch"; import { eventRuntime } from "@/core/events"; import { toast } from "sonner"; import type { SSEEvent } from "@/lib/agentSSETypes"; // ─── Helpers ───────────────────────────────────────────────────────────────── function makeStore(extra: Record = {}) { return { updateStatus: vi.fn(), addLog: vi.fn(), addAction: vi.fn(), updateStep: vi.fn(), tasks: [], ...extra, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; } /** Costruisce un SSEEvent task_aborted con abort_reason opzionale */ function abortEv(abort_reason?: string): SSEEvent { return { event: "task_aborted", taskId: TASK_ID, ...(abort_reason !== undefined && { abort_reason }), } as unknown as SSEEvent; } const TASK_ID = "test-task-mx16"; const _toast = toast as unknown as Record>; const _er = eventRuntime as unknown as { emit: ReturnType }; // ─── Suite ──────────────────────────────────────────────────────────────────── describe("agentSSEDispatch — task_aborted MX16-ABORT", () => { beforeEach(() => { vi.clearAllMocks(); // reset window.dispatchEvent per verificare no auto-retry vi.stubGlobal("dispatchEvent", vi.fn()); }); // R1: user_stop → toast.success (non toast.info) it("R1: user_stop → toast.success '✓ Task fermato'", () => { handleEvent(abortEv("user_stop"), TASK_ID, makeStore()); expect(_toast.success).toHaveBeenCalledWith( "✓ Task fermato", expect.objectContaining({ id: "task-aborted-toast" }), ); expect(_toast.info).not.toHaveBeenCalled(); }); // R2: user_stop → eventRuntime.emit NON chiamato (stop intenzionale ≠ errore) it("R2: user_stop → eventRuntime.emit NON chiamato", () => { handleEvent(abortEv("user_stop"), TASK_ID, makeStore()); expect(_er.emit).not.toHaveBeenCalled(); }); // R3: abort_reason:"system" → toast.info + eventRuntime.emit({kind:"task_error"}) it("R3: system → toast.info + eventRuntime task_error emesso", () => { handleEvent(abortEv("system"), TASK_ID, makeStore()); expect(_toast.info).toHaveBeenCalledWith( "Task interrotto — riavvio automatico in corso…", expect.objectContaining({ id: "task-aborted-toast" }), ); expect(_er.emit).toHaveBeenCalledWith( expect.objectContaining({ kind: "task_error" }), ); expect(_toast.success).not.toHaveBeenCalled(); }); // R4: abort_reason assente → default user_stop (no eventRuntime, toast.success) it("R4: abort_reason assente → default user_stop (no eventRuntime)", () => { handleEvent(abortEv(undefined), TASK_ID, makeStore()); expect(_er.emit).not.toHaveBeenCalled(); expect(_toast.success).toHaveBeenCalled(); }); // R5: store.updateStatus("CANCELLED") chiamato in entrambi i casi it("R5: updateStatus CANCELLED — user_stop", () => { const store = makeStore(); handleEvent(abortEv("user_stop"), TASK_ID, store); expect(store.updateStatus).toHaveBeenCalledWith(TASK_ID, "CANCELLED"); }); it("R5b: updateStatus CANCELLED — system", () => { const store = makeStore(); handleEvent(abortEv("system"), TASK_ID, store); expect(store.updateStatus).toHaveBeenCalledWith(TASK_ID, "CANCELLED"); }); });