/** * @file Tests for DestructiveLaneModal: the preflight-gated confirmation for * reset/remove/purge. Covers that the displayed counts are exactly the * preflight facts, that `reset` is refused for adopted/missing/unreadable * lanes while `remove` stays available for all of them (the server permits it, * so the UI must not be stricter), that the Force checkbox appears exactly when * the server would demand force, and that confirming echoes back exactly the * `expect` block the modal displayed. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; const { preflightMock } = vi.hoisted(() => ({ preflightMock: vi.fn() })); vi.mock("../../../lib/api", () => ({ api: { lanes: { preflight: preflightMock } }, })); import { DestructiveLaneModal } from "../DestructiveLaneModal"; import type { Lane, LanePurgePreflight, LaneWorktreePreflight } from "../../../lib/types"; function makeLane(overrides: Partial = {}): Lane { // `as Lane`: spreading a Partial widens every field it may carry to // `T | undefined`, which no longer satisfies Lane's required fields. The // base object below still lists all of them, so the cast asserts what the // literal already proves. return { id: 1, title: "demo", cwd: "/work/demo", branch: "lane/demo", kind: "managed", 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, slot: null, ports: {}, ...overrides, } as Lane; } function worktreePreflight(overrides: Partial = {}): LaneWorktreePreflight { return { action: "reset", lane: 1, kind: "managed", branch: "lane/demo", head: "abc1234", dirty: 2, untracked: 3, unpushed: 0, blocked: [], warnings: [], ...overrides, }; } beforeEach(() => { preflightMock.mockReset(); }); describe("DestructiveLaneModal", () => { it("renders exactly the counts the preflight returned", async () => { preflightMock.mockResolvedValue( worktreePreflight({ head: "deadbee", dirty: 4, untracked: 5, unpushed: 0 }) ); render( ); expect(await screen.findByText("deadbee")).toBeInTheDocument(); expect(screen.getByText("4")).toBeInTheDocument(); expect(screen.getByText("5")).toBeInTheDocument(); }); it("disables RESET for an adopted lane (a worktree action can never touch it)", async () => { preflightMock.mockResolvedValue( worktreePreflight({ action: "reset", kind: "adopted", blocked: ["adopted"] }) ); render( ); const confirmButton = await screen.findByRole("button", { name: "Reset worktree" }); await waitFor(() => expect(confirmButton).toBeDisabled()); }); it("ENABLES remove for an adopted lane and sends the payload — the server forgets the row and leaves the directory alone", async () => { preflightMock.mockResolvedValue( worktreePreflight({ action: "remove", kind: "adopted", head: "adopt01", dirty: 0, untracked: 0, unpushed: 0, blocked: ["adopted"], }) ); const onConfirm = vi.fn(); render( ); const confirmButton = await screen.findByRole("button", { name: "Delete lane record" }); await waitFor(() => expect(confirmButton).not.toBeDisabled()); // "adopted" is shown as context, not as a refusal. expect(screen.getByText(/Only the dashboard's record of it is dropped/)).toBeInTheDocument(); fireEvent.click(confirmButton); expect(onConfirm).toHaveBeenCalledWith({ expect: { head: "adopt01", dirty: 0, untracked: 0, unpushed: 0 }, }); }); it("forgets an adopted lane with unpushed commits without offering Force — the server does not require it", async () => { preflightMock.mockResolvedValue( worktreePreflight({ action: "remove", kind: "adopted", head: "adopt02", dirty: 0, untracked: 0, unpushed: 7, blocked: ["adopted", "unpushed-commits"], }) ); const onConfirm = vi.fn(); render( ); const confirmButton = await screen.findByRole("button", { name: "Delete lane record" }); await waitFor(() => expect(confirmButton).not.toBeDisabled()); expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); fireEvent.click(confirmButton); expect(onConfirm).toHaveBeenCalledWith({ expect: { head: "adopt02", dirty: 0, untracked: 0, unpushed: 7 }, }); }); it("disables RESET when the worktree directory is missing", async () => { preflightMock.mockResolvedValue(worktreePreflight({ action: "reset", blocked: ["missing"] })); render( ); const confirmButton = await screen.findByRole("button", { name: "Reset worktree" }); await waitFor(() => expect(confirmButton).toBeDisabled()); }); it("ENABLES remove when the worktree directory is missing — the row goes, git's records stay", async () => { preflightMock.mockResolvedValue( worktreePreflight({ action: "remove", head: null, dirty: 0, untracked: 0, unpushed: 0, blocked: ["missing"], }) ); const onConfirm = vi.fn(); render( ); const confirmButton = await screen.findByRole("button", { name: "Delete lane record" }); await waitFor(() => expect(confirmButton).not.toBeDisabled()); expect(screen.getByText(/The lane directory is already gone/)).toBeInTheDocument(); fireEvent.click(confirmButton); expect(onConfirm).toHaveBeenCalledWith({ expect: { head: null, dirty: 0, untracked: 0, unpushed: 0 }, }); }); it("disables RESET when the worktree directory is unreadable", async () => { preflightMock.mockResolvedValue( worktreePreflight({ action: "reset", blocked: ["unreadable"] }) ); render( ); const confirmButton = await screen.findByRole("button", { name: "Reset worktree" }); await waitFor(() => expect(confirmButton).toBeDisabled()); }); it("ENABLES remove when the worktree directory is unreadable — nothing on disk is touched either way", async () => { preflightMock.mockResolvedValue( worktreePreflight({ action: "remove", head: null, dirty: 0, untracked: 0, unpushed: 0, blocked: ["unreadable"], }) ); const onConfirm = vi.fn(); render( ); const confirmButton = await screen.findByRole("button", { name: "Delete lane record" }); await waitFor(() => expect(confirmButton).not.toBeDisabled()); expect(screen.getByText(/cannot be read as a Git worktree/)).toBeInTheDocument(); fireEvent.click(confirmButton); expect(onConfirm).toHaveBeenCalledWith({ expect: { head: null, dirty: 0, untracked: 0, unpushed: 0 }, }); }); it("never offers Force for a managed removal with unpushed commits — it deletes the row, not the branch", async () => { preflightMock.mockResolvedValue( worktreePreflight({ action: "remove", head: "abc0000", dirty: 0, untracked: 0, unpushed: 3, blocked: ["unpushed-commits"], }) ); const onConfirm = vi.fn(); render( ); const confirmButton = await screen.findByRole("button", { name: "Delete lane record" }); await waitFor(() => expect(confirmButton).not.toBeDisabled()); expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); fireEvent.click(confirmButton); expect(onConfirm).toHaveBeenCalledWith({ expect: { head: "abc0000", dirty: 0, untracked: 0, unpushed: 3 }, }); }); it("a local-only managed lane (no-remote warning, unpushed-commits blocker) is resettable with Force", async () => { preflightMock.mockResolvedValue( worktreePreflight({ action: "reset", unpushed: 3, blocked: ["unpushed-commits"], warnings: ["no-remote"], }) ); render( ); // The warning is shown as context, not as an obstacle. expect(await screen.findByText(/No Git remote is configured/)).toBeInTheDocument(); const confirmButton = await screen.findByRole("button", { name: "Reset worktree" }); await waitFor(() => expect(confirmButton).toBeDisabled()); fireEvent.click(screen.getByRole("checkbox")); expect(confirmButton).not.toBeDisabled(); }); it("does not offer the Force checkbox when a hard blocker makes confirming impossible anyway", async () => { preflightMock.mockResolvedValue( worktreePreflight({ action: "reset", unpushed: 3, blocked: ["unpushed-commits", "missing"] }) ); render( ); const confirmButton = await screen.findByRole("button", { name: "Reset worktree" }); await waitFor(() => expect(confirmButton).toBeDisabled()); expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); }); it("confirming a worktree action passes back exactly the expect block that was displayed", async () => { preflightMock.mockResolvedValue( worktreePreflight({ head: "cafefeed", dirty: 1, untracked: 2, unpushed: 0, blocked: [] }) ); const onConfirm = vi.fn(); render( ); const confirmButton = await screen.findByRole("button", { name: "Reset worktree" }); await waitFor(() => expect(confirmButton).not.toBeDisabled()); fireEvent.click(confirmButton); expect(onConfirm).toHaveBeenCalledWith({ expect: { head: "cafefeed", dirty: 1, untracked: 2, unpushed: 0 }, }); }); it("confirming an unpushed-commits RESET with Force ticked sends force:true plus the same expect block", async () => { preflightMock.mockResolvedValue( worktreePreflight({ action: "reset", head: "abc0000", dirty: 0, untracked: 0, unpushed: 2, blocked: ["unpushed-commits"], }) ); const onConfirm = vi.fn(); render( ); const confirmButton = await screen.findByRole("button", { name: "Reset worktree" }); fireEvent.click(screen.getByRole("checkbox")); await waitFor(() => expect(confirmButton).not.toBeDisabled()); fireEvent.click(confirmButton); expect(onConfirm).toHaveBeenCalledWith({ expect: { head: "abc0000", dirty: 0, untracked: 0, unpushed: 2 }, force: true, }); }); it("confirming a purge passes back the purge-specific expect block", async () => { const purgePreflight: LanePurgePreflight = { action: "purge", lane: 1, sessions: 4, events: 120, tokenRows: 30, bytesEstimate: 4096, activeSessionSkipped: false, }; preflightMock.mockResolvedValue(purgePreflight); const onConfirm = vi.fn(); render( ); const confirmButton = await screen.findByRole("button", { name: "Purge history" }); await waitFor(() => expect(confirmButton).not.toBeDisabled()); fireEvent.click(confirmButton); expect(onConfirm).toHaveBeenCalledWith({ expect: { sessions: 4, events: 120, tokenRows: 30 }, }); }); it("shows the purge size estimate and says when a live session was spared", async () => { const purgePreflight: LanePurgePreflight = { action: "purge", lane: 1, sessions: 2, events: 8, tokenRows: 2, bytesEstimate: 5120, activeSessionSkipped: true, }; preflightMock.mockResolvedValue(purgePreflight); render( ); expect(await screen.findByText("5.0 KB")).toBeInTheDocument(); expect(screen.getByText(/still active and will be kept/)).toBeInTheDocument(); }); it("does not claim a session was spared when none was", async () => { const purgePreflight: LanePurgePreflight = { action: "purge", lane: 1, sessions: 1, events: 1, tokenRows: 0, bytesEstimate: 512, activeSessionSkipped: false, }; preflightMock.mockResolvedValue(purgePreflight); render( ); expect(await screen.findByText("512 B")).toBeInTheDocument(); expect(screen.queryByText(/still active and will be kept/)).not.toBeInTheDocument(); }); });