feat(ui): ralpi-style chat progress and pipeline-overview footer
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.
This commit is contained in:
@@ -355,4 +355,28 @@ describe("/pygienium-all orchestrator (task 12)", () => {
|
||||
expect(joined).toContain("Beta");
|
||||
expect(joined).toContain("all [");
|
||||
});
|
||||
|
||||
it("renders only the unified widget in UI mode (no per-check spinner)", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
const calls: Array<[string, string[] | undefined]> = [];
|
||||
const ui = {
|
||||
setWidget: (key: string, content: string[] | undefined) => {
|
||||
calls.push([key, content]);
|
||||
},
|
||||
} as never; // stub ExtensionUIContext (tests have no pi type imports)
|
||||
|
||||
await runAllChecks({ cwd, ui, hasUI: true });
|
||||
|
||||
const keys = new Set(calls.map(([k]) => k));
|
||||
// The unified strip drives the widget area…
|
||||
expect(keys.has("pygienium-all-progress")).toBe(true);
|
||||
// …and per-check strips never claim it, so no second spinner can
|
||||
// flicker/swap against the unified one.
|
||||
expect(keys.has("pygienium-progress")).toBe(false);
|
||||
// The widget is cleared when the run completes.
|
||||
const last = calls[calls.length - 1]!;
|
||||
expect(last[0]).toBe("pygienium-all-progress");
|
||||
expect(last[1]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
184
tests/footer.test.ts
Normal file
184
tests/footer.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user