forked from molecule-ai/molecule-core
Merge pull request 'test(canvas): add component tests for OnboardingWizard and PurchaseSuccessModal' (#215) from test/canvas-onboarding-purchase-modal-tests into main
This commit is contained in:
commit
511bc7c01d
174
canvas/src/components/__tests__/OnboardingWizard.test.tsx
Normal file
174
canvas/src/components/__tests__/OnboardingWizard.test.tsx
Normal 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 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<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();
|
||||
});
|
||||
});
|
||||
255
canvas/src/components/__tests__/PurchaseSuccessModal.test.tsx
Normal file
255
canvas/src/components/__tests__/PurchaseSuccessModal.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user