Spaces:
Sleeping
Sleeping
| /** | |
| * 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"); | |
| }); | |
| }); | |