import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, cleanup, act } from "@testing-library/react"; import { createRef, StrictMode } from "react"; import { Widget } from "./Widget"; import type { EmbedHandle } from "@perspective-ai/sdk"; // Stable reference so useEmbedConfig doesn't trigger extra effect runs const mockEmbedConfig = { primaryColor: "#7c3aed", textColor: "#ffffff", darkPrimaryColor: "#a78bfa", darkTextColor: "#ffffff", }; // Mock the core embed package vi.mock("@perspective-ai/sdk", () => ({ createWidget: vi.fn(() => ({ unmount: vi.fn(), update: vi.fn(), destroy: vi.fn(), researchId: "test-research-id", type: "widget", iframe: null, container: null, })), fetchEmbedConfig: vi.fn(() => Promise.resolve(mockEmbedConfig)), createLoadingIndicator: vi.fn(() => { const el = document.createElement("div"); el.className = "perspective-loading"; return el; }), perfLog: vi.fn(), isPerfDebug: vi.fn(() => false), ensureHostPreconnect: vi.fn(), })); import { createWidget } from "@perspective-ai/sdk"; const mockCreateWidget = vi.mocked(createWidget); describe("Widget", () => { beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { cleanup(); }); it("renders a div container", async () => { render(); await act(async () => {}); const container = screen.getByTestId("perspective-widget"); expect(container).toBeDefined(); expect(container.tagName).toBe("DIV"); }); it("does not force an inline height on the container by default", async () => { // The widget's default height comes from the injected `.perspective-widget` // stylesheet rule, not an inline style. Forcing inline sizing here would // make every widget look "host-sized" and suppress the card default. render(); await act(async () => {}); const container = screen.getByTestId("perspective-widget"); expect(container.style.minHeight).toBe(""); }); it("accepts custom className", async () => { render(); await act(async () => {}); const container = screen.getByTestId("perspective-widget"); expect(container.classList.contains("custom-class")).toBe(true); }); it("accepts custom style", async () => { render( ); await act(async () => {}); const container = screen.getByTestId("perspective-widget"); expect(container.style.backgroundColor).toBe("red"); expect(container.style.minHeight).toBe("600px"); // Custom overrides default }); it("calls createWidget with correct config", async () => { const onReady = vi.fn(); const onVisualReady = vi.fn(); const onSubmit = vi.fn(); render( ); await act(async () => {}); expect(mockCreateWidget).toHaveBeenCalledTimes(1); const [container, config] = mockCreateWidget.mock.calls[0]!; expect(container).toBeInstanceOf(HTMLDivElement); expect(config.researchId).toBe("test-research-id"); expect(config.params).toEqual({ source: "test" }); expect(config.theme).toBe("dark"); expect(config.host).toBe("https://custom.example.com"); expect(config.onVisualReady).toBeTypeOf("function"); }); it("forwards the frame object to createWidget", async () => { render( ); await act(async () => {}); const [, config] = mockCreateWidget.mock.calls[0]!; expect(config.frame).toEqual({ layout: "fill", radius: "4px" }); }); it("creates widget immediately on mount (no upfront config fetch)", async () => { render(); // Widget is created synchronously inside the mount effect — no upfront // /api/v1/embed/config round-trip blocks iframe creation. createWidget // renders its own skeleton internally while the iframe loads. await act(async () => {}); expect(mockCreateWidget).toHaveBeenCalledTimes(1); }); it("calls unmount on cleanup", async () => { const mockUnmount = vi.fn(); mockCreateWidget.mockReturnValueOnce({ unmount: mockUnmount, update: vi.fn(), destroy: vi.fn(), researchId: "test-research-id", type: "widget", iframe: null, container: null, }); const { unmount } = render(); await act(async () => {}); expect(mockUnmount).not.toHaveBeenCalled(); unmount(); expect(mockUnmount).toHaveBeenCalled(); }); it("exposes handle via embedRef", async () => { const mockHandle: EmbedHandle = { unmount: vi.fn(), update: vi.fn(), destroy: vi.fn(), researchId: "test-research-id", type: "widget", iframe: null, container: null, }; mockCreateWidget.mockReturnValueOnce(mockHandle); const embedRef = createRef(); render(); await act(async () => {}); expect(embedRef.current).toBe(mockHandle); }); it("clears embedRef on unmount", async () => { const embedRef = createRef(); const { unmount } = render( ); await act(async () => {}); expect(embedRef.current).not.toBeNull(); unmount(); expect(embedRef.current).toBeNull(); }); it("re-creates widget when researchId changes", async () => { const { rerender } = render(); await act(async () => {}); expect(mockCreateWidget).toHaveBeenCalledTimes(1); rerender(); await act(async () => {}); expect(mockCreateWidget).toHaveBeenCalledTimes(2); }); it("passes additional div props", async () => { render( ); await act(async () => {}); const container = screen.getByTestId("perspective-widget"); expect(container.getAttribute("aria-label")).toBe("Interview widget"); expect(container.getAttribute("role")).toBe("region"); }); describe("StrictMode behavior", () => { it("creates only one iframe in StrictMode (mock inserts real DOM)", async () => { const mockUnmount = vi.fn(); mockCreateWidget.mockImplementation((containerEl: HTMLElement | null) => { if (!containerEl) { return { unmount: vi.fn(), update: vi.fn(), destroy: vi.fn(), researchId: "test-research-id", type: "widget" as const, iframe: null, container: null, }; } const iframe = document.createElement("iframe"); iframe.setAttribute("data-perspective", "true"); containerEl.appendChild(iframe); return { unmount: () => { mockUnmount(); iframe.remove(); }, update: vi.fn(), destroy: vi.fn(), researchId: "test-research-id", type: "widget" as const, iframe, container: containerEl, }; }); render( ); await act(async () => {}); const container = screen.getByTestId("perspective-widget"); const iframes = container.querySelectorAll("iframe[data-perspective]"); expect(iframes.length).toBe(1); }); it("properly cleans up in StrictMode double-mount cycle", async () => { const mockUnmount = vi.fn(); mockCreateWidget.mockImplementation((containerEl: HTMLElement | null) => { if (!containerEl) { return { unmount: vi.fn(), update: vi.fn(), destroy: vi.fn(), researchId: "test-research-id", type: "widget" as const, iframe: null, container: null, }; } const iframe = document.createElement("iframe"); iframe.setAttribute("data-perspective", "true"); containerEl.appendChild(iframe); return { unmount: () => { mockUnmount(); iframe.remove(); }, update: vi.fn(), destroy: vi.fn(), researchId: "test-research-id", type: "widget" as const, iframe, container: containerEl, }; }); const { unmount } = render( ); await act(async () => {}); unmount(); expect(mockUnmount).toHaveBeenCalled(); const container = screen.queryByTestId("perspective-widget"); if (container) { const iframes = container.querySelectorAll("iframe[data-perspective]"); expect(iframes.length).toBe(0); } }); it("StrictMode double-mount calls createWidget twice but cleanup prevents duplicates", async () => { const createCalls: number[] = []; const unmountCalls: number[] = []; let callCount = 0; mockCreateWidget.mockImplementation((containerEl: HTMLElement | null) => { if (!containerEl) { return { unmount: vi.fn(), update: vi.fn(), destroy: vi.fn(), researchId: "test-research-id", type: "widget" as const, iframe: null, container: null, }; } const thisCall = ++callCount; createCalls.push(thisCall); const iframe = document.createElement("iframe"); iframe.setAttribute("data-perspective", "true"); iframe.setAttribute("data-call", String(thisCall)); containerEl.appendChild(iframe); return { unmount: () => { unmountCalls.push(thisCall); iframe.remove(); }, update: vi.fn(), destroy: vi.fn(), researchId: "test-research-id", type: "widget" as const, iframe, container: containerEl, }; }); render( ); await act(async () => {}); const container = screen.getByTestId("perspective-widget"); const iframes = container.querySelectorAll("iframe[data-perspective]"); expect(iframes.length).toBe(1); expect(createCalls.length).toBeGreaterThanOrEqual(1); }); }); });