import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises"; import { assert, expect, test } from "vite-plus/test"; import { isNotFound } from "#common"; import type { RuntimeFailure, RuntimeManifest, RuntimeOutcome } from "#runtime"; import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing"; import { type Fetcher, type PostContext, postRuntime } from "./post.ts"; const context: PostContext = { apiUrl: "https://gitea.example/api/v1", repository: "owner/repo", issueIndex: "7", eventName: "issue_comment", }; interface Fixture { manifestPath: string; runtimeDir: string; reviewerFile: string; reviewPath: string; outcomeFile: string; } interface FixtureSuccess { ok: true; body: string; } type FixtureSource = RuntimeFailure | FixtureSuccess; // One request the fake Gitea received. interface RecordedRequest { url: string; init: RequestInit; } async function writePrivate(path: string, text: string): Promise { await writeFile(path, text, { mode: 0o600 }); await chmod(path, 0o600); } async function writeArtifacts( outcome: RuntimeOutcome, review?: string, anchors?: unknown, ): Promise { if (!outcome.ok) return; if (review !== undefined) await writePrivate(outcome.reviewPath, review); if (anchors !== undefined) await writePrivate(outcome.anchorsPath, JSON.stringify(anchors)); } function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest { return { runtimeDir, manifestPath: `${runtimeDir}/manifest.json`, reviewerFile: `${runtimeDir}/reviewer-token`, outcomeFile: `${runtimeDir}/outcome.json`, profiles: [ { index: 0, type: "claude", model: "test", uid: currentUid(), gid: currentGid(), home: profileHome, tokenFile: null, }, ], }; } async function fixture( source: FixtureSource, review?: string, anchors?: unknown, ): Promise { const runtimeDir = await makeTempDir(); const profileHome = `${runtimeDir}/profile-0`; const manifest = manifestFor(runtimeDir, profileHome); await mkdir(profileHome); await writeFile(manifest.reviewerFile, "reviewer-secret"); const outcome: RuntimeOutcome = source.ok ? { ...source, profile: { type: "codex", model: "gpt-test" }, reviewPath: `${profileHome}/review.md`, anchorsPath: `${profileHome}/anchors.json`, } : source; await writeFile(manifest.outcomeFile, JSON.stringify(outcome)); await writeArtifacts(outcome, review, anchors); await writeFile(manifest.manifestPath, JSON.stringify(manifest)); const { manifestPath, reviewerFile, outcomeFile } = manifest; return { manifestPath, runtimeDir, reviewerFile, reviewPath: `${profileHome}/review.md`, outcomeFile, }; } const missing = async (path: string): Promise => { try { await stat(path); return false; } catch (error) { if (isNotFound(error)) return true; throw error; } }; // The JSON body of one request the fake Gitea received. function requestBody(init?: RequestInit): unknown { return typeof init?.body === "string" ? JSON.parse(init.body) : undefined; } // A fake Gitea that records each request body and answers with `status`. function recordingFetch(bodies: unknown[], status = 201): Fetcher { return (_url, init) => { bodies.push(requestBody(init)); return Promise.resolve(new Response(null, { status })); }; } test("posts a successful issue result and removes runtime credentials", async () => { const result = await fixture({ ok: true, body: "done" }); const requests: RecordedRequest[] = []; await postRuntime(result.manifestPath, context, (url, init) => { requests.push({ url, init }); return Promise.resolve(new Response(null, { status: 201 })); }); expect(requests.length).toBe(1); const [request] = requests; expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`); expect(requestBody(request?.init)).toStrictEqual({ body: "done\n\n> Profile: `codex` · `gpt-test`", }); expect(request?.init?.headers).toStrictEqual({ Authorization: "token reviewer-secret", "Content-Type": "application/json", }); assert(await missing(result.runtimeDir)); }); test("retries a rejected inline review without anchors", async () => { const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [ { path: "run.ts", line: 1, side: "new", body: "Finding" }, ]); const bodies: unknown[] = []; await postRuntime( result.manifestPath, { ...context, eventName: "pull_request", }, (_url, init) => { bodies.push(requestBody(init)); return Promise.resolve(new Response(null, { status: bodies.length === 1 ? 422 : 200 })); }, ); expect(bodies[0]).toMatchObject({ comments: [{ body: "Finding\n\n> Profile: `codex` · `gpt-test`" }], }); expect(bodies[1]).toMatchObject({ comments: [], body: "✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`", }); assert(await missing(result.runtimeDir)); }); test("reports each tried profile and keeps the action failed", async () => { const result = await fixture({ ok: false, failures: [ { type: "codex", model: "gpt-test", reason: "sign-in failed" }, { type: "claude", model: "claude-test", reason: "usage exhausted" }, { type: "opencode", model: "openrouter/test", reason: "failed" }, ], }); const bodies: unknown[] = []; await expect(() => postRuntime(result.manifestPath, context, recordingFetch(bodies)), ).rejects.toThrow("agent phase failed"); expect(bodies).toStrictEqual([ { body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (sign-in failed), " + "`claude` · `claude-test` (usage exhausted), " + "`opencode` · `openrouter/test` (failed)", }, ]); assert(!JSON.stringify(bodies).includes("reviewer-secret")); assert(await missing(result.runtimeDir)); }); test("drops failure entries that are not known labels", async () => { const result = await fixture({ ok: false, failures: [] }); await writeFile( result.outcomeFile, JSON.stringify({ ok: false, failures: [ { type: "codex", model: "gpt-test", reason: "token sk-secret leaked" }, { type: "shell", model: "x", reason: "failed" }, { type: "claude", model: "claude-test", reason: "sign-in failed" }, ], }), ); const bodies: unknown[] = []; await expect(() => postRuntime(result.manifestPath, context, recordingFetch(bodies)), ).rejects.toThrow("agent phase failed"); expect(bodies).toStrictEqual([ { body: "AI bot failed.\n\n> Tried: `claude` · `claude-test` (sign-in failed)", }, ]); }); test("reports a generic failure when the runner wrote no outcome", async () => { const result = await fixture({ ok: false, failures: [] }); await rm(result.outcomeFile); const bodies: unknown[] = []; await expect(() => postRuntime(result.manifestPath, context, recordingFetch(bodies)), ).rejects.toThrow("agent phase failed"); expect(bodies).toStrictEqual([{ body: "AI bot failed.\n\n> Profile: none" }]); assert(await missing(result.runtimeDir)); }); test("removes runtime credentials when posting fails", async () => { const result = await fixture({ ok: true, body: "done" }); await expect(() => postRuntime(result.manifestPath, context, () => Promise.resolve(new Response("failed", { status: 500 })), ), ).rejects.toThrow("500"); assert(await missing(result.runtimeDir)); }); test("preserves a Gitea transport failure without retrying a comment", async () => { const result = await fixture({ ok: true, body: "done" }); let requests = 0; await expect(() => postRuntime(result.manifestPath, context, () => { requests += 1; return Promise.reject(new Error(`network failure ${requests}`)); }), ).rejects.toThrow("network failure 1"); expect(requests).toBe(1); assert(await missing(result.runtimeDir)); }); test.skipIf(IS_WINDOWS)("refuses to follow an agent artifact symlink", async () => { const result = await fixture({ ok: true, body: "done" }); await symlink(result.reviewerFile, result.reviewPath); let requests = 0; await expect(() => postRuntime(result.manifestPath, context, () => { requests += 1; return Promise.resolve(new Response(null, { status: 200 })); }), ).rejects.toThrow("regular private file"); expect(requests).toBe(1); assert(await missing(result.runtimeDir)); }); test("reports an attributed failure when a pull request review is missing", async () => { const result = await fixture({ ok: true, body: "done" }); const bodies: unknown[] = []; await expect(() => postRuntime( result.manifestPath, { ...context, eventName: "pull_request", }, recordingFetch(bodies), ), ).rejects.toThrow("did not write"); expect(bodies).toStrictEqual([ { body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)", }, ]); assert(await missing(result.runtimeDir)); });