6c605849d8
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note. REPL `help lanes|workspace|workspaces` resolves through the aliases. - A workspace holds up to ten sessions (slots 0-9); every console tab is a group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout); parseGroups keeps the stored active tab when an earlier one is dropped. - Browse button beside path fields: POST /api/lanes/pick-path opens the OS picker on the server's desktop — loopback only and no forwarding header (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is killed when the request closes, a folder field falls back to the in-app browser on any failure. - Changes rail counts chip, image viewer (/git/raw), status bar session counts and per-day cost (`date=` on /api/pricing/cost). - /agenthub-open and /agenthub-doctor run from the INSTALLED build (supersededBy), doctor offers fixes behind two confirmations; plugin-open treats a locked bootstrap as non-fatal. - New-terminal shell waits for the first attach (issue #55). - repo-onboarding / debug-live-issue skills removed. - OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history, /api/workspaces mount.
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 workspace 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 workspace add/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("surfaces a failed fetch", async () => {
|
|
statsMock.mockRejectedValue(new Error("stats unreachable"));
|
|
render(<LanePipelinePanel />);
|
|
|
|
expect(await screen.findByText("stats unreachable")).toBeInTheDocument();
|
|
});
|
|
});
|