/** * @file Regression test for the lane card's status badge. Every lane status * the server can set must have a real translated word behind * `t("status." + lane.status)`; before this test existed, no locale defined * any `status.*` key, and i18next's default missing-key behavior (return the * key itself) hid that from the `||` fallback, so the badge showed literal * text like `status.active`. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import LaneCard from "../LaneCard"; import type { Lane } from "../../../lib/types"; import { api } from "../../../lib/api"; vi.mock("../../../lib/api", () => ({ api: { lanes: { git: vi.fn(), preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }) }, }, })); beforeEach(() => { vi.mocked(api.lanes.git).mockReset(); vi.mocked(api.lanes.git).mockResolvedValue({ available: false }); }); function makeLane(overrides: Partial = {}): Lane { return { id: 1, title: "demo", cwd: "/work/demo", branch: "lane/demo", kind: "adopted", pipeline: "default", session_id: null, run_id: null, stage: "plan", stage_since: null, status: "idle", gate_decision: null, ci_status: null, needs_action: null, links: {}, stages: {}, notes: null, pipeline_name: "Default", pipeline_nodes: [], progress: 0, stage_seconds: null, last_event_seconds: null, liveness: "idle", detected_stage: null, detected_signal: null, ...overrides, }; } 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(); expect(screen.queryByText(`status.${status}`)).not.toBeInTheDocument(); expect(screen.queryByText(status.toUpperCase())).not.toBeInTheDocument(); }); } }); const pipelineNodes: Lane["pipeline_nodes"] = [ { id: "intake", label: "intake", icon: "📥", gate: false, state: "done" }, { id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" }, { id: "implement", label: "implement", icon: "🛠", gate: false, state: "current" }, { id: "tests", label: "tests", icon: "🧪", gate: false, state: "pending" }, ]; describe("LaneCard rebuilt layout", () => { const full = (over: Partial = {}) => makeLane({ id: 7, title: "Rename Metric to Rule", kind: "managed", status: "running", liveness: "active", stage: "plan", stage_seconds: 152, progress: 48, ci_status: "green", pipeline_nodes: pipelineNodes, ...over, }); it("labels the card with the lane id", () => { render(); expect(screen.getByTestId("lane-card-7")).toBeInTheDocument(); }); it("shows the progress percentage and the time on stage", () => { render(); expect(screen.getByText("48%")).toBeInTheDocument(); expect(screen.getByText("2m 32s")).toBeInTheDocument(); }); it("gives the progress bar a width matching the lane's progress", () => { render(); expect(screen.getByTestId("lane-progress-fill").getAttribute("style")).toContain("48%"); }); it("surfaces a needs-you message", () => { render(); expect(screen.getByText(/waiting on approval/)).toBeInTheDocument(); }); it("fires a plain action with its own name", async () => { const onAction = vi.fn(); render(); 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 () => { render(); // 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(); 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(); const user = userEvent.setup(); await user.click(screen.getByTestId("lane-more")); await user.click(screen.getByTestId("lane-action-reset")); expect(onAction).not.toHaveBeenCalled(); }); it("offers reset only for a managed lane, and drops the menu entirely without it", async () => { const user = userEvent.setup(); const { unmount } = render(); 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. render(); expect(screen.queryByTestId("lane-more")).toBeNull(); expect(screen.queryByTestId("lane-action-reset")).toBeNull(); expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument(); }); }); describe("LaneCard git block", () => { const facts = { available: true as const, branch: "feat/rename-metric", head: "9b3e74a", subject: "free-text rule mode in the form", dirty: 2, untracked: 1, }; it("renders the live branch, head, subject and uncommitted counts", async () => { vi.mocked(api.lanes.git).mockResolvedValueOnce(facts); render(); const block = await screen.findByTestId("lane-git"); expect(block.textContent).toContain("feat/rename-metric"); expect(block.textContent).toContain("9b3e74a"); expect(block.textContent).toContain("free-text rule mode in the form"); expect(block.textContent).toContain("2"); // The live branch replaces the lane's recorded one rather than doubling it. expect(screen.queryByText(/stale\/recorded/)).toBeNull(); }); it("omits the uncommitted line when the tree is clean", async () => { vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 }); render(); const block = await screen.findByTestId("lane-git"); expect(block.textContent).not.toContain("modified"); }); 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(); expect(await screen.findByText("plain dir lane")).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(); expect(await screen.findByText("offline lane")).toBeInTheDocument(); expect(screen.queryByTestId("lane-git")).toBeNull(); expect(screen.queryByText(/network down/)).toBeNull(); }); it("stops polling once the card unmounts", async () => { vi.useFakeTimers(); try { vi.mocked(api.lanes.git).mockResolvedValue({ available: false }); const { unmount } = render(); expect(api.lanes.git).toHaveBeenCalledTimes(1); unmount(); vi.advanceTimersByTime(120_000); expect(api.lanes.git).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); } }); });