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);
});
});
});