feat(lanes): lane runtime UI, docs, and env additions (A1+A2)
Client-side rendering for the per-lane runtime facts (slot, ports, database, Redis index, service liveness) added in the server-side A1/A2 work, plus the doc updates (README, CLAUDE.md, docs/API.md, client/server READMEs) describing the new profile.env keys, hook environment contract, and REST endpoints.
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import LaneCard from "../LaneCard";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
@@ -17,13 +17,23 @@ import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: { git: vi.fn(), preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }) },
|
||||
lanes: {
|
||||
git: vi.fn(),
|
||||
runtime: vi.fn(),
|
||||
up: vi.fn(),
|
||||
down: vi.fn(),
|
||||
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.git).mockReset();
|
||||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||||
// A lane with no profile is the default fixture: most lanes never run a
|
||||
// stack, so the runtime strip and its button stay absent unless a test opts in.
|
||||
vi.mocked(api.lanes.runtime).mockReset();
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
});
|
||||
|
||||
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||
@@ -53,6 +63,8 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||
liveness: "idle",
|
||||
detected_stage: null,
|
||||
detected_signal: null,
|
||||
slot: null,
|
||||
ports: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -225,3 +237,101 @@ describe("LaneCard git block", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneCard — the lane's own application stack", () => {
|
||||
const upRuntime = {
|
||||
available: true as const,
|
||||
provisioned: true as const,
|
||||
slot: 3,
|
||||
kind: "managed" as const,
|
||||
hooks: ["boot", "health"],
|
||||
profileDir: "/work/demo/.ccam/profile",
|
||||
services: [{ name: "web", pid: 4242, alive: true }],
|
||||
ports: { api: { port: 8003, expected: 8003, listening: true } },
|
||||
steppedAside: false,
|
||||
up: true,
|
||||
healthy: true,
|
||||
logs: ["boot.log"],
|
||||
logDir: "/lanes/.state/lane3/logs",
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
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()} />);
|
||||
|
||||
expect(await screen.findByText("no profile")).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 () => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime);
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByTestId("lane-runtime-1")).toBeInTheDocument();
|
||||
expect(screen.getByText(":8003")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-runtime-state")).toHaveTextContent(/healthy/i);
|
||||
});
|
||||
|
||||
it("flags a port that stepped aside from its base, showing the expected number", async () => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||
...upRuntime,
|
||||
steppedAside: true,
|
||||
ports: { api: { port: 8103, expected: 8003, listening: true } },
|
||||
});
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText(":8103")).toBeInTheDocument();
|
||||
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,
|
||||
up: false,
|
||||
healthy: false,
|
||||
lastError: { at: "2026-08-03T00:00:00Z", code: "EUNHEALTHY", message: "health check failed" },
|
||||
});
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText(/EUNHEALTHY/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/health check failed/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user