Initial commit: pygenium as git submodule
This commit is contained in:
420
tests/status-resume-export.test.ts
Normal file
420
tests/status-resume-export.test.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* 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 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"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user