876c6de6d2
Lane Pipeline Pass Rates read `lanes.stages`, which is a snapshot of the
pass in flight: re-entering a node overwrites its record, and moving
backwards deletes every record ahead of the new stage. The report treated
one lane row as one run, so shipping ten features through a lane still
counted as one, and no node could ever exceed 1/1. History survived only
via `lane_features`, which is written when a feature is activated — a
path most users never take. The panel was not wrong, it was structurally
incapable of counting.
Add `lane_stage_events`, an append-only log written by every declaration
and every accepted detection, and aggregate from that. A run is one pass
through the template: the log starts a new one when the lane moves
backwards or leaves the template's last node. Existing snapshots are
seeded as one run each on first boot, so upgrading does not read as
"never run". Templates a lane is configured with are still listed at
zero — a pipeline going unused has to be visible as such.
Count inferred reaches too, in their own column and their own colour.
Detection stays out of the pass rate ("inference is never evidence"), but
reporting a stage the hooks demonstrably saw as "never reached" is what
made the panel look broken to anyone who works without running the
pipeline commands.
Also reject a stage naming no node of the lane's pipeline (400 EBADSTAGE,
listing the ones that exist) instead of storing it with a warning nobody
reads. A real lane had accumulated five such keys, one of them `test` on
a ship-feature lane whose CI node is `gates` — which is why that row read
never-reached while the stage had in fact been declared. Aliases still
resolve, so shorthand is unaffected.
194 lines
7.2 KiB
TypeScript
194 lines
7.2 KiB
TypeScript
/**
|
|
* @file Tests for the Workflows page's pipeline pass-rate panel: it renders one
|
|
* row per node of the busiest pipeline, distinguishes a proved pass from a
|
|
* merely claimed one, and switches templates through the picker. Conflating
|
|
* evidence with a bare claim is the failure that matters here — it would make
|
|
* an undocumented stage look verified. Also covers the per-node driver command:
|
|
* shown and copyable when the template names one, absent when it does not.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
|
import { LanePipelinePanel } from "../LanePipelinePanel";
|
|
|
|
const statsMock = vi.fn();
|
|
|
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
return { ...actual, api: { lanes: { pipelineStats: (id?: number | null) => statsMock(id) } } };
|
|
});
|
|
|
|
const node = (id: string, over: Record<string, number | string> = {}) => ({
|
|
id,
|
|
label: id,
|
|
icon: "🧭",
|
|
gate: false,
|
|
command: "",
|
|
entered: 0,
|
|
passed: 0,
|
|
withEvidence: 0,
|
|
failed: 0,
|
|
detected: 0,
|
|
...over,
|
|
});
|
|
|
|
const STATS = {
|
|
pipelines: [
|
|
{
|
|
id: "default",
|
|
name: "Default feature pipeline",
|
|
runs: 4,
|
|
nodes: [
|
|
node("plan", {
|
|
entered: 4,
|
|
passed: 4,
|
|
withEvidence: 4,
|
|
command: "/superpowers:writing-plans",
|
|
}),
|
|
node("implement", { entered: 4, passed: 4, withEvidence: 0 }),
|
|
node("tests", { entered: 3, passed: 2, withEvidence: 2, failed: 1 }),
|
|
node("ship"),
|
|
],
|
|
},
|
|
{ id: "hotfix", name: "Hotfix", runs: 1, nodes: [node("patch", { entered: 1, passed: 1 })] },
|
|
],
|
|
lanes: [
|
|
{ id: 1, title: "api lane", cwd: "/w/api" },
|
|
{ id: 2, title: "", cwd: "/w/ui" },
|
|
],
|
|
};
|
|
|
|
describe("LanePipelinePanel per-node command", () => {
|
|
it("shows the driver command under a node that declares one, and nothing for a node that does not", async () => {
|
|
statsMock.mockResolvedValue(STATS);
|
|
render(<LanePipelinePanel />);
|
|
|
|
const plan = await screen.findByTestId("pipeline-stat-command-plan");
|
|
expect(plan).toHaveTextContent("/superpowers:writing-plans");
|
|
// `tests` carries no command in the fixture: an empty string must render
|
|
// nothing at all, never an empty clickable row.
|
|
expect(screen.queryByTestId("pipeline-stat-command-tests")).toBeNull();
|
|
});
|
|
|
|
it("copies the command to the clipboard on click", async () => {
|
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
Object.defineProperty(navigator, "clipboard", {
|
|
value: { writeText },
|
|
configurable: true,
|
|
});
|
|
statsMock.mockResolvedValue(STATS);
|
|
render(<LanePipelinePanel />);
|
|
|
|
fireEvent.click(await screen.findByTestId("pipeline-stat-command-plan"));
|
|
|
|
expect(writeText).toHaveBeenCalledWith("/superpowers:writing-plans");
|
|
});
|
|
});
|
|
|
|
describe("LanePipelinePanel", () => {
|
|
it("shows pass count, entries and rate per node of the busiest pipeline", async () => {
|
|
statsMock.mockResolvedValue(STATS);
|
|
render(<LanePipelinePanel />);
|
|
|
|
const tests = await screen.findByTestId("pipeline-stat-tests");
|
|
expect(within(tests).getByText("2")).toBeInTheDocument();
|
|
expect(tests).toHaveTextContent("2/3");
|
|
expect(tests).toHaveTextContent("67%");
|
|
});
|
|
|
|
it("draws a claimed pass in a different colour from a proved one", async () => {
|
|
statsMock.mockResolvedValue(STATS);
|
|
render(<LanePipelinePanel />);
|
|
|
|
const proved = await screen.findByTestId("pipeline-stat-plan");
|
|
const claimed = screen.getByTestId("pipeline-stat-implement");
|
|
// Same counts, different bar: plan is all evidence, implement is all claim.
|
|
expect(proved.querySelector(".bg-status-success")).toHaveStyle({ width: "100%" });
|
|
expect(claimed.querySelector(".bg-status-success")).toHaveStyle({ width: "0%" });
|
|
expect(claimed.querySelector(".bg-status-warning")).toHaveStyle({ width: "100%" });
|
|
});
|
|
|
|
it("marks a node that was never reached instead of showing 0%", async () => {
|
|
statsMock.mockResolvedValue(STATS);
|
|
render(<LanePipelinePanel />);
|
|
|
|
const ship = await screen.findByTestId("pipeline-stat-ship");
|
|
expect(ship).not.toHaveTextContent("%");
|
|
expect(ship).toHaveTextContent(/never reached/i);
|
|
});
|
|
|
|
it("separates a node the hooks saw from one that never happened", async () => {
|
|
// `gate` was reached in two runs but never declared. Reporting that as
|
|
// "never reached" is what made the panel look broken to someone who works
|
|
// without running the pipeline commands.
|
|
statsMock.mockResolvedValue({
|
|
...STATS,
|
|
pipelines: STATS.pipelines.map((p, i) =>
|
|
i === 0 ? { ...p, nodes: [...p.nodes, node("gate", { detected: 2 })] } : p
|
|
),
|
|
});
|
|
render(<LanePipelinePanel />);
|
|
|
|
const gate = await screen.findByTestId("pipeline-stat-gate");
|
|
expect(gate).not.toHaveTextContent(/never reached/i);
|
|
expect(gate).toHaveTextContent("2");
|
|
});
|
|
|
|
it("switches templates through the picker", async () => {
|
|
statsMock.mockResolvedValue(STATS);
|
|
render(<LanePipelinePanel />);
|
|
|
|
fireEvent.change(await screen.findByTestId("pipeline-stats-picker"), {
|
|
target: { value: "hotfix" },
|
|
});
|
|
|
|
expect(screen.getByTestId("pipeline-stat-patch")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("pipeline-stat-plan")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows the CLI hint when nothing has been recorded", async () => {
|
|
statsMock.mockResolvedValue({ pipelines: [], lanes: [] });
|
|
render(<LanePipelinePanel />);
|
|
|
|
expect(await screen.findByText(/agenthub lanes add/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("refetches narrowed to the lane picked, and falls back to a cwd for an untitled one", async () => {
|
|
statsMock.mockResolvedValue(STATS);
|
|
render(<LanePipelinePanel />);
|
|
|
|
const picker = await screen.findByTestId("pipeline-stats-lane-picker");
|
|
expect(within(picker).getByText("/w/ui")).toBeInTheDocument();
|
|
expect(statsMock).toHaveBeenLastCalledWith(null);
|
|
|
|
fireEvent.change(picker, { target: { value: "2" } });
|
|
expect(statsMock).toHaveBeenLastCalledWith(2);
|
|
});
|
|
|
|
it("keeps the lane picker reachable when the picked lane recorded nothing", async () => {
|
|
statsMock.mockResolvedValue(STATS);
|
|
render(<LanePipelinePanel />);
|
|
|
|
// Without a picker in the empty branch, narrowing to a quiet lane would be
|
|
// a one-way trip — no control left to select any other lane.
|
|
statsMock.mockResolvedValue({ pipelines: [], lanes: STATS.lanes });
|
|
fireEvent.change(await screen.findByTestId("pipeline-stats-lane-picker"), {
|
|
target: { value: "1" },
|
|
});
|
|
|
|
// Wait for the empty branch to take over, then re-query: the refetch swaps
|
|
// the whole subtree, so a handle grabbed before it is already detached.
|
|
await waitFor(() => expect(screen.queryByTestId("pipeline-stat-plan")).not.toBeInTheDocument());
|
|
expect(screen.getByTestId("pipeline-stats-lane-picker")).toBeInTheDocument();
|
|
expect(screen.queryByText(/agenthub lanes add/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("surfaces a failed fetch", async () => {
|
|
statsMock.mockRejectedValue(new Error("stats unreachable"));
|
|
render(<LanePipelinePanel />);
|
|
|
|
expect(await screen.findByText("stats unreachable")).toBeInTheDocument();
|
|
});
|
|
});
|