phases.ts becomes a live chat widget (spinner + tool-call tree) and the check-runner posts per-agent tool-call summaries and an expandable completion tree through a custom message renderer; footer.ts adds the static pipeline-overview status strip for single checks and /pygienium-all.
185 lines
6.2 KiB
TypeScript
185 lines
6.2 KiB
TypeScript
/**
|
|
* footer.test.ts — unit tests for the pipeline-overview footer (task: footer).
|
|
*
|
|
* The footer is presentation-only state: with no UI it tracks items but writes
|
|
* nothing; with a stub UI it renders one status line and clears on `done()`.
|
|
* These tests exercise the state machine (cursor demotion, terminal marks)
|
|
* without spinning up a runner.
|
|
*/
|
|
import { describe, expect, it } from "bun:test";
|
|
import {
|
|
createPipelineFooter,
|
|
footerPhaseItems,
|
|
FOOTER_GLYPH,
|
|
FOOTER_STATUS_KEY,
|
|
type ItemStatus,
|
|
} from "../src/footer.js";
|
|
import { PHASE_LABELS } from "../src/phases.js";
|
|
import { PHASE_ANALYSIS, PHASE_FIX, PHASE_RECON } from "../src/run-state.js";
|
|
|
|
/** Minimal UI stub capturing `setStatus(key, text)` calls in order. */
|
|
function stubUi(): {
|
|
ui: { setStatus: (key: string, text: string | undefined) => void };
|
|
calls: { key: string; text: string | undefined }[];
|
|
} {
|
|
const calls: { key: string; text: string | undefined }[] = [];
|
|
return {
|
|
calls,
|
|
ui: {
|
|
setStatus(key, text) {
|
|
calls.push({ key, text });
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Build the canonical phase-id list a scan-only check uses. */
|
|
function scanPhaseIds(): string[] {
|
|
return [PHASE_RECON, PHASE_ANALYSIS, PHASE_FIX];
|
|
}
|
|
|
|
describe("createPipelineFooter", () => {
|
|
it("is a no-op without a UI but still tracks item state", () => {
|
|
// hasUI false: setStatus must never be called.
|
|
const footer = createPipelineFooter({ hasUI: false });
|
|
footer.setPipeline("pygienium smoke", [
|
|
{ label: "Recon", status: "pending" },
|
|
]);
|
|
footer.setCursor(0);
|
|
footer.done();
|
|
// No UI → no observable side effect, but getItems reflects state.
|
|
expect(footer.getItems()[0]?.status).toBe("running");
|
|
expect(footer.getTitle()).toBe("pygienium smoke");
|
|
});
|
|
|
|
it("renders the full pipeline with the cursor running and clears on done", () => {
|
|
const { ui, calls } = stubUi();
|
|
const footer = createPipelineFooter({ ui, hasUI: true });
|
|
const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS);
|
|
|
|
footer.setPipeline("pygienium smoke", items, 0);
|
|
|
|
// One setStatus call, under the canonical key, listing every phase:
|
|
// cursor gets `▶ <label>`, the rest are glued `…<label>`.
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.key).toBe(FOOTER_STATUS_KEY);
|
|
expect(calls[0]?.text).toContain("pygienium smoke");
|
|
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.running} Recon`);
|
|
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.pending}Scanning`);
|
|
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.pending}Fixing`);
|
|
// The cursor item is marked running.
|
|
expect(footer.getItems()[0]?.status).toBe("running");
|
|
|
|
footer.done();
|
|
// done() pushes an undefined to clear the slot, then resets state.
|
|
const last = calls[calls.length - 1];
|
|
expect(last?.text).toBeUndefined();
|
|
expect(footer.getItems()).toHaveLength(0);
|
|
});
|
|
|
|
it("demotes the previous running item to pending when the cursor moves", () => {
|
|
const { ui } = stubUi();
|
|
const footer = createPipelineFooter({ ui, hasUI: true });
|
|
footer.setPipeline(
|
|
"pygienium smoke",
|
|
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
|
|
0,
|
|
);
|
|
// recon → complete, then advance to analysis.
|
|
footer.setItem(0, "complete");
|
|
footer.setCursor(1);
|
|
|
|
const items = footer.getItems();
|
|
expect(items[0]?.status).toBe("complete");
|
|
expect(items[1]?.status).toBe("running");
|
|
});
|
|
|
|
it("does not demote a terminal item when the cursor advances past it", () => {
|
|
const { ui } = stubUi();
|
|
const footer = createPipelineFooter({ ui, hasUI: true });
|
|
footer.setPipeline(
|
|
"pygienium smoke",
|
|
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
|
|
0,
|
|
);
|
|
footer.setCursor(0); // recon running
|
|
// Simulate analysis completing (cursor was already moved there) then
|
|
// jumping to fix: a completed phase must stay complete, not revert.
|
|
footer.setItem(0, "complete");
|
|
footer.setCursor(1); // analysis running
|
|
footer.setItem(1, "complete");
|
|
footer.setCursor(2); // fix running
|
|
const items = footer.getItems();
|
|
expect(items[0]?.status).toBe("complete");
|
|
expect(items[1]?.status).toBe("complete");
|
|
expect(items[2]?.status).toBe("running");
|
|
});
|
|
|
|
it("marks a skipped gate as every item skipped", () => {
|
|
const { ui } = stubUi();
|
|
const footer = createPipelineFooter({ ui, hasUI: true });
|
|
footer.setPipeline(
|
|
"pygienium comments",
|
|
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
|
|
);
|
|
// No cursor: all pending. Now a gate-skip marks every phase skipped,
|
|
// mirroring the runner's gate branch.
|
|
for (let i = 0; i < footer.getItems().length; i++) {
|
|
footer.setItem(i, "skipped" as ItemStatus);
|
|
}
|
|
expect(footer.getItems().every((it) => it.status === "skipped")).toBe(true);
|
|
});
|
|
|
|
it("can be disabled so it never touches the status slot", () => {
|
|
const { ui, calls } = stubUi();
|
|
const footer = createPipelineFooter({
|
|
ui,
|
|
hasUI: true,
|
|
enabled: false,
|
|
});
|
|
footer.setPipeline("pygienium smoke", [
|
|
{ label: "Recon", status: "pending" },
|
|
]);
|
|
footer.setCursor(0);
|
|
footer.done();
|
|
// enabled:false suppresses every setStatus call (used by /pygienium-all
|
|
// which owns its own footer).
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
it("writes under a custom status key (all-run owns its slot)", () => {
|
|
const { ui, calls } = stubUi();
|
|
const footer = createPipelineFooter({
|
|
ui,
|
|
hasUI: true,
|
|
statusKey: "pygienium-all",
|
|
});
|
|
footer.setPipeline(
|
|
"pygienium: all",
|
|
[
|
|
{ label: "comments", status: "pending" },
|
|
{ label: "dead-code", status: "pending" },
|
|
],
|
|
0,
|
|
);
|
|
expect(calls[0]?.key).toBe("pygienium-all");
|
|
expect(calls[0]?.text).toContain("pygienium: all");
|
|
// Cursor on the first check; second still pending (to come).
|
|
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.running} comments`);
|
|
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.pending}dead-code`);
|
|
});
|
|
});
|
|
|
|
describe("footerPhaseItems", () => {
|
|
it("maps phase ids to pending footer items using PHASE_LABELS", () => {
|
|
const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS);
|
|
expect(items.map((i) => i.label)).toEqual(["Recon", "Scanning", "Fixing"]);
|
|
expect(items.every((i) => i.status === "pending")).toBe(true);
|
|
});
|
|
|
|
it("falls back to the raw id for unknown phases", () => {
|
|
const items = footerPhaseItems(["custom"], PHASE_LABELS);
|
|
expect(items[0]?.label).toBe("custom");
|
|
});
|
|
});
|