Files
fusion/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx
2026-04-14 22:00:56 -07:00

714 lines
24 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { ModelOnboardingModal } from "../ModelOnboardingModal";
import type { AuthProvider } from "../../api";
// Mock the API module
const mockFetchAuthStatus = vi.fn();
const mockLoginProvider = vi.fn();
const mockLogoutProvider = vi.fn();
const mockSaveApiKey = vi.fn();
const mockClearApiKey = vi.fn();
const mockFetchModels = vi.fn();
const mockFetchGlobalSettings = vi.fn();
const mockUpdateGlobalSettings = vi.fn();
vi.mock("../../api", () => ({
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
loginProvider: (...args: unknown[]) => mockLoginProvider(...args),
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
clearApiKey: (...args: unknown[]) => mockClearApiKey(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args),
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
}));
// Mock CustomModelDropdown since it has complex portal behavior
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) => (
<select
data-testid="mock-model-dropdown"
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">{placeholder ?? "Select…"}</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="openai/gpt-4o">GPT-4o</option>
</select>
),
}));
// Mock model-onboarding-state
const mockGetOnboardingState = vi.fn();
const mockSaveOnboardingState = vi.fn();
const mockClearOnboardingState = vi.fn();
vi.mock("../model-onboarding-state", () => ({
getOnboardingState: (...args: unknown[]) => mockGetOnboardingState(...args),
saveOnboardingState: (...args: unknown[]) => mockSaveOnboardingState(...args),
clearOnboardingState: (...args: unknown[]) => mockClearOnboardingState(...args),
}));
const defaultAuthProviders: AuthProvider[] = [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
];
const defaultModels = [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: false, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
];
// Navigate through steps helper
async function navigateToGitHubStep() {
await waitFor(() => {
expect(screen.getByText("Next →")).toBeTruthy();
});
fireEvent.click(screen.getByText("Next →"));
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeTruthy();
});
}
async function navigateToFirstTaskStep() {
await navigateToGitHubStep();
fireEvent.click(screen.getByText("Next →"));
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
}
beforeEach(() => {
vi.clearAllMocks();
mockFetchAuthStatus.mockResolvedValue({ providers: defaultAuthProviders });
mockFetchModels.mockResolvedValue({ models: defaultModels, favoriteProviders: [], favoriteModels: [] });
mockFetchGlobalSettings.mockResolvedValue({});
mockUpdateGlobalSettings.mockResolvedValue({});
mockLoginProvider.mockResolvedValue({ url: "https://auth.example.com/login" });
mockLogoutProvider.mockResolvedValue({ success: true });
mockSaveApiKey.mockResolvedValue({ success: true });
mockClearApiKey.mockResolvedValue({ success: true });
// Default to no persisted state (start at ai-setup)
mockGetOnboardingState.mockReturnValue(null);
mockSaveOnboardingState.mockImplementation(() => {});
mockClearOnboardingState.mockImplementation(() => {});
});
afterEach(() => {
// Clean up localStorage
localStorage.removeItem("kb-onboarding-state");
});
describe("ModelOnboardingModal", () => {
describe("step structure", () => {
it("renders the AI Setup step by default with all three step indicators", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// Check step indicators
expect(screen.getByText("AI Setup")).toBeTruthy();
expect(screen.getByText("GitHub")).toBeTruthy();
expect(screen.getByText("First Task")).toBeTruthy();
// Check that AI Setup step is active
expect(screen.getByText("AI Setup").closest(".model-onboarding-step-indicator")).toHaveClass("active");
});
it("shows Next button on first step, not Back", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Next →")).toBeTruthy();
});
// Back button should not exist on first step
expect(screen.queryByText("← Back")).toBeNull();
});
it("shows Skip for now button on non-terminal steps", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Skip for now")).toBeTruthy();
});
});
it("shows Back and Next buttons on middle steps", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
// Both Back and Next should be visible
expect(screen.getByText("← Back")).toBeTruthy();
expect(screen.getByText("Next →")).toBeTruthy();
});
});
describe("AI Setup step", () => {
it("shows OAuth providers with Login button", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Anthropic")).toBeTruthy();
});
expect(screen.getByText("✗ Not authenticated")).toBeTruthy();
expect(screen.getByText("Login")).toBeTruthy();
});
it("shows API key providers with key input", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("OpenAI")).toBeTruthy();
});
expect(screen.getByTestId("onboarding-apikey-input-openai")).toBeTruthy();
expect(screen.getByTestId("onboarding-apikey-save-openai")).toBeTruthy();
});
it("renders OAuth and API key providers at the same time", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Anthropic")).toBeTruthy();
expect(screen.getByText("OpenAI")).toBeTruthy();
});
expect(screen.getByText("Login")).toBeTruthy();
expect(screen.getByTestId("onboarding-apikey-save-openai")).toBeTruthy();
});
it("shows model dropdown in AI Setup step", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Default Model (Optional)")).toBeTruthy();
});
expect(screen.getByTestId("mock-model-dropdown")).toBeTruthy();
});
it("allows model selection in AI Setup step", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByTestId("mock-model-dropdown")).toBeTruthy();
});
const dropdown = screen.getByTestId("mock-model-dropdown");
fireEvent.change(dropdown, { target: { value: "anthropic/claude-sonnet-4-5" } });
await waitFor(() => {
expect(screen.getByText(/Claude Sonnet 4\.5/)).toBeTruthy();
});
});
it("initiates OAuth login when Login is clicked", async () => {
const mockWindowOpen = vi.fn();
vi.spyOn(window, "open").mockImplementation(mockWindowOpen);
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Login")).toBeTruthy();
});
fireEvent.click(screen.getByText("Login"));
await waitFor(() => {
expect(mockLoginProvider).toHaveBeenCalledWith("anthropic");
expect(mockWindowOpen).toHaveBeenCalledWith("https://auth.example.com/login", "_blank");
});
});
it("saves API key when Save is clicked", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByTestId("onboarding-apikey-input-openai")).toBeTruthy();
});
const input = screen.getByTestId("onboarding-apikey-input-openai");
fireEvent.change(input, { target: { value: "sk-test-key-123" } });
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
await waitFor(() => {
expect(mockSaveApiKey).toHaveBeenCalledWith("openai", "sk-test-key-123");
});
});
it("shows Save button as disabled when API key input is empty", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByTestId("onboarding-apikey-save-openai")).toBeTruthy();
});
const saveBtn = screen.getByTestId("onboarding-apikey-save-openai") as HTMLButtonElement;
expect(saveBtn.disabled).toBe(true);
});
it("clears API key when Remove Key is clicked for authenticated provider", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "openai", name: "OpenAI", authenticated: true, type: "api_key" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("✓ Key saved")).toBeTruthy();
});
fireEvent.click(screen.getByText("Remove Key"));
await waitFor(() => {
expect(mockClearApiKey).toHaveBeenCalledWith("openai");
});
});
it("does NOT render API key values in the DOM", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByTestId("onboarding-apikey-input-openai")).toBeTruthy();
});
const input = screen.getByTestId("onboarding-apikey-input-openai") as HTMLInputElement;
// Input should be empty initially (never prefilled)
expect(input.value).toBe("");
expect(input.type).toBe("password");
});
});
describe("GitHub step", () => {
it("shows optional guidance when GitHub provider is not configured", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
expect(screen.getByText(/GitHub integration is not configured/)).toBeTruthy();
expect(screen.getByText("Continue without GitHub →")).toBeTruthy();
});
it("shows GitHub status and login/logout actions when GitHub provider is present", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
// Use more specific selector to avoid matching the header title
expect(screen.getByTestId("onboarding-auth-status-github")).toBeTruthy();
expect(screen.getByText("✗ Not connected")).toBeTruthy();
expect(screen.getByText("Connect")).toBeTruthy();
});
it("shows connected status when GitHub is authenticated", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
expect(screen.getByTestId("onboarding-auth-status-github")).toBeTruthy();
expect(screen.getByText("✓ Connected")).toBeTruthy();
expect(screen.getByText("Disconnect")).toBeTruthy();
});
it("allows navigating to First Task step via Continue without GitHub", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
fireEvent.click(screen.getByText("Continue without GitHub →"));
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
});
it("allows navigation back to AI Setup step", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
fireEvent.click(screen.getByText("← Back"));
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
});
});
describe("First Task step", () => {
it("shows CTA options for creating first task", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
expect(screen.getByText("Create a New Task")).toBeTruthy();
expect(screen.getByText("Import from GitHub")).toBeTruthy();
});
it("shows skip note about CLI and board creation", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
expect(screen.getByText(/fn task create/)).toBeTruthy();
});
it("allows navigation back to GitHub step", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
fireEvent.click(screen.getByText("← Back"));
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeTruthy();
});
});
});
describe("completion", () => {
it("completes onboarding and calls onOpenNewTask callback", async () => {
const onComplete = vi.fn();
const onOpenNewTask = vi.fn();
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
render(
<ModelOnboardingModal
onComplete={onComplete}
addToast={vi.fn()}
onOpenNewTask={onOpenNewTask}
/>
);
// Select a model
await waitFor(() => {
expect(screen.getByTestId("mock-model-dropdown")).toBeTruthy();
});
const dropdown = screen.getByTestId("mock-model-dropdown");
fireEvent.change(dropdown, { target: { value: "anthropic/claude-sonnet-4-5" } });
// Navigate through all steps
await navigateToFirstTaskStep();
// Click Create a New Task
fireEvent.click(screen.getByText("Create a New Task"));
// Should mark onboarding complete
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
modelOnboardingComplete: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
}),
);
});
// Should close modal and call both callbacks
expect(onComplete).toHaveBeenCalled();
expect(onOpenNewTask).toHaveBeenCalled();
});
it("completes onboarding and calls onOpenGitHubImport callback", async () => {
const onComplete = vi.fn();
const onOpenGitHubImport = vi.fn();
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
render(
<ModelOnboardingModal
onComplete={onComplete}
addToast={vi.fn()}
onOpenGitHubImport={onOpenGitHubImport}
/>
);
// Navigate through all steps
await navigateToFirstTaskStep();
// Click Import from GitHub
fireEvent.click(screen.getByText("Import from GitHub"));
// Should mark onboarding complete
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
modelOnboardingComplete: true,
}),
);
});
// Should close modal and call both callbacks
expect(onComplete).toHaveBeenCalled();
expect(onOpenGitHubImport).toHaveBeenCalled();
});
it("completes with Finish Setup button (no CTA)", async () => {
const onComplete = vi.fn();
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={onComplete} addToast={vi.fn()} />);
// Navigate through all steps
await navigateToFirstTaskStep();
// Click Finish Setup
await waitFor(() => {
expect(screen.getByText("Finish Setup")).toBeTruthy();
});
fireEvent.click(screen.getByText("Finish Setup"));
// Should show completion screen
await waitFor(() => {
expect(screen.getByText("All Set!")).toBeTruthy();
});
// Click Get Started to close
fireEvent.click(screen.getByText("Get Started"));
expect(onComplete).toHaveBeenCalled();
});
it("completes without model selection", async () => {
const onComplete = vi.fn();
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={onComplete} addToast={vi.fn()} />);
// Navigate through all steps without selecting model
await navigateToFirstTaskStep();
// Click Finish Setup
fireEvent.click(screen.getByText("Finish Setup"));
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
modelOnboardingComplete: true,
}),
);
});
});
});
describe("dismiss / skip", () => {
it("marks onboarding complete when dismissed via X button", async () => {
const onComplete = vi.fn();
render(<ModelOnboardingModal onComplete={onComplete} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// Click the X close button
const closeBtn = screen.getByLabelText("Skip onboarding");
fireEvent.click(closeBtn);
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({
modelOnboardingComplete: true,
});
});
expect(onComplete).toHaveBeenCalled();
});
it("marks onboarding complete when Skip for now is clicked", async () => {
const onComplete = vi.fn();
render(<ModelOnboardingModal onComplete={onComplete} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Skip for now")).toBeTruthy();
});
fireEvent.click(screen.getByText("Skip for now"));
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({
modelOnboardingComplete: true,
});
});
expect(onComplete).toHaveBeenCalled();
});
it("still calls onComplete even if global settings save fails", async () => {
const onComplete = vi.fn();
mockUpdateGlobalSettings.mockRejectedValueOnce(new Error("Network error"));
render(<ModelOnboardingModal onComplete={onComplete} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Skip for now")).toBeTruthy();
});
fireEvent.click(screen.getByText("Skip for now"));
await waitFor(() => {
expect(onComplete).toHaveBeenCalled();
});
});
});
describe("edge cases", () => {
it("shows empty state when no providers are configured", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({ providers: [] });
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText(/No AI providers are configured/)).toBeTruthy();
});
});
it("handles auth status fetch failure gracefully", async () => {
mockFetchAuthStatus.mockRejectedValueOnce(new Error("Network error"));
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
// Should still render the modal without crashing
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
});
it("shows loading state while fetching providers", () => {
// Make the fetch hang
mockFetchAuthStatus.mockReturnValue(new Promise(() => {}));
mockFetchModels.mockReturnValue(new Promise(() => {}));
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
expect(screen.getByText("Loading providers…")).toBeTruthy();
});
it("works without optional callbacks", async () => {
const onComplete = vi.fn();
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
// Render without onOpenNewTask and onOpenGitHubImport
render(<ModelOnboardingModal onComplete={onComplete} addToast={vi.fn()} />);
// Navigate through all steps
await navigateToFirstTaskStep();
// Click Finish Setup - should work without callbacks
fireEvent.click(screen.getByText("Finish Setup"));
await waitFor(() => {
expect(screen.getByText("All Set!")).toBeTruthy();
});
});
});
describe("global settings hydration", () => {
it("pre-populates selectedModel from global settings defaultProvider/defaultModelId", async () => {
// Mock global settings with a saved default model
mockFetchGlobalSettings.mockResolvedValueOnce({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
modelOnboardingComplete: true,
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// The model dropdown should be pre-populated with the saved default
const dropdown = screen.getByTestId("mock-model-dropdown") as HTMLSelectElement;
expect(dropdown.value).toBe("anthropic/claude-sonnet-4-5");
});
it("leaves selectedModel empty when no default is configured in global settings", async () => {
// Mock global settings with no default model
mockFetchGlobalSettings.mockResolvedValueOnce({
modelOnboardingComplete: true,
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// The model dropdown should be empty
const dropdown = screen.getByTestId("mock-model-dropdown") as HTMLSelectElement;
expect(dropdown.value).toBe("");
});
it("handles fetchGlobalSettings failure gracefully", async () => {
// Mock global settings fetch to fail
mockFetchGlobalSettings.mockRejectedValueOnce(new Error("Network error"));
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// The modal should still render with empty dropdown
const dropdown = screen.getByTestId("mock-model-dropdown") as HTMLSelectElement;
expect(dropdown.value).toBe("");
});
});
});