From 4bc1ea6987022ffb04085b5f854f4fc496dacf94 Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Mon, 11 May 2026 20:34:52 +0000 Subject: [PATCH] test(canvas): fix ApprovalBanner spy-chain + add EmptyState coverage Fix test isolation in ApprovalBanner: replace vi.spyOn per-test with module-level vi.hoisted + vi.mock so the mock is stable across tests. Add EmptyState.test.tsx covering: - Loading/empty/template-fetched states - Template grid rendering (name, tier badge, model label) - Deploy-on-click - Create blank workspace (POST, loading, error, retry, canvas-store wiring) - Rendering (welcome, tips, OrgTemplatesSection) Fix vi.hoisted pattern for multiple vi.mock calls: use a single vi.hoisted() returning all mock fns as m., then reference m. inside each vi.mock factory. This avoids "Cannot access before initialization" errors that arise when vi.hoisted factories are called before module-level vi.mock hoisting completes. Co-Authored-By: Claude Opus 4.7 --- .../__tests__/ApprovalBanner.test.tsx | 103 ++++--- .../components/__tests__/EmptyState.test.tsx | 267 ++++++++++++++++++ 2 files changed, 335 insertions(+), 35 deletions(-) create mode 100644 canvas/src/components/__tests__/EmptyState.test.tsx 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(); + }); +});