Merge remote-tracking branch 'origin/main' into trig-221

This commit is contained in:
Molecule AI Core Platform Lead 2026-05-10 02:34:18 +00:00
commit b97bda13e9
7 changed files with 1348 additions and 0 deletions

View File

@ -0,0 +1,376 @@
// @vitest-environment jsdom
/**
* Tests for ContextMenu component.
*
* Covers: null guard, node header (name + status), outside-click close,
* Escape close, arrow-key navigation, conditional menu items by status,
* danger items, dividers, rAF position clamping.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ContextMenu } from "../ContextMenu";
import { useCanvasStore } from "@/store/canvas";
import { showToast } from "../Toaster";
// ─── Mock Toaster ─────────────────────────────────────────────────────────────
vi.mock("../Toaster", () => ({
showToast: vi.fn(),
}));
// ─── Mock API ────────────────────────────────────────────────────────────────
const apiPost = vi.fn().mockResolvedValue(undefined as void);
const apiPatch = vi.fn().mockResolvedValue(undefined as void);
vi.mock("@/lib/api", () => ({
api: {
post: apiPost,
patch: apiPatch,
get: vi.fn(),
},
}));
// ─── Mock store ──────────────────────────────────────────────────────────────
const mockStoreState = {
contextMenu: null as {
x: number;
y: number;
nodeId: string;
nodeData: {
name: string;
status: string;
tier: number;
role: string;
parentId?: string | null;
collapsed?: boolean;
};
} | null,
closeContextMenu: vi.fn(),
updateNodeData: vi.fn(),
selectNode: vi.fn(),
setPanelTab: vi.fn(),
nestNode: vi.fn().mockResolvedValue(undefined as void),
setPendingDelete: vi.fn(),
setCollapsed: vi.fn(),
arrangeChildren: vi.fn(),
nodes: [] as Array<{
id: string;
data: { parentId?: string | null };
}>,
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
{ getState: () => mockStoreState },
),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
function openMenu(overrides?: Partial<NonNullable<typeof mockStoreState.contextMenu>>) {
mockStoreState.contextMenu = {
x: 100,
y: 200,
nodeId: "n1",
nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" },
...overrides,
};
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("ContextMenu — visibility", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.updateNodeData.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
mockStoreState.nestNode.mockClear();
mockStoreState.setPendingDelete.mockClear();
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
it("renders nothing when contextMenu is null", () => {
mockStoreState.contextMenu = null;
render(<ContextMenu />);
expect(screen.queryByRole("menu")).toBeNull();
});
it("renders the menu when contextMenu is set", () => {
openMenu();
render(<ContextMenu />);
expect(screen.getByRole("menu")).toBeTruthy();
});
it("has aria-label describing the node name", () => {
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
render(<ContextMenu />);
expect(screen.getByRole("menu").getAttribute("aria-label")).toBe("Actions for Alice");
});
it("shows the node name in the header", () => {
openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } });
render(<ContextMenu />);
expect(screen.getByText("Bob")).toBeTruthy();
});
it("shows the node status in the header", () => {
openMenu({ nodeData: { name: "Alice", status: "failed", tier: 4, role: "assistant" } });
render(<ContextMenu />);
expect(screen.getByText("failed")).toBeTruthy();
});
});
describe("ContextMenu — close", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.updateNodeData.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
mockStoreState.nestNode.mockClear();
mockStoreState.setPendingDelete.mockClear();
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
it("closes when clicking outside the menu", () => {
openMenu();
render(<ContextMenu />);
fireEvent.mouseDown(document.body);
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
});
it("closes when Escape is pressed", () => {
openMenu();
render(<ContextMenu />);
fireEvent.keyDown(document.body, { key: "Escape" });
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
});
it("closes when Tab is pressed", () => {
openMenu();
render(<ContextMenu />);
fireEvent.keyDown(document.body, { key: "Tab" });
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
});
});
describe("ContextMenu — menu items", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.updateNodeData.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
mockStoreState.nestNode.mockClear();
mockStoreState.setPendingDelete.mockClear();
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
it("shows Chat and Terminal only for online nodes", () => {
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
render(<ContextMenu />);
expect(screen.getByRole("menuitem", { name: /chat/i })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: /terminal/i })).toBeTruthy();
});
it("hides Chat and Terminal for offline nodes", () => {
openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } });
render(<ContextMenu />);
expect(screen.queryByRole("menuitem", { name: /chat/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /terminal/i })).toBeNull();
});
it("shows Pause for online nodes (not paused)", () => {
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
render(<ContextMenu />);
expect(screen.getByRole("menuitem", { name: /pause/i })).toBeTruthy();
});
it("shows Resume for paused nodes (not Pause)", () => {
openMenu({ nodeData: { name: "Carol", status: "paused", tier: 3, role: "writer" } });
render(<ContextMenu />);
expect(screen.queryByRole("menuitem", { name: /pause/i })).toBeNull();
expect(screen.getByRole("menuitem", { name: /resume/i })).toBeTruthy();
});
it("shows Extract from Team only for child nodes", () => {
openMenu({ nodeData: { name: "Child", status: "online", tier: 4, role: "", parentId: "parent1" } });
render(<ContextMenu />);
expect(screen.getByRole("menuitem", { name: /extract/i })).toBeTruthy();
});
it("hides Extract from Team for root nodes", () => {
openMenu({ nodeData: { name: "Root", status: "online", tier: 4, role: "", parentId: null } });
render(<ContextMenu />);
expect(screen.queryByRole("menuitem", { name: /extract/i })).toBeNull();
});
it("shows team items only when node has children", () => {
openMenu({ nodeData: { name: "Parent", status: "online", tier: 4, role: "" } });
mockStoreState.nodes = [{ id: "child1", data: { parentId: "n1" } }];
render(<ContextMenu />);
expect(screen.getByRole("menuitem", { name: /arrange/i })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: /collapse/i })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: /zoom/i })).toBeTruthy();
});
it("hides team items when node has no children", () => {
openMenu({ nodeData: { name: "Leaf", status: "online", tier: 4, role: "" } });
mockStoreState.nodes = [];
render(<ContextMenu />);
expect(screen.queryByRole("menuitem", { name: /arrange/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /collapse/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /zoom/i })).toBeNull();
});
it("shows Collapse Team when collapsed, Expand Team when expanded", () => {
openMenu({ nodeData: { name: "Parent", status: "online", tier: 4, role: "", collapsed: true } });
mockStoreState.nodes = [{ id: "child1", data: { parentId: "n1" } }];
render(<ContextMenu />);
expect(screen.getByRole("menuitem", { name: /expand/i })).toBeTruthy();
});
it("Delete item has danger styling class", () => {
openMenu();
render(<ContextMenu />);
const deleteItem = screen.getByRole("menuitem", { name: /delete/i });
expect(deleteItem.getAttribute("class")).toMatch(/text-bad|bad/);
});
it("renders role=separator for dividers", () => {
openMenu();
render(<ContextMenu />);
expect(document.body.querySelectorAll('[role="separator"]').length).toBeGreaterThan(0);
});
});
describe("ContextMenu — keyboard navigation", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.updateNodeData.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
mockStoreState.nestNode.mockClear();
mockStoreState.setPendingDelete.mockClear();
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
it("ArrowDown moves focus to next enabled menuitem", () => {
openMenu();
render(<ContextMenu />);
const menu = screen.getByRole("menu");
// First tab goes to Details (first non-disabled item)
fireEvent.keyDown(menu, { key: "ArrowDown" });
const buttons = screen.getAllByRole("menuitem");
const focusedIdx = buttons.findIndex((b) => document.activeElement === b);
expect(focusedIdx).toBeGreaterThanOrEqual(0);
});
it("ArrowUp moves focus to previous enabled menuitem", () => {
openMenu();
render(<ContextMenu />);
const menu = screen.getByRole("menu");
fireEvent.keyDown(menu, { key: "ArrowDown" });
const beforeFocused = document.activeElement;
fireEvent.keyDown(menu, { key: "ArrowUp" });
// Focus should have moved
expect(document.activeElement).toBeTruthy();
});
});
describe("ContextMenu — item actions", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.updateNodeData.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
mockStoreState.nestNode.mockClear();
mockStoreState.setPendingDelete.mockClear();
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
it("Details selects node and opens details tab", () => {
openMenu();
render(<ContextMenu />);
fireEvent.click(screen.getByRole("menuitem", { name: /details/i }));
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1");
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details");
});
it("Chat selects node and opens chat tab", () => {
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
render(<ContextMenu />);
fireEvent.click(screen.getByRole("menuitem", { name: /chat/i }));
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1");
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("chat");
});
it("Delete calls setPendingDelete without closing immediately", () => {
openMenu();
render(<ContextMenu />);
fireEvent.click(screen.getByRole("menuitem", { name: /delete/i }));
expect(mockStoreState.setPendingDelete).toHaveBeenCalled();
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
});
it("Pause calls the pause API and updates node status optimistically", async () => {
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
apiPost.mockResolvedValue(undefined);
render(<ContextMenu />);
fireEvent.click(screen.getByRole("menuitem", { name: /pause/i }));
await act(async () => { /* flush */ });
expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/pause", {});
expect(mockStoreState.updateNodeData).toHaveBeenCalledWith("n1", { status: "paused" });
});
it("Resume calls the resume API", async () => {
openMenu({ nodeData: { name: "Alice", status: "paused", tier: 4, role: "assistant" } });
apiPost.mockResolvedValue(undefined);
render(<ContextMenu />);
fireEvent.click(screen.getByRole("menuitem", { name: /resume/i }));
await act(async () => { /* flush */ });
expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/resume", {});
});
});

View File

@ -0,0 +1,174 @@
// @vitest-environment jsdom
/**
* Tests for OnboardingWizard component.
*
* Covers: renders only when not dismissed, renders 4 steps, dismiss
* button, localStorage persistence, progress bar width, step navigation,
* auto-advance from welcomeapi-key on nodes change, aria-live region.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OnboardingWizard } from "../OnboardingWizard";
import { useCanvasStore } from "@/store/canvas";
const mockStoreState = {
nodes: [] as Array<{ id: string; data: Record<string, unknown> }>,
selectedNodeId: null as string | null,
panelTab: "chat" as string,
agentMessages: {} as Record<string, unknown[]>,
setPanelTab: vi.fn(),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
{ getState: () => mockStoreState },
),
}));
const STORAGE_KEY = "molecule-onboarding-complete";
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string): string | null => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
removeItem: vi.fn((key: string) => { delete store[key]; }),
clear: () => { store = {}; },
getStore: () => store,
};
})();
Object.defineProperty(window, "localStorage", { value: localStorageMock });
afterEach(() => {
cleanup();
localStorageMock.clear();
vi.clearAllMocks();
// Reset mutable store properties (mockStoreState is const, so mutate fields)
mockStoreState.nodes = [];
mockStoreState.selectedNodeId = null;
mockStoreState.panelTab = "chat";
mockStoreState.agentMessages = {};
mockStoreState.setPanelTab = vi.fn();
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("OnboardingWizard — visibility", () => {
it("renders nothing when localStorage has the complete flag", () => {
localStorageMock.getItem.mockReturnValueOnce("true");
render(<OnboardingWizard />);
expect(screen.queryByRole("complementary")).toBeNull();
});
it("renders the wizard for first-time users (no localStorage flag)", () => {
localStorageMock.getItem.mockReturnValueOnce(null);
render(<OnboardingWizard />);
expect(screen.getByRole("complementary", { name: "Onboarding guide" })).toBeTruthy();
});
});
describe("OnboardingWizard — steps", () => {
beforeEach(() => {
localStorageMock.getItem.mockReturnValue(null);
});
it("renders step 1 'Welcome to Molecule AI' on first paint", () => {
render(<OnboardingWizard />);
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
expect(screen.getByText("Step 1 of 4")).toBeTruthy();
});
it("renders the 'Skip guide' button", () => {
render(<OnboardingWizard />);
expect(screen.getByRole("button", { name: "Skip onboarding guide" })).toBeTruthy();
});
it("renders the progress bar", () => {
render(<OnboardingWizard />);
// Progress bar is inside a div
const bar = document.body.querySelector(".h-full.bg-gradient-to-r");
expect(bar).toBeTruthy();
// Step 1 should be 25% wide
expect(bar?.getAttribute("style")).toContain("25%");
});
it("advances to step 2 'Set your API key' when Next is clicked", () => {
render(<OnboardingWizard />);
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("Set your API key")).toBeTruthy();
expect(screen.getByText("Step 2 of 4")).toBeTruthy();
});
it("advances to step 3 'Send your first message' when Next is clicked twice", () => {
render(<OnboardingWizard />);
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("Send your first message")).toBeTruthy();
expect(screen.getByText("Step 3 of 4")).toBeTruthy();
});
it("shows 'Get Started' button on the last step", () => {
render(<OnboardingWizard />);
// Navigate to done step
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("You're all set!")).toBeTruthy();
expect(screen.getByRole("button", { name: "Get Started" })).toBeTruthy();
});
it("dismisses the wizard when 'Skip guide' is clicked", () => {
render(<OnboardingWizard />);
expect(screen.getByRole("complementary")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Skip onboarding guide" }));
expect(screen.queryByRole("complementary")).toBeNull();
});
it("persists the dismissed state to localStorage when dismissed", () => {
render(<OnboardingWizard />);
fireEvent.click(screen.getByRole("button", { name: "Skip onboarding guide" }));
expect(localStorageMock.setItem).toHaveBeenCalledWith(STORAGE_KEY, "true");
});
});
describe("OnboardingWizard — auto-advance", () => {
beforeEach(() => {
localStorageMock.getItem.mockReturnValue(null);
});
it("auto-advances from welcome to api-key when nodes appear", async () => {
const { unmount } = render(<OnboardingWizard />);
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
// Simulate a node being added to the store and re-render
mockStoreState.nodes = [{ id: "ws-1", data: {} }];
render(<OnboardingWizard />);
await waitFor(() => {
expect(screen.queryByText("Welcome to Molecule AI")).toBeNull();
});
expect(screen.getByText("Set your API key")).toBeTruthy();
unmount();
});
});
describe("OnboardingWizard — accessibility", () => {
beforeEach(() => {
localStorageMock.getItem.mockReturnValue(null);
});
it("has aria-live='polite' region for step announcements", () => {
render(<OnboardingWizard />);
const liveRegion = document.body.querySelector('[aria-live="polite"]');
expect(liveRegion).toBeTruthy();
expect(liveRegion?.textContent).toMatch(/onboarding step 1/i);
});
it("has role=complementary with aria-label", () => {
render(<OnboardingWizard />);
expect(screen.getByRole("complementary", { name: "Onboarding guide" })).toBeTruthy();
});
});

View File

@ -0,0 +1,255 @@
// @vitest-environment jsdom
/**
* Tests for PurchaseSuccessModal component.
*
* Covers: no render when no URL params, renders with ?purchase_success=1,
* portal rendering, item name from &item=, auto-dismiss after 5s,
* manual dismiss, backdrop click close, Escape key close, URL stripping,
* focus management.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PurchaseSuccessModal } from "../PurchaseSuccessModal";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function pushUrl(url: string) {
window.history.pushState({}, "", url);
}
function replaceUrl(url: string) {
window.history.replaceState({}, "", url);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("PurchaseSuccessModal — render conditions", () => {
beforeEach(() => {
replaceUrl("http://localhost/");
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("renders nothing when URL has no purchase_success param", () => {
replaceUrl("http://localhost/");
render(<PurchaseSuccessModal />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders nothing on a plain URL", () => {
replaceUrl("http://localhost/dashboard?foo=bar");
render(<PurchaseSuccessModal />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders the dialog when ?purchase_success=1 is present", async () => {
replaceUrl("http://localhost/?purchase_success=1");
render(<PurchaseSuccessModal />);
// useEffect fires after mount
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("dialog")).toBeTruthy();
});
it("renders the dialog when ?purchase_success=true is present", async () => {
replaceUrl("http://localhost/?purchase_success=true");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("dialog")).toBeTruthy();
});
it("renders a portal attached to document.body", async () => {
replaceUrl("http://localhost/?purchase_success=1");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).toBeTruthy();
});
it("shows the item name when &item= is present", async () => {
replaceUrl("http://localhost/?purchase_success=1&item=MyAgent");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText("MyAgent")).toBeTruthy();
expect(screen.getByText("Purchase successful")).toBeTruthy();
});
it("shows 'Your new agent' when no item param is present", async () => {
replaceUrl("http://localhost/?purchase_success=1");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText("Your new agent")).toBeTruthy();
});
it("decodes URI-encoded item names", async () => {
replaceUrl("http://localhost/?purchase_success=1&item=Claude%20Code%20Agent");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText("Claude Code Agent")).toBeTruthy();
});
});
describe("PurchaseSuccessModal — dismiss", () => {
beforeEach(() => {
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("closes the dialog when the close button is clicked", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.queryByRole("dialog")).toBeNull();
});
it("closes the dialog when the backdrop is clicked", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
// Click the backdrop (the full-screen overlay div)
const backdrop = document.body.querySelector('[aria-hidden="true"]');
if (backdrop) fireEvent.click(backdrop);
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.queryByRole("dialog")).toBeNull();
});
it("closes on Escape key", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
fireEvent.keyDown(window, { key: "Escape" });
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.queryByRole("dialog")).toBeNull();
});
it("auto-dismisses after 5 seconds", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
// Advance 5 seconds
act(() => { vi.advanceTimersByTime(5000); });
await act(async () => { /* flush */ });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("does not auto-dismiss before 5 seconds", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
act(() => { vi.advanceTimersByTime(4900); });
await act(async () => { /* flush */ });
expect(screen.queryByRole("dialog")).toBeTruthy();
});
});
describe("PurchaseSuccessModal — URL stripping", () => {
beforeEach(() => {
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("strips purchase_success and item params from the URL on mount", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const url = new URL(window.location.href);
expect(url.searchParams.get("purchase_success")).toBeNull();
expect(url.searchParams.get("item")).toBeNull();
});
it("uses replaceState (not pushState) so back-button does not re-trigger", async () => {
const replaceSpy = vi.spyOn(window.history, "replaceState");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(replaceSpy).toHaveBeenCalled();
});
});
describe("PurchaseSuccessModal — accessibility", () => {
beforeEach(() => {
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("has aria-modal=true on the dialog", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const dialog = screen.getByRole("dialog");
expect(dialog.getAttribute("aria-modal")).toBe("true");
});
it("has aria-labelledby pointing to the title", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const dialog = screen.getByRole("dialog");
const labelledby = dialog.getAttribute("aria-labelledby");
expect(labelledby).toBeTruthy();
expect(document.getElementById(labelledby!)).toBeTruthy();
expect(document.getElementById(labelledby!)?.textContent).toMatch(/purchase successful/i);
});
it("moves focus to the close button on open", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
// Two rAFs for focus: one from the effect, one from the RAF wrapper
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
});
expect(document.activeElement?.textContent).toMatch(/close/i);
});
});

View File

@ -0,0 +1,351 @@
// @vitest-environment jsdom
/**
* Tests for SearchDialog component.
*
* Covers: renders only when open, Cmd+K/Ctrl+K shortcut, Escape close,
* focus management, text filtering (name/role/status), arrow-key
* navigation, Enter to select, footer count, aria attributes.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SearchDialog } from "../SearchDialog";
import { useCanvasStore } from "@/store/canvas";
// ─── Mock store ──────────────────────────────────────────────────────────────
const mockStoreState = {
searchOpen: false,
setSearchOpen: vi.fn((open: boolean) => {
mockStoreState.searchOpen = open;
}),
nodes: [] as Array<{
id: string;
data: {
name: string;
status: string;
tier: number;
role: string;
parentId?: string | null;
};
}>,
selectNode: vi.fn(),
setPanelTab: vi.fn(),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
{ getState: () => mockStoreState },
),
}));
const STORAGE_KEY = "molecule-onboarding-complete";
// ─── Helpers ─────────────────────────────────────────────────────────────────
function dispatchKeydown(key: string, meta = false, ctrl = false) {
fireEvent.keyDown(window, {
key,
metaKey: meta,
ctrlKey: ctrl,
});
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("SearchDialog — visibility", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("does not render when searchOpen is false", () => {
mockStoreState.searchOpen = false;
render(<SearchDialog />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders the dialog when searchOpen is true", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
expect(screen.getByRole("dialog", { name: "Search workspaces" })).toBeTruthy();
});
});
describe("SearchDialog — keyboard shortcuts", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("opens the dialog when Cmd+K is pressed", () => {
render(<SearchDialog />);
dispatchKeydown("k", true, false);
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(true);
});
it("opens the dialog when Ctrl+K is pressed", () => {
render(<SearchDialog />);
dispatchKeydown("k", false, true);
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(true);
});
it("clears the query when Cmd+K opens the dialog", () => {
render(<SearchDialog />);
dispatchKeydown("k", true, false);
const input = screen.getByRole("combobox");
expect(input.getAttribute("value") ?? "").toBe("");
});
it("closes the dialog when Escape is pressed while open", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
dispatchKeydown("Escape");
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false);
});
});
describe("SearchDialog — focus", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("focuses the input when the dialog opens", async () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
await act(async () => {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
});
expect(document.activeElement?.getAttribute("role")).toBe("combobox");
});
it("input has the combobox role", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
expect(screen.getByRole("combobox")).toBeTruthy();
});
});
describe("SearchDialog — filtering", () => {
beforeEach(() => {
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
{ id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } },
{ id: "n3", data: { name: "Carol", status: "online", tier: 3, role: "writer" } },
];
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("shows all workspaces when query is empty", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.getByText("Bob")).toBeTruthy();
expect(screen.getByText("Carol")).toBeTruthy();
});
it("filters workspaces by name (case-insensitive)", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "alice" } });
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.queryByText("Bob")).toBeNull();
expect(screen.queryByText("Carol")).toBeNull();
});
it("filters workspaces by role (case-insensitive)", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "writer" } });
expect(screen.queryByText("Alice")).toBeNull();
expect(screen.queryByText("Bob")).toBeNull();
expect(screen.getByText("Carol")).toBeTruthy();
});
it("filters workspaces by status", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "online" } });
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.queryByText("Bob")).toBeNull();
expect(screen.getByText("Carol")).toBeTruthy();
});
it("shows 'No workspaces match' when filtering returns nothing", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "xyz123" } });
expect(screen.getByText("No workspaces match")).toBeTruthy();
});
it("shows 'No workspaces yet' when canvas is empty", () => {
mockStoreState.searchOpen = true;
mockStoreState.nodes = [];
render(<SearchDialog />);
expect(screen.getByText("No workspaces yet")).toBeTruthy();
});
});
describe("SearchDialog — listbox navigation", () => {
beforeEach(() => {
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
{ id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } },
{ id: "n3", data: { name: "Carol", status: "online", tier: 3, role: "writer" } },
];
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("highlights the first result when query is typed", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "a" } });
// First result (Alice) should be highlighted
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("true");
});
it("ArrowDown moves highlight to the next item", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
fireEvent.keyDown(input, { key: "ArrowDown" });
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("false");
expect(options[1].getAttribute("aria-selected")).toBe("true");
});
it("ArrowUp moves highlight to the previous item", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "ArrowUp" });
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("true");
expect(options[1].getAttribute("aria-selected")).toBe("false");
});
it("Enter selects the highlighted workspace", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
fireEvent.keyDown(input, { key: "ArrowDown" }); // Highlight Bob
fireEvent.keyDown(input, { key: "Enter" });
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1"); // Alice
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details");
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false);
});
});
describe("SearchDialog — aria attributes", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("dialog has role=dialog and aria-modal=true", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const dialog = screen.getByRole("dialog");
expect(dialog.getAttribute("aria-modal")).toBe("true");
expect(dialog.getAttribute("aria-label")).toBe("Search workspaces");
});
it("results container has role=listbox", () => {
mockStoreState.searchOpen = true;
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
];
render(<SearchDialog />);
expect(screen.getByRole("listbox")).toBeTruthy();
});
it("each result has role=option", () => {
mockStoreState.searchOpen = true;
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
];
render(<SearchDialog />);
expect(screen.getAllByRole("option").length).toBeGreaterThan(0);
});
});
describe("SearchDialog — footer", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("footer shows singular 'workspace' when count is 1", () => {
mockStoreState.searchOpen = true;
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
];
render(<SearchDialog />);
expect(screen.getByText("1 workspace")).toBeTruthy();
});
it("footer shows plural 'workspaces' when count > 1", () => {
mockStoreState.searchOpen = true;
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
{ id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } },
];
render(<SearchDialog />);
expect(screen.getByText("2 workspaces")).toBeTruthy();
});
});

View File

@ -0,0 +1,58 @@
// @vitest-environment jsdom
/**
* Tests for Spinner component.
*
* Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Spinner } from "../Spinner";
describe("Spinner — size variants", () => {
it("renders with sm size class", () => {
const { container } = render(<Spinner size="sm" />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
expect(svg?.className).toContain("w-3");
expect(svg?.className).toContain("h-3");
});
it("renders with md size class (default)", () => {
const { container } = render(<Spinner size="md" />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-4");
expect(svg?.className).toContain("h-4");
});
it("renders with lg size class", () => {
const { container } = render(<Spinner size="lg" />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-5");
expect(svg?.className).toContain("h-5");
});
it("defaults to md size when no size prop given", () => {
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-4");
expect(svg?.className).toContain("h-4");
});
it("has aria-hidden=true so screen readers skip it", () => {
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
it("includes the motion-safe:animate-spin class for CSS animation", () => {
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("motion-safe:animate-spin");
});
it("renders exactly one SVG element", () => {
const { container } = render(<Spinner />);
expect(container.querySelectorAll("svg").length).toBe(1);
});
});

View File

@ -0,0 +1,57 @@
// @vitest-environment jsdom
/**
* Tests for StatusBadge component.
*
* Covers: renders all three status variants, aria-label, role=status,
* icon presence, className variants, no render when passed invalid status.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { StatusBadge } from "../ui/StatusBadge";
describe("StatusBadge — render", () => {
it("renders verified status with ✓ icon", () => {
render(<StatusBadge status="verified" />);
const badge = screen.getByRole("status");
expect(badge.textContent).toBe("✓");
expect(badge.getAttribute("aria-label")).toBe("Connection status: verified");
});
it("renders invalid status with ✗ icon", () => {
render(<StatusBadge status="invalid" />);
const badge = screen.getByRole("status");
expect(badge.textContent).toBe("✗");
expect(badge.getAttribute("aria-label")).toBe("Connection status: invalid");
});
it("renders unverified status with ○ icon", () => {
render(<StatusBadge status="unverified" />);
const badge = screen.getByRole("status");
expect(badge.textContent).toBe("○");
expect(badge.getAttribute("aria-label")).toBe("Connection status: unverified");
});
it("has role=status on the badge element", () => {
render(<StatusBadge status="verified" />);
expect(screen.getByRole("status")).toBeTruthy();
});
it("includes the config className on the rendered element", () => {
render(<StatusBadge status="verified" />);
const badge = screen.getByRole("status");
expect(badge.className).toContain("status-badge--valid");
});
it("includes status-badge--invalid class for invalid status", () => {
render(<StatusBadge status="invalid" />);
const badge = screen.getByRole("status");
expect(badge.className).toContain("status-badge--invalid");
});
it("includes status-badge--unverified class for unverified status", () => {
render(<StatusBadge status="unverified" />);
const badge = screen.getByRole("status");
expect(badge.className).toContain("status-badge--unverified");
});
});

View File

@ -0,0 +1,77 @@
// @vitest-environment jsdom
/**
* Tests for ValidationHint component.
*
* Covers: error state, valid state, neutral/hidden state,
* aria-live for error, icon rendering.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ValidationHint } from "../ui/ValidationHint";
describe("ValidationHint — error state", () => {
it("renders error message when error is a non-null string", () => {
render(<ValidationHint error="Invalid email address" />);
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText("Invalid email address")).toBeTruthy();
});
it("includes the warning icon in error state", () => {
render(<ValidationHint error="Too short" />);
expect(screen.getByText(/⚠/)).toBeTruthy();
});
it("uses the error class on the paragraph element", () => {
render(<ValidationHint error="Bad input" />);
const el = screen.getByRole("alert");
expect(el.className).toContain("validation-hint--error");
});
it("renders error even when showValid is true", () => {
render(<ValidationHint error="Oops" showValid={true} />);
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.queryByText(/✓/)).toBeNull();
});
});
describe("ValidationHint — valid state", () => {
it("renders valid message when error is null and showValid is true", () => {
render(<ValidationHint error={null} showValid={true} />);
expect(screen.getByText("Valid format")).toBeTruthy();
});
it("includes the checkmark icon in valid state", () => {
render(<ValidationHint error={null} showValid={true} />);
expect(screen.getByText(/✓ Valid format/)).toBeTruthy();
});
it("uses the valid class on the paragraph element", () => {
render(<ValidationHint error={null} showValid={true} />);
const el = document.body.querySelector(".validation-hint--valid");
expect(el).toBeTruthy();
});
it("renders nothing when error is null and showValid is false (default)", () => {
const { container } = render(<ValidationHint error={null} />);
expect(container.textContent).toBe("");
});
it("renders nothing when error is empty string", () => {
const { container } = render(<ValidationHint error="" />);
expect(container.textContent).toBe("");
});
});
describe("ValidationHint — neutral / not-yet-validated", () => {
it("renders nothing when error is null and showValid defaults to false", () => {
const { container } = render(<ValidationHint error={null} />);
expect(container.textContent).toBe("");
});
it("renders nothing when error is undefined", () => {
// @ts-expect-error — testing runtime behavior with undefined
const { container } = render(<ValidationHint error={undefined} />);
expect(container.textContent).toBe("");
});
});