feat(lanes): add lane tools menu and header liveness/age indicator
Move the rare destructive lane actions behind a LaneToolsMenu next to the lane header, and show a liveness dot + status label + stage age inline in the header instead of only in the card grid.
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import LaneCard from "../LaneCard";
|
||||
import LaneCard, { LaneToolsMenu } from "../LaneCard";
|
||||
import type { Lane, LaneRuntime } from "../../../lib/types";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
@@ -85,16 +85,6 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||
} as Lane;
|
||||
}
|
||||
|
||||
describe("LaneCard status badge", () => {
|
||||
for (const status of ["idle", "running", "provisioning", "failed"] as const) {
|
||||
it(`renders a real word for status "${status}", not the raw key`, () => {
|
||||
render(<LaneCard lane={makeLane({ status })} onAction={vi.fn()} />);
|
||||
expect(screen.queryByText(`status.${status}`)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(status.toUpperCase())).not.toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("LaneCard child worktrees", () => {
|
||||
it("lists worktrees provisioned from this lane and jumps to one on click", async () => {
|
||||
const onSelectLane = vi.fn();
|
||||
@@ -148,10 +138,9 @@ describe("LaneCard rebuilt layout", () => {
|
||||
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the progress percentage and the time on stage", () => {
|
||||
it("shows the progress percentage", () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
expect(screen.getByText("48%")).toBeInTheDocument();
|
||||
expect(screen.getByText("2m 32s")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives the progress bar a width matching the lane's progress", () => {
|
||||
@@ -164,35 +153,14 @@ describe("LaneCard rebuilt layout", () => {
|
||||
expect(screen.getByText(/waiting on approval/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires a plain action with its own name", async () => {
|
||||
const onAction = vi.fn();
|
||||
render(<LaneCard lane={full()} onAction={onAction} />);
|
||||
await userEvent.setup().click(screen.getByTestId("lane-action-stop"));
|
||||
expect(onAction).toHaveBeenCalledWith("stop");
|
||||
});
|
||||
|
||||
it("shows both deletions as buttons, and keeps reset behind the menu", async () => {
|
||||
it("keeps reset behind the ⋯ menu rather than showing it directly", async () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
// Deleting the lane and deleting its history are the two the user goes
|
||||
// looking for, so they are visible without opening anything. Reset is not.
|
||||
expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-action-purge")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
|
||||
|
||||
await userEvent.setup().click(screen.getByTestId("lane-more"));
|
||||
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("routes both deletions through the modal rather than firing them", async () => {
|
||||
const onAction = vi.fn();
|
||||
render(<LaneCard lane={full()} onAction={onAction} />);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("lane-action-remove"));
|
||||
expect(onAction).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByTestId("lane-action-purge"));
|
||||
expect(onAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes reset through the confirmation modal rather than firing it", async () => {
|
||||
const onAction = vi.fn();
|
||||
render(<LaneCard lane={full()} onAction={onAction} />);
|
||||
@@ -202,18 +170,17 @@ describe("LaneCard rebuilt layout", () => {
|
||||
expect(onAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers reset only for a managed lane, and drops the menu entirely without it", async () => {
|
||||
it("offers the ⋯ menu only for a managed lane", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
await user.click(screen.getByTestId("lane-more"));
|
||||
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
// An adopted lane has nothing left in the menu, so there is no ⋯ to open.
|
||||
// An adopted lane has no worktree to reset, so there is no ⋯ to open.
|
||||
render(<LaneCard lane={full({ kind: "adopted" })} onAction={vi.fn()} />);
|
||||
expect(screen.queryByTestId("lane-more")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
|
||||
expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -249,17 +216,17 @@ describe("LaneCard git block", () => {
|
||||
|
||||
it("renders the card with no git block and no error when git is unavailable", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
|
||||
render(<LaneCard lane={makeLane({ title: "plain dir lane" })} onAction={vi.fn()} />);
|
||||
render(<LaneCard lane={makeLane({ id: 2 })} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("plain dir lane")).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("lane-card-2")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
});
|
||||
|
||||
it("swallows a rejected request instead of surfacing an error", async () => {
|
||||
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
|
||||
render(<LaneCard lane={makeLane({ title: "offline lane" })} onAction={vi.fn()} />);
|
||||
render(<LaneCard lane={makeLane({ id: 3 })} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("offline lane")).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("lane-card-3")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
expect(screen.queryByText(/network down/)).toBeNull();
|
||||
});
|
||||
@@ -299,11 +266,10 @@ describe("LaneCard — the lane's own application stack", () => {
|
||||
|
||||
it("shows nothing at all for a lane whose repo declares no profile", async () => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
render(<LaneCard lane={makeLane({ title: "no profile" })} onAction={vi.fn()} />);
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("no profile")).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("lane-card-1")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-runtime-1")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-runtime-toggle")).toBeNull();
|
||||
});
|
||||
|
||||
it("lists each declared port with its listening state", async () => {
|
||||
@@ -327,42 +293,6 @@ describe("LaneCard — the lane's own application stack", () => {
|
||||
expect(screen.getByText(/8003/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stops the stack through /down, never through the run's onAction prop", async () => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime);
|
||||
vi.mocked(api.lanes.down).mockResolvedValue({
|
||||
ok: true,
|
||||
killed: [4242],
|
||||
runtime: { available: false },
|
||||
});
|
||||
const onAction = vi.fn();
|
||||
render(<LaneCard lane={makeLane()} onAction={onAction} />);
|
||||
|
||||
await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle"));
|
||||
|
||||
await waitFor(() => expect(api.lanes.down).toHaveBeenCalledWith(1));
|
||||
expect(api.lanes.up).not.toHaveBeenCalled();
|
||||
expect(onAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("boots a provisioned-but-down lane and stays busy past the 202", async () => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||
...upRuntime,
|
||||
services: [{ name: "web", pid: 4242, alive: false }],
|
||||
ports: { api: { port: 8003, expected: 8003, listening: false } },
|
||||
up: false,
|
||||
healthy: false,
|
||||
});
|
||||
vi.mocked(api.lanes.up).mockResolvedValue({ ok: true, laneId: 1 });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle"));
|
||||
|
||||
await waitFor(() => expect(api.lanes.up).toHaveBeenCalledWith(1));
|
||||
// The request resolving is not the stack being up: the server answered 202
|
||||
// and is still booting, so the button must not go idle yet.
|
||||
await waitFor(() => expect(screen.getByTestId("lane-runtime-toggle")).toBeDisabled());
|
||||
});
|
||||
|
||||
it("surfaces the last boot error when nothing is streaming", async () => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||
...upRuntime,
|
||||
@@ -398,7 +328,7 @@ describe("LaneCard — named locks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("agents install / mcp sync / integration badges / sync check", () => {
|
||||
describe("LaneCard — integration badges", () => {
|
||||
beforeEach(() => {
|
||||
// Only the fields this describe block's assertions read; `as LaneRuntime`
|
||||
// keeps the double from having to restate a shape the component never
|
||||
@@ -424,13 +354,33 @@ describe("agents install / mcp sync / integration badges / sync check", () => {
|
||||
expect(devQc.className).not.toContain("status-success");
|
||||
});
|
||||
|
||||
it("hides integration badges when the lane has no profile", async () => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneToolsMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||
available: true as const,
|
||||
provisioned: true as const,
|
||||
up: false,
|
||||
slot: 1,
|
||||
profileDir: "/work/demo/.ccam/profile",
|
||||
ports: {},
|
||||
} as unknown as LaneRuntime);
|
||||
});
|
||||
|
||||
it("clicking Install agents calls the API and shows the result", async () => {
|
||||
vi.mocked(api.lanes.agentsInstall).mockResolvedValue({
|
||||
installed: ["qc-local.md", "senior-gate-reviewer.md"],
|
||||
});
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
const button = await screen.findByTestId("lane-agents-install");
|
||||
await userEvent.click(button);
|
||||
render(<LaneToolsMenu lane={makeLane()} />);
|
||||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||||
await userEvent.click(await screen.findByTestId("lane-agents-install"));
|
||||
await waitFor(() => expect(api.lanes.agentsInstall).toHaveBeenCalledWith(1));
|
||||
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("qc-local.md");
|
||||
});
|
||||
@@ -440,9 +390,9 @@ describe("agents install / mcp sync / integration badges / sync check", () => {
|
||||
servers: ["playwright"],
|
||||
profilesSeeded: [],
|
||||
});
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
const button = await screen.findByTestId("lane-mcp-sync");
|
||||
await userEvent.click(button);
|
||||
render(<LaneToolsMenu lane={makeLane()} />);
|
||||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||||
await userEvent.click(await screen.findByTestId("lane-mcp-sync"));
|
||||
await waitFor(() => expect(api.lanes.mcpSync).toHaveBeenCalledWith(1));
|
||||
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("playwright");
|
||||
});
|
||||
@@ -453,9 +403,9 @@ describe("agents install / mcp sync / integration badges / sync check", () => {
|
||||
devDelta: ["a.txt", "b.txt"],
|
||||
overlap: [],
|
||||
});
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
const button = await screen.findByTestId("lane-sync-check");
|
||||
await userEvent.click(button);
|
||||
render(<LaneToolsMenu lane={makeLane()} />);
|
||||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||||
await userEvent.click(await screen.findByTestId("lane-sync-check"));
|
||||
await waitFor(() => expect(api.lanes.syncBaseCheck).toHaveBeenCalledWith(1));
|
||||
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("2");
|
||||
});
|
||||
@@ -465,19 +415,16 @@ describe("agents install / mcp sync / integration badges / sync check", () => {
|
||||
code: 5,
|
||||
collisions: [{ file: "002_a.sql", collidesWith: "002_b.sql", suggestion: "003_a.sql" }],
|
||||
});
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
const button = await screen.findByTestId("lane-sync-check");
|
||||
await userEvent.click(button);
|
||||
render(<LaneToolsMenu lane={makeLane()} />);
|
||||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||||
await userEvent.click(await screen.findByTestId("lane-sync-check"));
|
||||
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("003_a.sql");
|
||||
});
|
||||
|
||||
it("hides all four additions when the lane has no profile", async () => {
|
||||
it("renders nothing when the lane has no profile", async () => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
const { container } = render(<LaneToolsMenu lane={makeLane()} />);
|
||||
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("lane-agents-install")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-mcp-sync")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-sync-check")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user