Files
kandev/apps/web/components/settings/agent-profile-delete-dialog.test.tsx
T
2026-09-12 14:58:49 +02:00

304 lines
11 KiB
TypeScript

import { describe, it, expect, afterEach, vi } from "vitest";
import { fireEvent, render, screen, cleanup, waitFor, within } from "@testing-library/react";
import { useRef, useState } from "react";
import { StateProvider } from "@/components/state-provider";
import {
AgentProfileDeleteConfirmation,
AgentProfileDeleteConflictDialog,
} from "./agent-profile-delete-dialog";
import type { AgentProfileDeleteConflict } from "./agent-profile-delete-dialog";
afterEach(() => {
cleanup();
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
});
function renderLocalConfirmation(
isFinePointer: boolean,
onConfirm: () => void | Promise<void> = () => {},
) {
function Harness() {
const [open, setOpen] = useState(true);
const anchorRef = useRef<HTMLButtonElement>(null);
const close = () => setOpen(false);
return (
<>
<button ref={anchorRef} type="button" data-testid="profile-delete-trigger">
Delete
</button>
<AgentProfileDeleteConfirmation
profileId="profile-1"
profileName="Code reviewer"
open={open}
isFinePointer={isFinePointer}
anchorRef={anchorRef}
onOpenChange={setOpen}
onCancel={close}
onConfirm={onConfirm}
/>
</>
);
}
return render(<Harness />);
}
describe("AgentProfileDeleteConfirmation", () => {
it("names the phone target and restores the original trigger on Cancel", async () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
const onConfirm = vi.fn();
renderLocalConfirmation(false, onConfirm);
const sheet = screen.getByRole("dialog", { name: "Delete agent profile?" });
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
expect(within(sheet).getByText("Code reviewer", { selector: "p" })).toBeTruthy();
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByTestId("profile-delete-trigger")),
);
expect(onConfirm).not.toHaveBeenCalled();
});
it("uses touch-sized inline actions on coarse pointers and cancels without mutating", async () => {
const onConfirm = vi.fn();
renderLocalConfirmation(false, onConfirm);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Cancel" })),
);
expect(screen.getByTestId("agent-profile-delete-inline-confirmation")).toBeTruthy();
const inlineConfirmation = screen.getByTestId("agent-profile-delete-inline-confirmation");
expect(within(inlineConfirmation).getByRole("button", { name: "Delete" }).className).toContain(
"h-11",
);
fireEvent.click(within(inlineConfirmation).getByRole("button", { name: "Cancel" }));
expect(onConfirm).not.toHaveBeenCalled();
expect(screen.queryByTestId("agent-profile-delete-inline-confirmation")).toBeNull();
});
it("uses a non-modal fine-pointer popover and confirms exactly once after closing", async () => {
let shellClosed = false;
const onConfirm = vi.fn(() => {
shellClosed = screen.queryByTestId("agent-profile-delete-confirm-popover") === null;
});
renderLocalConfirmation(true, onConfirm);
const popover = screen.getByTestId("agent-profile-delete-confirm-popover");
expect(popover).toBeTruthy();
expect(screen.queryByRole("alertdialog")).toBeNull();
fireEvent.click(within(popover).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
expect(shellClosed).toBe(true);
});
});
const FIXTURE_TIMESTAMP = "2026-01-01T00:00:00.000Z";
function renderConflictDialog(conflict: AgentProfileDeleteConflict | null) {
return render(
<StateProvider
initialState={{
workspaces: {
activeId: "ws-1",
items: [
{
id: "ws-1",
name: "Office Workspace",
owner_id: "user-1",
created_at: FIXTURE_TIMESTAMP,
updated_at: FIXTURE_TIMESTAMP,
},
],
},
settingsAgents: {
items: [
{
id: "codex-acp",
name: "Codex",
supports_mcp: false,
profiles: [],
created_at: FIXTURE_TIMESTAMP,
updated_at: FIXTURE_TIMESTAMP,
},
],
},
}}
>
<AgentProfileDeleteConflictDialog
conflict={conflict}
onOpenChange={() => {}}
onConfirm={() => {}}
/>
</StateProvider>,
);
}
describe("AgentProfileDeleteConflictDialog", () => {
// An automation is configuration, not a session: nothing is running, so it
// never reaches activeSessions. Before this was rendered, an automation-only
// conflict popped a dialog that listed nothing at all and then offered
// "Delete Anyway".
it("names the automations on an automation-only conflict", () => {
renderConflictDialog({
activeSessions: [],
watchers: [],
routingTiers: [],
automations: [{ id: "auto-1", name: "Nightly dependency sweep", workspace_id: "ws-1" }],
});
expect(screen.getByTestId("delete-conflict-automations")).toBeTruthy();
screen.getByText("Nightly dependency sweep");
});
it("renders the watcher list grouped by kind on a watcher-only conflict", () => {
renderConflictDialog({
activeSessions: [],
watchers: [
{ id: "linear-w1", kind: "linear", label: "team ENG" },
{ id: "linear-w2", kind: "linear", label: "team WEB" },
{ id: "github-w1", kind: "github_issue", label: "kdlbs/kandev" },
],
routingTiers: [],
automations: [],
});
// Watcher group headings render the human-friendly kind label, and
// each watcher's label string appears once. Critically: the dialog
// pops even with no active sessions — this is the bug class the
// backend self-heal pre-flight fix would have left unaddressed
// without the frontend wiring.
expect(screen.getByText(/Watchers \(will be disabled\)/)).toBeTruthy();
expect(screen.getByText(/Linear:/)).toBeTruthy();
expect(screen.getByText(/GitHub Issues:/)).toBeTruthy();
expect(screen.getByText(/team ENG/)).toBeTruthy();
expect(screen.getByText(/team WEB/)).toBeTruthy();
expect(screen.getByText(/kdlbs\/kandev/)).toBeTruthy();
});
it("does not render the watchers section when the conflict is sessions-only", () => {
renderConflictDialog({
activeSessions: [{ task_id: "t1", task_title: "Live task", is_ephemeral: false }],
watchers: [],
routingTiers: [],
automations: [],
});
expect(screen.getByText(/Tasks:/)).toBeTruthy();
expect(screen.getByText("Live task")).toBeTruthy();
expect(screen.queryByText(/Watchers \(will be disabled\)/)).toBeNull();
});
it("renders both sections when sessions and watchers coexist", () => {
renderConflictDialog({
activeSessions: [{ task_id: "t1", task_title: "Live task", is_ephemeral: false }],
watchers: [{ id: "jira-w1", kind: "jira", label: "project = ENG" }],
routingTiers: [],
automations: [],
});
expect(screen.getByText("Live task")).toBeTruthy();
expect(screen.getByText(/Jira:/)).toBeTruthy();
expect(screen.getByText(/project = ENG/)).toBeTruthy();
});
it("renders tier mappings as a hard blocker", () => {
renderConflictDialog({
activeSessions: [],
watchers: [],
routingTiers: [{ workspace_id: "ws-1", provider_id: "codex-acp", tier: "balanced" }],
automations: [],
});
expect(screen.getByText(/Cannot delete agent profile/i)).toBeTruthy();
expect(screen.getByText(/Workspace tier mappings:/)).toBeTruthy();
expect(
screen.getByText((_content, element) =>
Boolean(
element?.tagName === "LI" &&
element.textContent?.includes(
"Balanced tier in Office Workspace (ws-1) for Codex (codex-acp)",
),
),
),
).toBeTruthy();
expect(screen.queryByText(/Delete Anyway/)).toBeNull();
});
// The watcher `kind` is the wire enum and its label is a catalog key, so a
// renamed key or a dropped entry must fail here rather than silently render
// the raw enum value to the user.
it("resolves every known watcher kind to its label, and echoes an unknown one", () => {
renderConflictDialog({
activeSessions: [],
watchers: [
{ id: "w1", kind: "linear", label: "team ENG" },
{ id: "w2", kind: "jira", label: "project = ENG" },
{ id: "w3", kind: "github_issue", label: "kdlbs/kandev" },
{ id: "w4", kind: "github_review", label: "kdlbs/kandev PRs" },
// A kind the frontend does not know yet — the raw value is echoed
// rather than rendering an empty label.
{ id: "w5", kind: "sentry" as never, label: "proj/backend" },
],
routingTiers: [],
automations: [],
});
expect(screen.getByText(/Linear:/)).toBeTruthy();
expect(screen.getByText(/Jira:/)).toBeTruthy();
expect(screen.getByText(/GitHub Issues:/)).toBeTruthy();
expect(screen.getByText(/GitHub PR Reviews:/)).toBeTruthy();
expect(screen.getByText(/sentry:/)).toBeTruthy();
});
it("does not render the dialog when conflict is null", () => {
renderConflictDialog(null);
expect(screen.queryByText(/Delete agent profile/i)).toBeNull();
});
});
describe("AgentProfileDeleteConflictDialog layout", () => {
it("keeps conflict details scrollable and decisions outside the scroll region", () => {
renderConflictDialog({
activeSessions: [{ task_id: "t1", task_title: "Live task", is_ephemeral: false }],
watchers: [],
routingTiers: [],
automations: [],
});
const dialog = screen.getByTestId("agent-profile-delete-conflict-dialog");
const body = screen.getByTestId("agent-profile-delete-conflict-body");
const footer = screen.getByTestId("agent-profile-delete-conflict-footer");
expect(dialog.getAttribute("data-layout")).toBe("contained");
expect(dialog.contains(body)).toBe(true);
expect(body.contains(footer)).toBe(false);
expect(within(footer).getByRole("button", { name: "Cancel" })).toBeTruthy();
});
// @covers AC-UI-SURFACE-TEXT-HIERARCHY-001.2, AC-UI-SURFACE-TEXT-HIERARCHY-001.3
it("keeps long conflict labels in one left-aligned semantic description", () => {
const longTaskTitle = `Profile task ${"x".repeat(320)}`;
renderConflictDialog({
activeSessions: [{ task_id: "long-task", task_title: longTaskTitle, is_ephemeral: false }],
watchers: [],
routingTiers: [],
automations: [],
});
const body = screen.getByTestId("agent-profile-delete-conflict-body");
expect(body.className).toContain("min-w-0");
expect(body.className).toContain("text-left");
expect(body.className).toContain("space-y-2");
const directParagraphs = body.querySelectorAll(":scope > p");
expect(directParagraphs).toHaveLength(2);
expect(directParagraphs[1]?.className).not.toContain("mt-2");
const taskItem = screen.getByText(longTaskTitle);
expect(taskItem.tagName).toBe("LI");
});
});