feat(workflows): pipeline pass rates and skill/plugin usage
Restores the five analytics sections that turned out to be worth keeping (patterns, model delegation, error propagation, session complexity, compaction impact) and reworks what the page opens on. The lane panel no longer mirrors the Workspace's live lane strip. It now answers the question the strip cannot: how often each pipeline node actually passes. GET /api/lanes/pipeline-stats aggregates every stage record of every lane AND every archived feature — a lane's own `stages` only holds its current feature, so without the archive the history resets each time a lane starts new work. Evidence-backed passes are counted apart from bare claims, the same line the pipeline map draws between `done` and `passed-no-evidence`. Beside it, GET /api/workflows/skill-usage reports which skills and plugins the sessions actually reach for. Only PreToolUse rows are counted: every call also emits a PostToolUse, and counting both would double every number. A plugin's total includes calls to the MCP tools it ships, so one used purely through MCP does not read as unused. Still dropped from issue #6: the agent collaboration network and the subagent effectiveness scorecards.
This commit is contained in:
@@ -1,115 +1,106 @@
|
||||
/**
|
||||
* @file Tests for the Workflows page's lane pipeline panel: it lists every lane
|
||||
* as a tile, shows the first lane's pipeline before anything is clicked, and
|
||||
* swaps the map when another lane is selected. A wrong selection would show one
|
||||
* lane's stage under another lane's name.
|
||||
* @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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { render, screen, fireEvent, within } from "@testing-library/react";
|
||||
import { LanePipelinePanel } from "../LanePipelinePanel";
|
||||
|
||||
const { LANES } = vi.hoisted(() => {
|
||||
const laneStub = (id: number, stageId: string) => ({
|
||||
id,
|
||||
title: `lane-${id}`,
|
||||
cwd: `/workspace/lane-${id}`,
|
||||
branch: null,
|
||||
kind: "adopted",
|
||||
source_repo: null,
|
||||
pipeline: "default",
|
||||
session_id: null,
|
||||
run_id: null,
|
||||
stage: stageId,
|
||||
stage_since: null,
|
||||
status: "idle",
|
||||
gate_decision: null,
|
||||
ci_status: null,
|
||||
needs_action: null,
|
||||
links: {},
|
||||
stages: {},
|
||||
notes: null,
|
||||
pipeline_name: "Default",
|
||||
pipeline_nodes: [
|
||||
{
|
||||
id: stageId,
|
||||
label: stageId,
|
||||
icon: "🧭",
|
||||
gate: false,
|
||||
state: "current",
|
||||
},
|
||||
],
|
||||
progress: 25,
|
||||
stage_seconds: null,
|
||||
last_event_seconds: null,
|
||||
liveness: "idle",
|
||||
detected_stage: null,
|
||||
detected_signal: null,
|
||||
slot: null,
|
||||
ports: {},
|
||||
active_feature_id: null,
|
||||
});
|
||||
return { LANES: [laneStub(1, "plan"), laneStub(2, "tests")] };
|
||||
});
|
||||
|
||||
const listMock = vi.fn();
|
||||
const statsMock = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { lanes: { list: () => listMock() } } };
|
||||
return { ...actual, api: { lanes: { pipelineStats: () => statsMock() } } };
|
||||
});
|
||||
|
||||
function renderPanel() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<LanePipelinePanel />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
const node = (id: string, over: Record<string, number> = {}) => ({
|
||||
id,
|
||||
label: id,
|
||||
icon: "🧭",
|
||||
gate: false,
|
||||
entered: 0,
|
||||
passed: 0,
|
||||
withEvidence: 0,
|
||||
failed: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
const STATS = {
|
||||
pipelines: [
|
||||
{
|
||||
id: "default",
|
||||
name: "Default feature pipeline",
|
||||
runs: 4,
|
||||
nodes: [
|
||||
node("plan", { entered: 4, passed: 4, withEvidence: 4 }),
|
||||
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 })] },
|
||||
],
|
||||
};
|
||||
|
||||
describe("LanePipelinePanel", () => {
|
||||
it("shows the first lane's pipeline before any tile is clicked", async () => {
|
||||
listMock.mockResolvedValue({
|
||||
lanes: LANES,
|
||||
counts: { total: 2, running: 0, needs_you: 0, dead: 0 },
|
||||
});
|
||||
renderPanel();
|
||||
it("shows pass count, entries and rate per node of the busiest pipeline", async () => {
|
||||
statsMock.mockResolvedValue(STATS);
|
||||
render(<LanePipelinePanel />);
|
||||
|
||||
expect(await screen.findByTestId("lane-tile-1")).toHaveAttribute("data-selected", "true");
|
||||
expect(screen.getByTestId("pipeline-node-plan")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pipeline-node-tests")).not.toBeInTheDocument();
|
||||
const tests = await screen.findByTestId("pipeline-stat-tests");
|
||||
expect(within(tests).getByText("2")).toBeInTheDocument();
|
||||
expect(tests).toHaveTextContent("2/3");
|
||||
expect(tests).toHaveTextContent("67%");
|
||||
});
|
||||
|
||||
it("swaps the pipeline map when another lane is selected", async () => {
|
||||
listMock.mockResolvedValue({
|
||||
lanes: LANES,
|
||||
counts: { total: 2, running: 0, needs_you: 0, dead: 0 },
|
||||
});
|
||||
renderPanel();
|
||||
it("draws a claimed pass in a different colour from a proved one", async () => {
|
||||
statsMock.mockResolvedValue(STATS);
|
||||
render(<LanePipelinePanel />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("lane-tile-2"));
|
||||
|
||||
expect(screen.getByTestId("pipeline-node-tests")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pipeline-node-plan")).not.toBeInTheDocument();
|
||||
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("renders the CLI hint when no lane exists", async () => {
|
||||
listMock.mockResolvedValue({
|
||||
lanes: [],
|
||||
counts: { total: 0, running: 0, needs_you: 0, dead: 0 },
|
||||
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("%");
|
||||
});
|
||||
|
||||
it("switches templates through the picker", async () => {
|
||||
statsMock.mockResolvedValue(STATS);
|
||||
render(<LanePipelinePanel />);
|
||||
|
||||
fireEvent.change(await screen.findByTestId("pipeline-stats-picker"), {
|
||||
target: { value: "hotfix" },
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
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: [] });
|
||||
render(<LanePipelinePanel />);
|
||||
|
||||
expect(await screen.findByText(/ccam lanes add/)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workflows-lane-strip")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a failed fetch instead of an empty strip", async () => {
|
||||
listMock.mockRejectedValue(new Error("lanes unreachable"));
|
||||
renderPanel();
|
||||
it("surfaces a failed fetch", async () => {
|
||||
statsMock.mockRejectedValue(new Error("stats unreachable"));
|
||||
render(<LanePipelinePanel />);
|
||||
|
||||
expect(await screen.findByText("lanes unreachable")).toBeInTheDocument();
|
||||
expect(await screen.findByText("stats unreachable")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user