714 lines
24 KiB
TypeScript
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("");
|
|
});
|
|
});
|
|
});
|