From b23ca65d35fe8c895b7df04584146897a0e3c6d6 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Sun, 10 May 2026 01:50:29 +0000 Subject: [PATCH 1/2] test(canvas): add component tests for OnboardingWizard and PurchaseSuccessModal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OnboardingWizard: visibility gates, 4-step flow, skip/dismiss, localStorage persistence, progress bar, aria-live announcements, auto-advance from welcome→api-key on nodes change. PurchaseSuccessModal: URL param gating, portal rendering, item name display, 5s auto-dismiss (fake timers), backdrop/Escape close, replaceState URL stripping, aria-modal/focus management. Co-Authored-By: Claude Opus 4.7 --- .../__tests__/OnboardingWizard.test.tsx | 174 ++++++++++++ .../__tests__/PurchaseSuccessModal.test.tsx | 255 ++++++++++++++++++ 2 files changed, 429 insertions(+) create mode 100644 canvas/src/components/__tests__/OnboardingWizard.test.tsx create mode 100644 canvas/src/components/__tests__/PurchaseSuccessModal.test.tsx diff --git a/canvas/src/components/__tests__/OnboardingWizard.test.tsx b/canvas/src/components/__tests__/OnboardingWizard.test.tsx new file mode 100644 index 00000000..54368950 --- /dev/null +++ b/canvas/src/components/__tests__/OnboardingWizard.test.tsx @@ -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 welcome→api-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 }>, + selectedNodeId: null as string | null, + panelTab: "chat" as string, + agentMessages: {} as Record, + 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 = {}; + 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(); + expect(screen.queryByRole("complementary")).toBeNull(); + }); + + it("renders the wizard for first-time users (no localStorage flag)", () => { + localStorageMock.getItem.mockReturnValueOnce(null); + render(); + 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(); + expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy(); + expect(screen.getByText("Step 1 of 4")).toBeTruthy(); + }); + + it("renders the 'Skip guide' button", () => { + render(); + expect(screen.getByRole("button", { name: "Skip onboarding guide" })).toBeTruthy(); + }); + + it("renders the progress bar", () => { + render(); + // 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(); + 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(); + 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(); + // 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(); + 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(); + 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(); + 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(); + + 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(); + 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(); + expect(screen.getByRole("complementary", { name: "Onboarding guide" })).toBeTruthy(); + }); +}); diff --git a/canvas/src/components/__tests__/PurchaseSuccessModal.test.tsx b/canvas/src/components/__tests__/PurchaseSuccessModal.test.tsx new file mode 100644 index 00000000..75f7dd3c --- /dev/null +++ b/canvas/src/components/__tests__/PurchaseSuccessModal.test.tsx @@ -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(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("renders nothing on a plain URL", () => { + replaceUrl("http://localhost/dashboard?foo=bar"); + render(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("renders the dialog when ?purchase_success=1 is present", async () => { + replaceUrl("http://localhost/?purchase_success=1"); + render(); + // 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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); + }); +}); From ee5648b3d1b3d292d21ddb9a3ab8c893c0ff5f80 Mon Sep 17 00:00:00 2001 From: Molecule AI Core Platform Lead Date: Sun, 10 May 2026 01:53:43 +0000 Subject: [PATCH 2/2] trigger