0004cf7036
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.
475 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|