/** * ciLogReader.test.ts — Unit test S26 * * Testa: * - getLatestRun: parsing della risposta GitHub API * - _fetchErrors: estrazione errori TS da log grezzo * - waitForCI: loop + backoff + timeout (con fake timers) * - Resilienza: fetch fallisce, API non OK, log vuoti * * Mock: fetch (globale), @/core/events, @/lib/runtimeLogger, @/lib/github */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("@/core/events", () => ({ eventRuntime: { emit: vi.fn() }, })); vi.mock("@/lib/runtimeLogger", () => ({ runtimeLogger: { log: vi.fn() }, })); vi.mock("@/lib/github", () => ({ getGHToken: vi.fn().mockReturnValue("test-token"), })); import { ciLogReader } from "@/lib/ciLogReader"; import type { CIRun } from "@/lib/ciLogReader"; // ─── Helpers ────────────────────────────────────────────────────────────────── function makeRunResponse(overrides: Partial<{ status: string; conclusion: string | null; head_sha: string; }> = {}) { return { ok: true, json: async () => ({ workflow_runs: [{ id: 42, name: "Vercel Auto Deploy", status: 'status' in overrides ? overrides.status! : "completed", conclusion: 'conclusion' in overrides ? overrides.conclusion : "success", head_sha: overrides.head_sha ?? "abc1234", html_url: "https://github.com", created_at: "2026-05-19T08:00:00Z", }], }), }; } function makeJobsResponse(conclusion: string = "failure") { return { ok: true, json: async () => ({ jobs: [{ id: 99, conclusion }] }), }; } function makeLogsResponse(log: string) { return { ok: true, text: async () => log }; } // ─── getLatestRun ───────────────────────────────────────────────────────────── describe("getLatestRun", () => { beforeEach(() => vi.clearAllMocks()); it("ritorna CIRun con i campi corretti da risposta GitHub API", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(makeRunResponse({ status: "completed", conclusion: "success", head_sha: "deadbeef", }))); const run = await ciLogReader.getLatestRun("Baida98", "AI"); expect(run).not.toBeNull(); expect(run!.id).toBe(42); expect(run!.status).toBe("completed"); expect(run!.conclusion).toBe("success"); expect(run!.commitSha).toBe("deadbeef"); expect(run!.url).toBe("https://github.com"); vi.unstubAllGlobals(); }); it("ritorna null se fetch fallisce", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Network error"))); const run = await ciLogReader.getLatestRun("Baida98", "AI"); expect(run).toBeNull(); vi.unstubAllGlobals(); }); it("ritorna null se API risponde non-OK", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 401 })); const run = await ciLogReader.getLatestRun("Baida98", "AI"); expect(run).toBeNull(); vi.unstubAllGlobals(); }); it("ritorna null se workflow_runs è array vuoto", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ workflow_runs: [] }), })); const run = await ciLogReader.getLatestRun("Baida98", "AI"); expect(run).toBeNull(); vi.unstubAllGlobals(); }); it("gestisce run con conclusion=null (in_progress)", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(makeRunResponse({ status: "in_progress", conclusion: null, }))); const run = await ciLogReader.getLatestRun("Baida98", "AI"); expect(run!.status).toBe("in_progress"); expect(run!.conclusion).toBeNull(); vi.unstubAllGlobals(); }); }); // ─── _fetchErrors ───────────────────────────────────────────────────────────── describe("_fetchErrors", () => { beforeEach(() => vi.clearAllMocks()); it("estrae errori TypeScript dal log grezzo (pattern TS2353)", async () => { const rawLog = [ "2026-05-19T08:11:29Z ##[group]Run typecheck", "src/lib/ciLogReader.ts(71,7): error TS2353: Object literal may only specify known properties, and 'log' does not exist in type 'CIError'.", "src/lib/actionPlanner.ts(102,5): error TS2345: Argument of type 'string' is not assignable.", "2026-05-19T08:11:29Z ##[error]Process completed with exit code 2.", ].join("\n"); const fetchMock = vi.fn() .mockResolvedValueOnce(makeJobsResponse("failure")) // jobs list .mockResolvedValueOnce(makeLogsResponse(rawLog)); // job logs vi.stubGlobal("fetch", fetchMock); const { errors, rawLog: returned } = await ciLogReader._fetchErrors( "Baida98", "AI", 100, "task-001", ); expect(errors).toHaveLength(3); // 2 TS errors + 1 generico ##[error] expect(errors[0].step).toBe("typecheck"); expect(errors[0].message).toContain("TS2353"); expect(errors[0].file).toBe("src/lib/ciLogReader.ts"); expect(errors[0].line).toBe(71); expect(errors[1].file).toBe("src/lib/actionPlanner.ts"); expect(errors[1].line).toBe(102); expect(errors[2].step).toBe("ci"); expect(returned).toContain("TS2353"); vi.unstubAllGlobals(); }); it("ritorna errors=[] se nessun job fallito", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(makeJobsResponse("success"))); const { errors } = await ciLogReader._fetchErrors("Baida98", "AI", 100, "task-002"); expect(errors).toHaveLength(0); vi.unstubAllGlobals(); }); it("non duplica errori già parsati come TS nel fallback generico", async () => { // La dedup funziona quando ##[error] contiene il messaggio breve TS (es. 'TS2304: ....') // e NON la riga intera (che verrebbe riconosciuta di nuovo dal tsPattern) const rawLog = [ "src/lib/foo.ts(10,5): error TS2304: Cannot find name 'x'.", "##[error]TS2304: Cannot find name 'x'.", // breve — viene deduplicato "##[error]Process completed with exit code 2.", // generico — viene aggiunto ].join("\n"); vi.stubGlobal("fetch", vi.fn() .mockResolvedValueOnce(makeJobsResponse("failure")) .mockResolvedValueOnce(makeLogsResponse(rawLog))); const { errors } = await ciLogReader._fetchErrors("Baida98", "AI", 100, "task-003"); const ts2304 = errors.filter(e => e.message.includes("TS2304")); expect(ts2304).toHaveLength(1); // solo 1: il ##[error]TS2304 è stato deduplicato expect(errors.some(e => e.message.includes("exit code 2"))).toBe(true); vi.unstubAllGlobals(); }); it("gestisce log che superano 50KB (tronca al max)", async () => { const bigLog = "x".repeat(100_000); // 100KB vi.stubGlobal("fetch", vi.fn() .mockResolvedValueOnce(makeJobsResponse("failure")) .mockResolvedValueOnce(makeLogsResponse(bigLog))); const { rawLog } = await ciLogReader._fetchErrors("Baida98", "AI", 100, "task-004"); expect(rawLog.length).toBeLessThanOrEqual(50_000); vi.unstubAllGlobals(); }); it("ritorna errors=[] se fetch fallisce", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Network"))); const { errors } = await ciLogReader._fetchErrors("Baida98", "AI", 100, "task-005"); expect(errors).toHaveLength(0); vi.unstubAllGlobals(); }); it("max 20 errori anche con log con 50 errori TS", async () => { const lines = Array.from({ length: 50 }, (_, i) => `src/lib/file${i}.ts(${i + 1},1): error TS${2000 + i}: Error number ${i}.` ); const rawLog = lines.join("\n"); vi.stubGlobal("fetch", vi.fn() .mockResolvedValueOnce(makeJobsResponse("failure")) .mockResolvedValueOnce(makeLogsResponse(rawLog))); const { errors } = await ciLogReader._fetchErrors("Baida98", "AI", 100, "task-006"); expect(errors.length).toBeLessThanOrEqual(20); vi.unstubAllGlobals(); }); }); // ─── waitForCI ──────────────────────────────────────────────────────────────── describe("waitForCI", () => { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); it("ritorna success quando il CI completa al primo poll", async () => { // Mock fetch: prima call → completed success vi.stubGlobal("fetch", vi.fn().mockResolvedValue(makeRunResponse({ status: "completed", conclusion: "success", head_sha: "abc1234", }))); const promise = ciLogReader.waitForCI("Baida98", "AI", "abc1234", "task-100"); await vi.advanceTimersByTimeAsync(5_000); // scatta il setTimeout iniziale const result = await promise; expect(result.status).toBe("success"); expect(result.run).not.toBeNull(); expect(result.errors).toHaveLength(0); expect(result.durationMs).toBeGreaterThanOrEqual(0); }); it("ritorna timeout se timeoutMs=0 (loop non entra mai)", async () => { vi.stubGlobal("fetch", vi.fn()); const promise = ciLogReader.waitForCI("Baida98", "AI", "xyz", "task-101", 0); const result = await promise; expect(result.status).toBe("timeout"); expect(result.run).toBeNull(); expect(result.errors).toHaveLength(0); }); it("ritorna failure e popola errors quando CI fallisce", async () => { const rawLog = "src/lib/x.ts(5,3): error TS9999: Something broke."; vi.stubGlobal("fetch", vi.fn() .mockResolvedValueOnce(makeRunResponse({ status: "completed", conclusion: "failure", head_sha: "fail01" })) .mockResolvedValueOnce(makeJobsResponse("failure")) .mockResolvedValueOnce(makeLogsResponse(rawLog))); const promise = ciLogReader.waitForCI("Baida98", "AI", "fail01", "task-102"); await vi.advanceTimersByTimeAsync(5_000); const result = await promise; expect(result.status).toBe("failure"); expect(result.errors.length).toBeGreaterThan(0); expect(result.errors[0].message).toContain("TS9999"); }); it("ignora run con commitSha diverso e continua a pollare", async () => { // Prima risposta: run di un altro commit → viene ignorata // Seconda risposta (dopo avanzamento timer): run del commit corretto const wrongRun = { ok: true, json: async () => ({ workflow_runs: [{ id: 1, name: "CI", status: "completed", conclusion: "success", head_sha: "other_sha", html_url: "https://gh", created_at: "", }], }), }; vi.stubGlobal("fetch", vi.fn() .mockResolvedValueOnce(wrongRun) .mockResolvedValueOnce(makeRunResponse({ head_sha: "correct_sha", status: "completed", conclusion: "success" }))); const promise = ciLogReader.waitForCI("Baida98", "AI", "correct_sha", "task-103"); await vi.advanceTimersByTimeAsync(5_000); // primo poll → wrong commit (ignorato) await vi.advanceTimersByTimeAsync(10_000); // secondo poll → corretto const result = await promise; expect(result.status).toBe("success"); expect(result.run!.commitSha).toBe("correct_sha"); }); });