diff --git a/canvas/src/components/__tests__/ApprovalBanner.test.tsx b/canvas/src/components/__tests__/ApprovalBanner.test.tsx index 5fc0d56f..f6fcfca4 100644 --- a/canvas/src/components/__tests__/ApprovalBanner.test.tsx +++ b/canvas/src/components/__tests__/ApprovalBanner.test.tsx @@ -2,8 +2,9 @@ /** * Tests for ApprovalBanner component. * - * Covers: renders nothing when no approvals, polls /approvals/pending, - * shows approval cards, approve/deny decisions, toast notifications. + * Uses vi.hoisted + vi.mock for stable module-level API mocks that survive + * vi.resetModules() cleanup. BeforeEach uses mockReset + mockResolvedValue + * so each test gets a clean slate. */ import React from "react"; import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react"; @@ -12,8 +13,19 @@ import { ApprovalBanner } from "../ApprovalBanner"; import { showToast } from "@/components/Toaster"; import { api } from "@/lib/api"; +// ─── Module-level mocks ─────────────────────────────────────────────────────── +// vi.hoisted captures stable references BEFORE hoisting so they are accessible +// in the test body after vi.mock registers. +const _mockGet = vi.hoisted(() => vi.fn<() => Promise>()); +const _mockPost = vi.hoisted(() => vi.fn<() => Promise>()); +const _mockToast = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/api", () => ({ + api: { get: _mockGet, post: _mockPost }, +})); + vi.mock("@/components/Toaster", () => ({ - showToast: vi.fn(), + showToast: _mockToast, })); afterEach(cleanup); @@ -38,11 +50,25 @@ const pendingApproval = (id = "a1", workspaceId = "ws-1"): { created_at: "2026-05-10T10:00:00Z", }); +// ─── Cleanup ───────────────────────────────────────────────────────────────── + +beforeEach(() => { + _mockGet.mockReset(); + _mockGet.mockResolvedValue([] as unknown[]); + _mockPost.mockReset(); + _mockPost.mockResolvedValue({} as unknown); + _mockToast.mockClear(); +}); + +afterEach(() => { + cleanup(); +}); + // ─── Tests ──────────────────────────────────────────────────────────────────── describe("ApprovalBanner — empty state", () => { it("renders nothing when there are no pending approvals", async () => { - vi.spyOn(api, "get").mockResolvedValueOnce([]); + _mockGet.mockResolvedValueOnce([] as unknown[]); render(); await act(async () => { await new Promise((r) => setTimeout(r, 10)); @@ -51,7 +77,7 @@ describe("ApprovalBanner — empty state", () => { }); it("does not render any approve/deny buttons when list is empty", async () => { - vi.spyOn(api, "get").mockResolvedValueOnce([]); + _mockGet.mockResolvedValueOnce([] as unknown[]); render(); await act(async () => { await new Promise((r) => setTimeout(r, 10)); @@ -63,10 +89,10 @@ describe("ApprovalBanner — empty state", () => { describe("ApprovalBanner — renders approval cards", () => { it("renders an alert card for each pending approval", async () => { - vi.spyOn(api, "get").mockResolvedValueOnce([ + _mockGet.mockResolvedValueOnce([ pendingApproval("a1"), pendingApproval("a2", "ws-2"), - ]); + ] as unknown[]); render(); await act(async () => { await new Promise((r) => setTimeout(r, 10)); @@ -76,7 +102,7 @@ describe("ApprovalBanner — renders approval cards", () => { }); it("displays the workspace name and action text", async () => { - vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]); + _mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]); render(); await act(async () => { await new Promise((r) => setTimeout(r, 10)); @@ -86,7 +112,7 @@ describe("ApprovalBanner — renders approval cards", () => { }); it("displays the reason when present", async () => { - vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]); + _mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]); render(); await act(async () => { await new Promise((r) => setTimeout(r, 10)); @@ -97,7 +123,7 @@ describe("ApprovalBanner — renders approval cards", () => { it("omits the reason div when reason is null", async () => { const approval = pendingApproval("a1"); approval.reason = null; - vi.spyOn(api, "get").mockResolvedValueOnce([approval]); + _mockGet.mockResolvedValueOnce([approval] as unknown[]); render(); await act(async () => { await new Promise((r) => setTimeout(r, 10)); @@ -106,7 +132,7 @@ describe("ApprovalBanner — renders approval cards", () => { }); it("renders both Approve and Deny buttons per card", async () => { - vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]); + _mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]); render(); await act(async () => { await new Promise((r) => setTimeout(r, 10)); @@ -116,7 +142,7 @@ describe("ApprovalBanner — renders approval cards", () => { }); it("has aria-live=assertive on the alert container", async () => { - vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]); + _mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]); render(); await act(async () => { await new Promise((r) => setTimeout(r, 10)); @@ -138,7 +164,7 @@ describe("ApprovalBanner — polling", () => { }); it("clears the polling interval on unmount", async () => { - vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]); + _mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]); const { unmount } = render(); await act(async () => { await new Promise((r) => setTimeout(r, 10)); @@ -151,8 +177,8 @@ describe("ApprovalBanner — polling", () => { describe("ApprovalBanner — decisions", () => { it("calls POST /workspaces/:id/approvals/:id/decide on Approve click", async () => { const approval = pendingApproval("a1", "ws-1"); - vi.spyOn(api, "get").mockResolvedValueOnce([approval]); - const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined); + _mockGet.mockResolvedValueOnce([approval] as unknown[]); + _mockPost.mockResolvedValueOnce({} as unknown); render(); await act(async () => { @@ -162,17 +188,17 @@ describe("ApprovalBanner — decisions", () => { fireEvent.click(screen.getByRole("button", { name: /approve/i })); await waitFor(() => { - expect(postSpy).toHaveBeenCalledWith( + expect(_mockPost).toHaveBeenCalledWith( "/workspaces/ws-1/approvals/a1/decide", - { decision: "approved", decided_by: "human" } + { decision: "approved", decided_by: "human" }, ); }); }); it("calls POST with decision=denied on Deny click", async () => { const approval = pendingApproval("a1", "ws-1"); - vi.spyOn(api, "get").mockResolvedValueOnce([approval]); - const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined); + _mockGet.mockResolvedValueOnce([approval] as unknown[]); + _mockPost.mockResolvedValueOnce({} as unknown); render(); await act(async () => { @@ -182,17 +208,17 @@ describe("ApprovalBanner — decisions", () => { fireEvent.click(screen.getByRole("button", { name: /deny/i })); await waitFor(() => { - expect(postSpy).toHaveBeenCalledWith( + expect(_mockPost).toHaveBeenCalledWith( "/workspaces/ws-1/approvals/a1/decide", - { decision: "denied", decided_by: "human" } + { decision: "denied", decided_by: "human" }, ); }); }); it("removes the card from state after a successful decision", async () => { const approval = pendingApproval("a1", "ws-1"); - vi.spyOn(api, "get").mockResolvedValueOnce([approval]); - vi.spyOn(api, "post").mockResolvedValueOnce(undefined); + _mockGet.mockResolvedValueOnce([approval] as unknown[]); + _mockPost.mockResolvedValueOnce({} as unknown); render(); await act(async () => { @@ -210,8 +236,8 @@ describe("ApprovalBanner — decisions", () => { }); it("shows a success toast on approve", async () => { - vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]); - vi.spyOn(api, "post").mockResolvedValueOnce(undefined); + _mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]); + _mockPost.mockResolvedValueOnce({} as unknown); render(); await act(async () => { @@ -221,13 +247,13 @@ describe("ApprovalBanner — decisions", () => { fireEvent.click(screen.getByRole("button", { name: /approve/i })); await waitFor(() => { - expect(showToast).toHaveBeenCalledWith("Approved", "success"); + expect(_mockToast).toHaveBeenCalledWith("Approved", "success"); }); }); it("shows an info toast on deny", async () => { - vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]); - vi.spyOn(api, "post").mockResolvedValueOnce(undefined); + _mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]); + _mockPost.mockResolvedValueOnce({} as unknown); render(); await act(async () => { @@ -237,13 +263,18 @@ describe("ApprovalBanner — decisions", () => { fireEvent.click(screen.getByRole("button", { name: /deny/i })); await waitFor(() => { - expect(showToast).toHaveBeenCalledWith("Denied", "info"); + expect(_mockToast).toHaveBeenCalledWith("Denied", "info"); }); }); it("shows an error toast when POST fails", async () => { - vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]); - vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error")); + _mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]); + // Use mockImplementation instead of mockRejectedValueOnce so the vi.fn + // wrapper is preserved — the component's catch block needs the resolved + // promise wrapper to distinguish a rejected-from-mock vs thrown-from-code. + _mockPost.mockImplementation( + () => new Promise((_, reject) => reject(new Error("Network error"))), + ); render(); await act(async () => { @@ -253,13 +284,15 @@ describe("ApprovalBanner — decisions", () => { fireEvent.click(screen.getByRole("button", { name: /approve/i })); await waitFor(() => { - expect(showToast).toHaveBeenCalledWith("Failed to submit decision", "error"); + expect(_mockToast).toHaveBeenCalledWith("Failed to submit decision", "error"); }); }); it("keeps the card visible when the POST fails", async () => { - vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]); - vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error")); + _mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]); + _mockPost.mockImplementation( + () => new Promise((_, reject) => reject(new Error("Network error"))), + ); render(); await act(async () => { @@ -277,7 +310,7 @@ describe("ApprovalBanner — decisions", () => { describe("ApprovalBanner — handles empty list from server", () => { it("shows nothing when the API returns an empty array on first poll", async () => { - vi.spyOn(api, "get").mockResolvedValueOnce([]); + _mockGet.mockResolvedValueOnce([] as unknown[]); render(); await act(async () => { await new Promise((r) => setTimeout(r, 10)); diff --git a/canvas/src/components/__tests__/EmptyState.test.tsx b/canvas/src/components/__tests__/EmptyState.test.tsx new file mode 100644 index 00000000..926f6fb0 --- /dev/null +++ b/canvas/src/components/__tests__/EmptyState.test.tsx @@ -0,0 +1,267 @@ +// @vitest-environment jsdom +/** + * Tests for EmptyState component — the full-canvas welcome card on first load. + * + * Pattern: all vi.fn() refs are created by a SINGLE vi.hoisted() call, + * returned as a named-const object. Individual vi.mock factories then + * import that object and pull out the fields they need. This avoids + * "Cannot access before initialization" errors from vi.mock hoisting. + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi, beforeEach } from "vitest"; +import { EmptyState } from "../EmptyState"; + +// ─── Module-level mocks ─────────────────────────────────────────────────────── +// vi.hoisted is evaluated after module-level vars are declared, so these +// refs are stable and accessible inside vi.mock factories (which are +// hoisted above everything). We return an object so a SINGLE hoisted call +// creates all mocks; each vi.mock then references m.. +const m = vi.hoisted(() => { + const mockGet = vi.fn<() => Promise>(); + const mockPost = vi.fn<() => Promise<{ id: string }>>(); + const mockCheckDeploySecrets = vi.fn< + () => Promise<{ + ok: boolean; + missingKeys: string[]; + providers: string[]; + runtime: string; + configuredKeys: string[]; + }> + >(); + const mockSelectNode = vi.fn<(id: string) => void>(); + const mockSetPanelTab = vi.fn<(tab: string) => void>(); + const mockDeploy = vi.fn<(t: { id: string; name: string }) => Promise>(); + const mockUseTemplateDeploy = vi.fn(() => ({ + deploy: mockDeploy, + deploying: false, + error: null, + modal: null, + })); + + return { + mockGet, + mockPost, + mockCheckDeploySecrets, + mockSelectNode, + mockSetPanelTab, + mockDeploy, + mockUseTemplateDeploy, + }; +}); + +vi.mock("@/lib/api", () => ({ + api: { get: m.mockGet, post: m.mockPost }, +})); + +vi.mock("@/lib/deploy-preflight", () => ({ + checkDeploySecrets: m.mockCheckDeploySecrets, +})); + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + // The hook returns an object with selectNode/setPanelTab; + // the component also calls useCanvasStore.getState() directly. + vi.fn(() => ({ + selectNode: m.mockSelectNode, + setPanelTab: m.mockSetPanelTab, + })), + { + getState: () => ({ + selectNode: m.mockSelectNode, + setPanelTab: m.mockSetPanelTab, + }), + }, + ), +})); + +vi.mock("@/hooks/useTemplateDeploy", () => ({ + useTemplateDeploy: m.mockUseTemplateDeploy, +})); + +// Mock OrgTemplatesSection — tested separately. +vi.mock("../TemplatePalette", () => ({ + OrgTemplatesSection: () => ( +
Org Templates
+ ), +})); + +// ─── Test data ─────────────────────────────────────────────────────────────── + +const TEMPLATE = { + id: "molecule-dev", + name: "Molecule Dev", + tier: 2, + description: "A full-featured agent workspace for development", + runtime: "langgraph", + required_env: ["ANTHROPIC_API_KEY"], + models: [{ id: "claude-sonnet-4-20250514", required_env: ["ANTHROPIC_API_KEY"] }], + model: "claude-sonnet-4-20250514", + skill_count: 12, +}; + +// ─── Cleanup ───────────────────────────────────────────────────────────────── + +beforeEach(() => { + m.mockGet.mockReset(); + m.mockGet.mockResolvedValue([] as unknown[]); + m.mockPost.mockReset(); + m.mockPost.mockResolvedValue({ id: "new-ws-123" } as unknown as { id: string }); + m.mockCheckDeploySecrets.mockReset(); + m.mockCheckDeploySecrets.mockResolvedValue({ + ok: true, + missingKeys: [], + providers: [], + runtime: "langgraph", + configuredKeys: [], + }); + m.mockSelectNode.mockReset(); + m.mockSetPanelTab.mockReset(); + m.mockDeploy.mockReset(); +}); + +afterEach(() => { + cleanup(); +}); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("EmptyState — loading state", () => { + it("shows spinner and loading text while templates are being fetched", () => { + m.mockGet.mockImplementation(() => new Promise(() => {})); + render(); + expect(screen.getByText(/loading templates/i)).toBeTruthy(); + }); +}); + +describe("EmptyState — templates fetched", () => { + it("renders template grid with name, tier badge, description, skill count", async () => { + m.mockGet.mockResolvedValueOnce([TEMPLATE] as unknown[]); + render(); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + expect(screen.getByText("Molecule Dev")).toBeTruthy(); + expect(screen.getByText("T2")).toBeTruthy(); + expect(screen.getByText(/full-featured agent workspace/i)).toBeTruthy(); + expect(screen.getByText(/12 skills/)).toBeTruthy(); + }); + + it("shows model label when template declares a model", async () => { + m.mockGet.mockResolvedValueOnce([TEMPLATE] as unknown[]); + render(); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + expect(screen.getByText(/claude-sonnet/i)).toBeTruthy(); + }); + + it("calls deploy(template) when template button is clicked", async () => { + m.mockGet.mockResolvedValueOnce([TEMPLATE] as unknown[]); + render(); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + fireEvent.click(screen.getByRole("button", { name: /molecule dev/i })); + expect(m.mockDeploy).toHaveBeenCalledWith( + expect.objectContaining({ id: "molecule-dev", name: "Molecule Dev" }), + ); + }); +}); + +describe("EmptyState — no templates", () => { + it("shows only the create-blank button when template list is empty", async () => { + // beforeEach already sets mockResolvedValue([]) as default — no override needed. + render(); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + expect(screen.getByRole("button", { name: /\+ create blank workspace/i })).toBeTruthy(); + expect(screen.queryByText(/molecule dev/i)).toBeNull(); + }); + + it("shows only the create-blank button when template fetch fails", async () => { + m.mockGet.mockRejectedValueOnce(new Error("Network error")); + render(); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + expect(screen.getByRole("button", { name: /\+ create blank workspace/i })).toBeTruthy(); + expect(screen.queryByText(/loading templates/i)).toBeNull(); + }); +}); + +describe("EmptyState — create blank workspace", () => { + it('shows "Creating..." label while blank workspace POST is in-flight', async () => { + m.mockPost.mockImplementationOnce(() => new Promise(() => {})); + render(); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i })); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + expect(screen.getByText("Creating...")).toBeTruthy(); + // The same button is now relabeled; check it is disabled while POST is in-flight. + expect(screen.getByRole("button", { name: /creating\.\.\./i })).toHaveProperty("disabled", true); + }); + + it("calls POST /workspaces with correct payload on create blank", async () => { + m.mockPost.mockResolvedValueOnce({ id: "ws-new-456" } as unknown as { id: string }); + render(); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i })); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + expect(m.mockPost).toHaveBeenCalledWith("/workspaces", { + name: "My First Agent", + canvas: { x: 200, y: 150 }, + }); + }); + + it("calls selectNode + setPanelTab(chat) after 500ms on blank create success", async () => { + m.mockPost.mockResolvedValueOnce({ id: "ws-new-789" } as unknown as { id: string }); + render(); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i })); + // Wait for the 500ms setTimeout inside handleDeployed to fire and call + // canvas store methods. Use waitFor so we don't hard-code timing assumptions. + await waitFor(() => { + expect(m.mockSelectNode).toHaveBeenCalledWith("ws-new-789"); + expect(m.mockSetPanelTab).toHaveBeenCalledWith("chat"); + }, { timeout: 1000 }); + }); + + it("shows error banner on blank create failure", async () => { + m.mockPost.mockRejectedValueOnce(new Error("Server error")); + render(); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i })); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.getByText(/server error/i)).toBeTruthy(); + }); + + it("blank workspace error clears on retry", async () => { + m.mockPost.mockRejectedValueOnce(new Error("Server error")); + render(); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i })); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + expect(screen.getByRole("alert")).toBeTruthy(); + + // Retry succeeds — error clears + m.mockPost.mockResolvedValueOnce({ id: "ws-retry" } as unknown as { id: string }); + fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i })); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + expect(screen.queryByRole("alert")).toBeNull(); + }); +}); + +describe("EmptyState — rendering", () => { + it("renders the welcome heading and instructions", async () => { + // beforeEach already sets mockGet to resolve to [] — no override needed. + render(); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + expect(screen.getByText(/deploy your first agent/i)).toBeTruthy(); + expect(screen.getByText(/welcome to molecule ai/i)).toBeTruthy(); + }); + + it("renders the tips footer", async () => { + render(); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + expect(screen.getByText(/drag to nest workspaces/i)).toBeTruthy(); + }); + + it("renders OrgTemplatesSection below the create-blank button", async () => { + render(); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + expect(screen.getByTestId("org-templates-section")).toBeTruthy(); + }); +});