AUDIT / src /lib /__tests__ /ciLogReader.test.ts
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 2)
cc11e77 verified
Raw
History Blame Contribute Delete
11.4 kB
/**
* 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");
});
});