/** * status-resume-export.test.ts — integration test for task 13. * * Mirrors the spec scenario: start a hypothetical `/pygienium-all`, treat it as * interrupted (one check complete, one pending), then exercise * `/pygienium-status`, `/pygienium-resume`, and `/pygienium-export` and assert: * * - status reports accurate per-check progress (one complete, one pending, * run still in_progress); * - resume re-dispatches the pending check and DOES NOT re-run the complete * one (proven via the agent-runner call log), and the run ends complete; * - --fresh re-dispatches even the complete check (proven via call log); * - export produces a filtered markdown bundle on disk, and the --check= * and --out=json filters work. * * A tracker wraps the fake agent runner so we can assert which checks were * actually dispatched without depending on timing or a model. */ import { describe, expect, it, beforeEach, afterEach } from "bun:test"; import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import { clearChecks, registerCheck, type CheckDefinition, } from "../src/checks/registry.js"; import { setAgentRunner, resetAgentRunner, fakeAgentRunner, type AgentRunner, } from "../src/agent-runner.js"; import { handleStatusCommand, handleResumeCommand, handleExportCommand, type PygieniumCtx, } from "../src/commands.js"; import { initRunState, loadRunState, saveRunState, markCheckStatus, recordCheckOutput, applyPhaseStatus, PHASE_RECON, PHASE_ANALYSIS, PHASE_FIX, PHASE_VERIFY, PHASE_CLEANUP, } from "../src/run-state.js"; import { formatRunStatus } from "../src/status.js"; import { exportRun, gatherExportEntries, renderExportJson, renderExportMarkdown, parseExportFilters, canonicalChecksRoot, } from "../src/export.js"; import type { AgentTaskOptions } from "../src/agent-runner.js"; /** Build a deterministic check whose fake runner writes on-disk artifacts. */ function fakeCheck(name: string): CheckDefinition { return { name, label: name, description: `${name} check`, agentName: "scanner", fixAgentName: "fixer", phaseId: "scan", buildScanTask: (_cwd, scope) => `!write pygienium/checks/${name}/findings.md # ${name} findings\nscan-target:${scope.target}\n!echo ${name}-scan`, buildFixTask: (_cwd, _scope, findings) => `!write pygienium/checks/${name}/changes.md # ${name} changes\nbased-on:${findings.split("\n")[0] ?? ""}\n!echo ${name}-fix`, gate: () => undefined, }; } function stubCtx(cwd: string): PygieniumCtx { return { cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx; } /** Tracker: records dispatched agent tasks then delegates to the fake runner. */ function trackingRunner(): { runner: AgentRunner; dispatched: string[] } { const dispatched: string[] = []; const runner: AgentRunner = async (opts) => { // Tag by check name from the task text (`!write pygienium/checks//`). const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task); if (m) dispatched.push(m[1] as string); return fakeAgentRunner(opts); }; return { runner, dispatched }; } /** Capture process.stdout.write lines for the duration of `fn`. */ async function captureStdout(fn: () => Promise): Promise { const out: string[] = []; const write = process.stdout.write.bind(process.stdout); (process.stdout as { write: (chunk: unknown) => boolean }).write = ( chunk: unknown, ) => { out.push(String(chunk).replace(/\r?\n$/, "")); return true; }; try { await fn(); } finally { (process.stdout as { write: (chunk: unknown) => boolean }).write = write; } return out; } /** Mark a check as fully complete in the run-state with captured output. */ function markComplete( state: Parameters[0], name: string, findings: string, changes: string, ): void { for (const phaseId of [ PHASE_RECON, PHASE_ANALYSIS, PHASE_FIX, PHASE_VERIFY, PHASE_CLEANUP, ]) { applyPhaseStatus(state, name, phaseId, "complete"); } recordCheckOutput(state, name, { findings, changes }); markCheckStatus(state, name, "complete"); } describe("status / resume / export (task 13)", () => { let cwd: string; let track: ReturnType; beforeEach(async () => { clearChecks(); track = trackingRunner(); setAgentRunner(track.runner); cwd = await mkdtemp(join(tmpdir(), "pygienium-resume-")); }); afterEach(async () => { resetAgentRunner(); await rm(cwd, { recursive: true, force: true }); }); /** Seed an interrupted two-check run: `alpha` complete, `beta` pending. */ async function seedInterruptedRun( fix = true, ): Promise<{ state: ReturnType }> { const state = initRunState(cwd, [ { name: "alpha", label: "alpha", fix }, { name: "beta", label: "beta", fix }, ]); state.recon = { complete: true, path: `${cwd}/.pygienium/recon.json`, finishedAt: Date.now(), }; // Simulate alpha fully complete with on-disk artifacts + captured text. markComplete( state, "alpha", "# alpha findings\nalpha-scan", "# alpha changes\nalpha-fix", ); const alphaDir = join(canonicalChecksRoot(cwd), "alpha"); await mkdir(alphaDir, { recursive: true }); await writeFile( join(alphaDir, "findings.md"), "# alpha findings\nalpha-scan\n", "utf8", ); await writeFile( join(alphaDir, "changes.md"), "# alpha changes\nalpha-fix\n", "utf8", ); // beta left pending (the interruption). For alpha's recon phase, mark it too. applyPhaseStatus(state, "beta", PHASE_RECON, "complete"); await saveRunState(state); return { state }; } it("formatRunStatus reports accurate per-check progress (alpha complete, beta pending)", async () => { const { state } = await seedInterruptedRun(); const lines = formatRunStatus(state); expect(lines.join("\n")).toContain("pygienium run — in_progress"); expect(lines.join("\n")).toContain("alpha — complete"); expect(lines.join("\n")).toContain("beta — pending"); // Artifacts captured on alpha appear; beta has none. expect(lines.join("\n")).toContain("findings: 2 line(s)"); expect(lines.join("\n")).toContain("changes: 2 line(s)"); }); it("/pygienium-status prints the status line list end to end", async () => { const { state } = await seedInterruptedRun(); const out = await captureStdout(() => handleStatusCommand("", stubCtx(cwd)), ); expect(out.length).toBeGreaterThan(0); expect(out.join("\n")).toContain("alpha — complete"); expect(out.join("\n")).toContain("beta — pending"); expect(out.join("\n")).toContain("pygienium run — in_progress"); void state; }); it("/pygienium-status with no run state prints a not-found message", async () => { const out = await captureStdout(() => handleStatusCommand("", stubCtx(cwd)), ); expect(out.join("\n")).toContain("no run state found"); }); it("/pygienium-resume re-dispatches beta without re-running complete alpha", async () => { registerCheck(fakeCheck("alpha")); registerCheck(fakeCheck("beta")); const { state } = await seedInterruptedRun(); void state; const out = await captureStdout(() => handleResumeCommand("", stubCtx(cwd)), ); // alpha is complete and must NOT be re-dispatched; beta was pending. expect(track.dispatched).not.toContain("alpha"); expect(track.dispatched).toContain("beta"); // The run should now be complete. const after = await loadRunState(cwd); expect(after?.status).toBe("complete"); expect(after?.checks.alpha.status).toBe("complete"); expect(after?.checks.beta.status).toBe("complete"); // beta's artifacts now exist on disk. const betaFindings = await readFile( join(canonicalChecksRoot(cwd), "beta", "findings.md"), "utf8", ); expect(betaFindings).toContain("beta findings"); // Summary mentions re-dispatched/skipped counts. expect(out.join("\n")).toContain("re-dispatched 1"); expect(out.join("\n")).toContain("skipped 1"); }); it("/pygienium-resume --fresh re-dispatches the complete check too", async () => { registerCheck(fakeCheck("alpha")); registerCheck(fakeCheck("beta")); await seedInterruptedRun(); await captureStdout(() => handleResumeCommand("--fresh", stubCtx(cwd))); expect(track.dispatched).toContain("alpha"); expect(track.dispatched).toContain("beta"); const after = await loadRunState(cwd); expect(after?.status).toBe("complete"); }); it("/pygienium-resume with no state prints nothing-to-resume", async () => { const out = await captureStdout(() => handleResumeCommand("", stubCtx(cwd)), ); expect(out.join("\n")).toContain("no run state to resume"); }); it("/pygienium-resume on an already-complete run refuses without --fresh", async () => { registerCheck(fakeCheck("alpha")); registerCheck(fakeCheck("beta")); const { state } = await seedInterruptedRun(); // Complete beta too so the whole run is complete. markComplete( state, "beta", "# beta findings\nbeta-scan", "# beta changes\nbeta-fix", ); await saveRunState(state); const out = await captureStdout(() => handleResumeCommand("", stubCtx(cwd)), ); expect(out.join("\n")).toContain("nothing to resume"); expect(track.dispatched).toHaveLength(0); }); it("/pygienium-export writes a markdown bundle with both checks", async () => { registerCheck(fakeCheck("alpha")); registerCheck(fakeCheck("beta")); const { state } = await seedInterruptedRun(); // Run beta via resume so its artifacts land on disk. await captureStdout(() => handleResumeCommand("", stubCtx(cwd))); void state; await captureStdout(() => handleExportCommand("", stubCtx(cwd))); const bundle = await readFile(join(cwd, "pygienium", "export.md"), "utf8"); expect(bundle).toContain("# Pygienium export"); expect(bundle).toContain("## alpha (complete)"); expect(bundle).toContain("## beta (complete)"); expect(bundle).toContain("# alpha findings"); expect(bundle).toContain("# beta findings"); }); it("/pygienium-export --check=beta produces a filtered bundle", async () => { registerCheck(fakeCheck("alpha")); registerCheck(fakeCheck("beta")); await seedInterruptedRun(); await captureStdout(() => handleResumeCommand("", stubCtx(cwd))); await captureStdout(() => handleExportCommand("--check=beta", stubCtx(cwd)), ); const bundle = await readFile(join(cwd, "pygienium", "export.md"), "utf8"); expect(bundle).toContain("## beta (complete)"); expect(bundle).not.toContain("## alpha"); }); it("/pygienium-export --status=failed includes only failed checks", async () => { registerCheck(fakeCheck("alpha")); registerCheck(fakeCheck("beta")); const { state } = await seedInterruptedRun(); // Mark alpha failed (artifacts already on disk from the seed); leave beta pending. markCheckStatus(state, "alpha", "failed", "fake failure"); await saveRunState(state); await captureStdout(() => handleExportCommand("--status=failed", stubCtx(cwd)), ); const bundle = await readFile(join(cwd, "pygienium", "export.md"), "utf8"); expect(bundle).toContain("## alpha (failed)"); expect(bundle).not.toContain("## beta"); }); it("/pygienium-export --out=json writes JSON matching renderExportJson", async () => { registerCheck(fakeCheck("alpha")); registerCheck(fakeCheck("beta")); await seedInterruptedRun(); await captureStdout(() => handleResumeCommand("", stubCtx(cwd))); const out = await captureStdout(() => handleExportCommand("--out=json", stubCtx(cwd)), ); expect(out.join("\n")).toContain("export.json"); const raw = await readFile(join(cwd, "pygienium", "export.json"), "utf8"); const parsed = JSON.parse(raw) as { checks: Array<{ name: string; status: string; findings: string }>; }; const names = parsed.checks.map((c) => c.name).sort(); expect(names).toEqual(["alpha", "beta"]); expect(parsed.checks.find((c) => c.name === "alpha")?.findings).toContain( "alpha findings", ); // renderExportJson matches the on-disk content for the gathered set. const state = await loadRunState(cwd); const entries = await gatherExportEntries(cwd, state); expect(renderExportJson(state, entries).trim()).toBe(raw.trim()); }); it("parseExportFilters splits comma lists and trims values", () => { const f = parseExportFilters( "--check=alpha,beta --status=complete,failed --out=json", ); expect(f.check).toEqual(["alpha", "beta"]); expect(f.status).toEqual(["complete", "failed"]); expect(f.out).toBe("json"); }); it("gatherExportEntries reads only the canonical pygienium/checks/ root", async () => { await mkdir(join(cwd, "pygienium", "checks", "alpha"), { recursive: true }); await writeFile( join(cwd, "pygienium", "checks", "alpha", "findings.md"), "# alpha findings\n", "utf8", ); // A stray .pygienium/checks/ dir (the removed legacy root) is ignored now // that all checks write to the single canonical `pygienium/checks/` root. await mkdir(join(cwd, ".pygienium", "checks", "ghost"), { recursive: true, }); await writeFile( join(cwd, ".pygienium", "checks", "ghost", "findings.md"), "# ghost findings\n", "utf8", ); const entries = await gatherExportEntries(cwd, undefined); const alpha = entries.find((e) => e.name === "alpha"); expect(alpha).toBeDefined(); expect(alpha?.findings).toContain("alpha findings"); expect(alpha?.status).toBe("unknown"); expect(alpha?.findingsPath).toBe( join(cwd, "pygienium", "checks", "alpha", "findings.md"), ); expect(entries.find((e) => e.name === "ghost")).toBeUndefined(); }); it("renderExportMarkdown includes a 'no artifacts' note for empty checks", async () => { const entries = [{ name: "ghost", status: "unknown" }]; const md = renderExportMarkdown(undefined, entries as never); expect(md).toContain("## ghost (unknown)"); expect(md).toContain("no findings.md or changes.md on disk"); // And renderExportJson emits nulls for the empty check. const json = renderExportJson(undefined, entries as never); const parsed = JSON.parse(json) as { checks: Array<{ findings: unknown; changes: unknown }>; }; expect(parsed.checks[0]!.findings).toBeNull(); expect(parsed.checks[0]!.changes).toBeNull(); }); it("exportRun writes nothing useful and reports zero entries cleanly", async () => { const result = await exportRun(cwd, undefined, {}); expect(result.entries).toHaveLength(0); expect(relative(cwd, result.path)).toBe(join("pygienium", "export.md")); }); });