diff --git a/canvas/src/components/settings/__tests__/DeleteConfirmDialog.test.tsx b/canvas/src/components/settings/__tests__/DeleteConfirmDialog.test.tsx new file mode 100644 index 00000000..b4d0e2ba --- /dev/null +++ b/canvas/src/components/settings/__tests__/DeleteConfirmDialog.test.tsx @@ -0,0 +1,225 @@ +// @vitest-environment jsdom +/** + * DeleteConfirmDialog — destructive confirmation for deleting a secret key. + * + * Per spec §3.5 & §4.5: + * - Opens via window 'secret:delete-request' custom event + * - Shows title "Delete \"{name}\"?" + * - Fetches dependents live on open + * - Delete button disabled for 1s (CONFIRM_DELAY_MS) + * - Focus-trapped (AlertDialog) + * + * NOTE: No @testing-library/jest-dom import — use DOM APIs. + * + * Covers: + * - Does not render when no delete request pending + * - Renders dialog when secret:delete-request fires + * - Title contains secret name + * - Cancel and Delete buttons present + * - role=alertdialog on dialog content + * - Delete button disabled initially (1s delay) + * - Delete button enabled after delay + * - Loading state while fetching dependents + * - Shows dependents list when present + * - Shows no-dependents message when none + * - Cancel closes dialog + * - Delete button calls deleteSecret and shows Deleting… state + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import React from "react"; + +import { DeleteConfirmDialog } from "../DeleteConfirmDialog"; + +// ─── Mocks ───────────────────────────────────────────────────────────────────── + +const _mockDeleteSecret = vi.fn<() => Promise>(); +const _mockFetchDependents = vi.fn<() => Promise>(); + +vi.mock("@/stores/secrets-store", () => ({ + useSecretsStore: (selector?: (s: { deleteSecret: () => Promise }) => unknown) => { + const state = { deleteSecret: _mockDeleteSecret }; + return selector ? selector(state) : state; + }, +})); + +vi.mock("@/lib/api/secrets", () => ({ + fetchDependents: (workspaceId: string, name: string) => + _mockFetchDependents(workspaceId, name), +})); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.resetModules(); +}); + +beforeEach(() => { + _mockDeleteSecret.mockResolvedValue(undefined); + _mockFetchDependents.mockResolvedValue([]); +}); + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +/** Dispatches secret:delete-request inside act() so React processes the event. */ +function fireDeleteRequest(secretName: string) { + act(() => { + window.dispatchEvent( + new CustomEvent("secret:delete-request", { + detail: secretName, + }), + ); + }); +} + +// ─── Render ──────────────────────────────────────────────────────────────────── + +describe("DeleteConfirmDialog — render", () => { + it("does not render when no delete request pending", () => { + render(); + expect(document.body.textContent ?? "").toBe(""); + }); + + it("renders dialog when secret:delete-request fires", () => { + render(); + fireDeleteRequest("ANTHROPIC_API_KEY"); + expect(document.querySelector('[role="alertdialog"]')).toBeTruthy(); + }); + + it("title contains secret name", () => { + render(); + fireDeleteRequest("GITHUB_TOKEN"); + const dialog = document.querySelector('[role="alertdialog"]'); + expect(dialog?.textContent ?? "").toContain("GITHUB_TOKEN"); + }); + + it("Cancel button present", () => { + render(); + fireDeleteRequest("TEST_KEY"); + const cancelBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Cancel", + ); + expect(cancelBtn).toBeTruthy(); + }); + + it("Delete button present", () => { + render(); + fireDeleteRequest("TEST_KEY"); + const deleteBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent?.includes("Delete key"), + ); + expect(deleteBtn).toBeTruthy(); + }); + + it("role=alertdialog on dialog content", () => { + render(); + fireDeleteRequest("TEST_KEY"); + expect(document.querySelector('[role="alertdialog"]')).toBeTruthy(); + }); +}); + +// ─── Confirm delay ───────────────────────────────────────────────────────────── + +describe("DeleteConfirmDialog — confirm delay", () => { + it("Delete button disabled initially (< 1s)", () => { + render(); + fireDeleteRequest("FAST_KEY"); + const deleteBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent?.includes("Delete key"), + ) as HTMLButtonElement; + expect(deleteBtn.disabled).toBe(true); + }); + + it("Delete button enabled after 1s delay", async () => { + render(); + fireDeleteRequest("DELAYED_KEY"); + const deleteBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent?.includes("Delete key"), + ) as HTMLButtonElement; + // Wait just over 1s + await new Promise((r) => setTimeout(r, 1010)); + expect(deleteBtn.disabled).toBe(false); + }); +}); + +// ─── Dependents fetch ───────────────────────────────────────────────────────── + +describe("DeleteConfirmDialog — dependents", () => { + it("shows loading state while fetching", () => { + _mockFetchDependents.mockImplementation( + () => new Promise(() => {}), // never resolves + ); + render(); + fireDeleteRequest("LOADING_KEY"); + expect(document.body.textContent ?? "").toContain("Checking for dependent agents"); + }); + + it("shows dependents list when present", async () => { + _mockFetchDependents.mockResolvedValue(["agent-alpha", "agent-beta"]); + render(); + fireDeleteRequest("SHARED_KEY"); + // Wait for fetch to resolve + await new Promise((r) => setTimeout(r, 10)); + expect(document.body.textContent ?? "").toContain("agent-alpha"); + }); + + it("shows no-dependents message when none", async () => { + render(); + fireDeleteRequest("SOLO_KEY"); + await new Promise((r) => setTimeout(r, 10)); + expect(document.body.textContent ?? "").toContain("No agents currently use this key"); + }); + + it("fetchDependents called with workspaceId and secretName", async () => { + render(); + fireDeleteRequest("MY_SECRET"); + await new Promise((r) => setTimeout(r, 10)); + expect(_mockFetchDependents).toHaveBeenCalledWith("ws1", "MY_SECRET"); + }); +}); + +// ─── Interaction ─────────────────────────────────────────────────────────────── + +describe("DeleteConfirmDialog — interaction", () => { + it("Cancel closes the dialog", async () => { + render(); + fireDeleteRequest("CANCEL_KEY"); + expect(document.querySelector('[role="alertdialog"]')).toBeTruthy(); + const cancelBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Cancel", + ) as HTMLButtonElement; + act(() => { + cancelBtn.click(); + }); + expect(document.querySelector('[role="alertdialog"]')).toBeNull(); + }); + + it("Delete calls deleteSecret when enabled and clicked", async () => { + render(); + fireDeleteRequest("DELETE_ME"); + // Wait for 1s delay + await new Promise((r) => setTimeout(r, 1010)); + const deleteBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent?.includes("Delete key"), + ) as HTMLButtonElement; + act(() => { + deleteBtn.click(); + }); + expect(_mockDeleteSecret).toHaveBeenCalledTimes(1); + }); + + it("Delete button text is 'Delete key' before clicking", async () => { + render(); + fireDeleteRequest("BTN_TEXT_KEY"); + await new Promise((r) => setTimeout(r, 1010)); + const deleteBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent?.includes("Delete key"), + ); + expect(deleteBtn).toBeTruthy(); + // Confirm text is NOT "Deleting…" before click + const deletingBtn = Array.from(document.querySelectorAll("button")).find( + (b) => (b.textContent ?? "").includes("Deleting"), + ); + expect(deletingBtn).toBeUndefined(); + }); +}); diff --git a/canvas/src/components/settings/__tests__/SettingsButton.test.tsx b/canvas/src/components/settings/__tests__/SettingsButton.test.tsx new file mode 100644 index 00000000..ef90c185 --- /dev/null +++ b/canvas/src/components/settings/__tests__/SettingsButton.test.tsx @@ -0,0 +1,175 @@ +// @vitest-environment jsdom +/** + * SettingsButton — gear icon in top bar, toggles SettingsPanel. + * + * Per spec §1.1: + * - Gear icon, aria-label="Settings" + * - aria-expanded reflects panel open state + * - Tooltip shows keyboard shortcut + * - Active state class when panel open + * + * NOTE: No @testing-library/jest-dom import — use DOM APIs. + * + * Covers: + * - Button has aria-label="Settings" + * - Gear SVG has aria-hidden="true" + * - aria-expanded is false when panel closed + * - aria-expanded is true when panel open + * - Toggle calls openPanel / closePanel + * - Active class applied when panel open + * - Tooltip content shows correct shortcut + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import React from "react"; + +// ResizeObserver polyfill required by Radix Tooltip's use-size hook +globalThis.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +import { SettingsButton } from "../SettingsButton"; + +// ─── Store mock ──────────────────────────────────────────────────────────────── + +const _mockIsPanelOpen = vi.fn<() => boolean>(() => false); +const _mockOpenPanel = vi.fn(); +const _mockClosePanel = vi.fn(); + +vi.mock("@/stores/secrets-store", () => ({ + useSecretsStore: (selector?: (s: { + isPanelOpen: boolean; + openPanel: () => void; + closePanel: () => void; + }) => unknown) => { + const state = { + isPanelOpen: _mockIsPanelOpen(), + openPanel: _mockOpenPanel, + closePanel: _mockClosePanel, + }; + return selector ? selector(state) : state; + }, +})); + +// Mock navigator for isMac detection +Object.defineProperty(navigator, "userAgent", { + configurable: true, + value: "Macintosh", +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.resetModules(); +}); + +beforeEach(() => { + _mockIsPanelOpen.mockReturnValue(false); + _mockOpenPanel.mockClear(); + _mockClosePanel.mockClear(); +}); + +// ─── Render ──────────────────────────────────────────────────────────────────── + +describe("SettingsButton — render", () => { + it("button has aria-label='Settings'", () => { + render(); + const btn = document.querySelector("button"); + expect(btn?.getAttribute("aria-label")).toBe("Settings"); + }); + + it("gear SVG has aria-hidden='true'", () => { + render(); + const svg = document.querySelector("svg"); + expect(svg?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("aria-expanded is false when panel is closed", () => { + _mockIsPanelOpen.mockReturnValue(false); + render(); + const btn = document.querySelector("button"); + expect(btn?.getAttribute("aria-expanded")).toBe("false"); + }); + + it("aria-expanded is true when panel is open", () => { + _mockIsPanelOpen.mockReturnValue(true); + render(); + const btn = document.querySelector("button"); + expect(btn?.getAttribute("aria-expanded")).toBe("true"); + }); + + it("button has settings-button class", () => { + render(); + const btn = document.querySelector("button"); + expect(btn?.className).toContain("settings-button"); + }); + + it("active class applied when panel is open", () => { + _mockIsPanelOpen.mockReturnValue(true); + render(); + const btn = document.querySelector("button"); + expect(btn?.className).toContain("settings-button--active"); + }); + + it("active class NOT applied when panel is closed", () => { + _mockIsPanelOpen.mockReturnValue(false); + render(); + const btn = document.querySelector("button"); + expect(btn?.className).not.toContain("settings-button--active"); + }); +}); + +// ─── Interaction ─────────────────────────────────────────────────────────────── + +describe("SettingsButton — interaction", () => { + it("clicking when panel closed calls openPanel", () => { + _mockIsPanelOpen.mockReturnValue(false); + render(); + const btn = document.querySelector("button") as HTMLButtonElement; + btn.click(); + expect(_mockOpenPanel).toHaveBeenCalledTimes(1); + expect(_mockClosePanel).not.toHaveBeenCalled(); + }); + + it("clicking when panel open calls closePanel", () => { + _mockIsPanelOpen.mockReturnValue(true); + render(); + const btn = document.querySelector("button") as HTMLButtonElement; + btn.click(); + expect(_mockClosePanel).toHaveBeenCalledTimes(1); + expect(_mockOpenPanel).not.toHaveBeenCalled(); + }); + + it("tooltip shows Mac shortcut on Mac", async () => { + Object.defineProperty(navigator, "userAgent", { + configurable: true, + value: "Macintosh", + }); + render(); + const btn = document.querySelector("button") as HTMLButtonElement; + act(() => { fireEvent.focus(btn); }); + // Wait for Radix tooltip delay (300ms) + render + await waitFor(() => { + const tooltipText = document.body.textContent ?? ""; + expect(tooltipText).toContain("Settings"); + expect(tooltipText).toContain("⌘"); + }); + }); + + it("tooltip shows Ctrl+ shortcut on non-Mac", async () => { + Object.defineProperty(navigator, "userAgent", { + configurable: true, + value: "Windows", + }); + render(); + const btn = document.querySelector("button") as HTMLButtonElement; + act(() => { fireEvent.focus(btn); }); + await waitFor(() => { + const tooltipText = document.body.textContent ?? ""; + expect(tooltipText).toContain("Settings"); + expect(tooltipText).toContain("Ctrl"); + }); + }); +});