All per-check artifacts, all-summary, and export move from pygienium/ to .pygienium/; ensureRunStateIgnored appends .pygienium/ to the target repo's .gitignore on first run (opt out with --no-gitignore), so a run never stages its own output. Export now reads a single canonical root.
423 lines
14 KiB
TypeScript
423 lines
14 KiB
TypeScript
/**
|
|
* 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/<name>/`).
|
|
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<void>): Promise<string[]> {
|
|
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<typeof markCheckStatus>[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<typeof trackingRunner>;
|
|
|
|
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<typeof initRunState> }> {
|
|
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 old non-hidden 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"));
|
|
});
|
|
});
|