Files
AgentHub/client/src/components/lanes/__tests__/DestructiveLaneModal.test.tsx
T
nntrivi2001 0004cf7036 fix(lanes): removing a lane deletes its record only, never the worktree
A lane's `remove` tore down what the lane pointed at: `git worktree remove
--force`, a branch delete, and a drop of the lane's own database. One
mis-click on a dashboard row cost a whole branch's unpushed code — and the
row is the only thing AgentHub actually owns. `remove` now stops the run and
the stack, clears AgentHub's own runtime bookkeeping under
`LANES_ROOT/.state/lane<slot>/`, and deletes the row. The working tree, its
branch, git's worktree records and the database stay exactly where they are,
for the user to delete by hand when they mean to.

That also fixes #28. Removal used to route through `assertDestroyable`, whose
third check demands the cwd appear in `git worktree list`; a managed lane
whose directory git no longer listed answered `ENOTWORKTREE` ("lane is not
listed as a worktree in …") and could never be dropped from the dashboard at
all. Removal no longer asks git anything, so no preflight blocker stops it,
and the unpushed-commits force-gate now applies to `reset` alone — nothing
`remove` does can discard a commit.

The UI follows: one label for both lane kinds ("Delete lane record"), copy
that says what survives, and no Force checkbox on removal.
`removeWorktree`/`removeLaneData` remain in place as guarded primitives; no
route calls them any more.
2026-09-18 09:44:17 +07:00

475 lines
15 KiB
TypeScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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> = {}): Lane {
// `as Lane`: spreading a Partial<Lane> 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> = {}): 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(
<DestructiveLaneModal
lane={makeLane()}
action="reset"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
);
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(
<DestructiveLaneModal
lane={makeLane({ kind: "adopted" })}
action="reset"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
);
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(
<DestructiveLaneModal
lane={makeLane({ kind: "adopted" })}
action="remove"
open
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
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(
<DestructiveLaneModal
lane={makeLane({ kind: "adopted" })}
action="remove"
open
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
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(
<DestructiveLaneModal
lane={makeLane()}
action="reset"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
);
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(
<DestructiveLaneModal
lane={makeLane()}
action="remove"
open
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
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(
<DestructiveLaneModal
lane={makeLane()}
action="reset"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
);
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(
<DestructiveLaneModal
lane={makeLane()}
action="remove"
open
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
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(
<DestructiveLaneModal
lane={makeLane()}
action="remove"
open
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
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(
<DestructiveLaneModal
lane={makeLane()}
action="reset"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
);
// 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(
<DestructiveLaneModal
lane={makeLane()}
action="reset"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
);
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(
<DestructiveLaneModal
lane={makeLane()}
action="reset"
open
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
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(
<DestructiveLaneModal
lane={makeLane()}
action="reset"
open
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
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(
<DestructiveLaneModal
lane={makeLane()}
action="purge"
open
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
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(
<DestructiveLaneModal
lane={makeLane()}
action="purge"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
);
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(
<DestructiveLaneModal
lane={makeLane()}
action="purge"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
);
expect(await screen.findByText("512 B")).toBeInTheDocument();
expect(screen.queryByText(/still active and will be kept/)).not.toBeInTheDocument();
});
});