feat(FN-812): add first-run model onboarding modal and API key provider support

- Add ModelOnboardingModal component with provider selection, API key input, and model verification flow
- Add API key authentication support to SettingsModal with clear-key functionality
- Trigger onboarding from App.tsx when modelOnboardingComplete setting is false
- Add comprehensive tests for ModelOnboardingModal and SettingsModal authentication
- Document model onboarding flow and API key auth in AGENTS.md and README
- Add CSS styles for onboarding modal, provider cards, and auth section
This commit is contained in:
gsxdsm
2026-04-04 14:44:06 -07:00
parent 78e95246d8
commit fb5b97e134
10 changed files with 1798 additions and 56 deletions

View File

@@ -0,0 +1,592 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { X, Loader2, CheckCircle, Key, Zap } from "lucide-react";
import type { AuthProvider, ModelInfo } from "../api";
import {
fetchAuthStatus,
loginProvider,
logoutProvider,
saveApiKey,
clearApiKey,
fetchModels,
updateGlobalSettings,
} from "../api";
import type { ToastType } from "../hooks/useToast";
import { CustomModelDropdown } from "./CustomModelDropdown";
export interface ModelOnboardingModalProps {
/** Called when onboarding is complete or dismissed */
onComplete: () => void;
/** Toast helper */
addToast: (message: string, type?: ToastType) => void;
}
type OnboardingStep = "providers" | "model" | "complete";
/**
* First-run onboarding modal that guides users through:
* 1. Provider credential setup (OAuth login or API key entry)
* 2. Default model selection
*
* Dismissing the modal marks onboarding as complete to prevent repeated popups.
*/
export function ModelOnboardingModal({
onComplete,
addToast,
}: ModelOnboardingModalProps) {
const [isOpen, setIsOpen] = useState(true);
const [step, setStep] = useState<OnboardingStep>("providers");
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
const [authLoading, setAuthLoading] = useState(true);
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [saving, setSaving] = useState(false);
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Load auth providers
const loadAuthStatus = useCallback(async () => {
try {
const { providers } = await fetchAuthStatus();
setAuthProviders(providers);
} catch {
// Silently fail
}
}, []);
// Load models
const loadModels = useCallback(async () => {
try {
const response = await fetchModels();
setAvailableModels(response.models);
} catch {
// Silently fail
}
}, []);
// Initial data load
useEffect(() => {
Promise.all([loadAuthStatus(), loadModels()]).finally(() =>
setAuthLoading(false),
);
}, [loadAuthStatus, loadModels]);
// Check if we can skip the providers step (already authenticated)
const hasAuthenticatedProvider = authProviders.some((p) => p.authenticated);
// Auto-advance to model step when provider is authenticated
useEffect(() => {
if (!authLoading && hasAuthenticatedProvider && step === "providers") {
// Small delay to let the user see the success state
const timer = setTimeout(() => setStep("model"), 600);
return () => clearTimeout(timer);
}
}, [authLoading, hasAuthenticatedProvider, step]);
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
// OAuth login handler
const handleLogin = useCallback(
async (providerId: string) => {
setAuthActionInProgress(providerId);
try {
const { url } = await loginProvider(providerId);
window.open(url, "_blank");
// Poll for auth completion
pollIntervalRef.current = setInterval(async () => {
try {
const { providers } = await fetchAuthStatus();
setAuthProviders(providers);
const provider = providers.find((p) => p.id === providerId);
if (provider?.authenticated) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setAuthActionInProgress(null);
addToast("Login successful", "success");
}
} catch {
// Continue polling
}
}, 2000);
} catch (err: unknown) {
addToast(
err instanceof Error ? err.message : "Login failed",
"error",
);
setAuthActionInProgress(null);
}
},
[addToast],
);
// API key save handler
const handleSaveApiKey = useCallback(
async (providerId: string) => {
const key = apiKeyInputs[providerId]?.trim();
if (!key) {
setApiKeyErrors((prev) => ({
...prev,
[providerId]: "API key is required",
}));
return;
}
setAuthActionInProgress(providerId);
setApiKeyErrors((prev) => {
const next = { ...prev };
delete next[providerId];
return next;
});
try {
await saveApiKey(providerId, key);
await loadAuthStatus();
setApiKeyInputs((prev) => {
const next = { ...prev };
delete next[providerId];
return next;
});
addToast("API key saved", "success");
} catch (err: unknown) {
addToast(
err instanceof Error ? err.message : "Failed to save API key",
"error",
);
} finally {
setAuthActionInProgress(null);
}
},
[apiKeyInputs, addToast, loadAuthStatus],
);
// API key clear handler
const handleClearApiKey = useCallback(
async (providerId: string) => {
setAuthActionInProgress(providerId);
try {
await clearApiKey(providerId);
await loadAuthStatus();
addToast("API key removed", "success");
} catch (err: unknown) {
addToast(
err instanceof Error ? err.message : "Failed to clear API key",
"error",
);
} finally {
setAuthActionInProgress(null);
}
},
[addToast, loadAuthStatus],
);
// Logout handler (for OAuth providers that are authenticated)
const handleLogout = useCallback(
async (providerId: string) => {
setAuthActionInProgress(providerId);
try {
await logoutProvider(providerId);
await loadAuthStatus();
addToast("Logged out", "success");
} catch (err: unknown) {
addToast(
err instanceof Error ? err.message : "Logout failed",
"error",
);
} finally {
setAuthActionInProgress(null);
}
},
[addToast, loadAuthStatus],
);
// Handle model selection from CustomModelDropdown
const handleModelSelect = useCallback(
(value: string) => {
setSelectedModel(value);
},
[],
);
// Complete onboarding
const handleComplete = useCallback(async () => {
setSaving(true);
try {
const updates: Record<string, unknown> = {
modelOnboardingComplete: true,
};
// If a model was selected, persist it as the default
if (selectedModel) {
// Parse the provider/modelId format from CustomModelDropdown
const slashIdx = selectedModel.indexOf("/");
const provider =
slashIdx !== -1 ? selectedModel.slice(0, slashIdx) : undefined;
const modelId =
slashIdx !== -1 ? selectedModel.slice(slashIdx + 1) : selectedModel;
const model = availableModels.find((m) => m.id === modelId);
if (model) {
updates.defaultProvider = model.provider;
updates.defaultModelId = model.id;
} else if (provider && modelId) {
// Fallback: use parsed values even if not in the model list
updates.defaultProvider = provider;
updates.defaultModelId = modelId;
}
}
await updateGlobalSettings(updates);
setStep("complete");
} catch (err: unknown) {
addToast(
err instanceof Error ? err.message : "Failed to save settings",
"error",
);
} finally {
setSaving(false);
}
}, [selectedModel, availableModels, addToast]);
// Dismiss without completing (still marks onboarding complete)
const handleDismiss = useCallback(async () => {
setSaving(true);
try {
await updateGlobalSettings({ modelOnboardingComplete: true });
} catch {
// Best-effort: still close even if save fails
}
setIsOpen(false);
onComplete();
}, [onComplete]);
// Close from the completion step
const handleFinish = useCallback(() => {
setIsOpen(false);
onComplete();
}, [onComplete]);
if (!isOpen) return null;
const oauthProviders = authProviders.filter(
(p) => !p.type || p.type === "oauth",
);
const apiKeyProviders = authProviders.filter((p) => p.type === "api_key");
return (
<div
className="modal-overlay open"
role="dialog"
aria-modal="true"
aria-labelledby="onboarding-title"
>
<div className="modal model-onboarding-modal">
{/* Header */}
<div className="model-onboarding-header">
<h2 id="onboarding-title" className="model-onboarding-title">
{step === "providers" && (
<>
<Zap size={24} /> Set Up AI Provider
</>
)}
{step === "model" && (
<>
<Zap size={24} /> Choose Default Model
</>
)}
{step === "complete" && (
<>
<CheckCircle size={24} /> All Set!
</>
)}
</h2>
{step !== "complete" && (
<button
className="modal-close"
onClick={handleDismiss}
aria-label="Skip onboarding"
title="Skip for now"
>
<X size={20} />
</button>
)}
</div>
{/* Step indicator */}
<div className="model-onboarding-steps">
<div
className={`model-onboarding-step-indicator${step === "providers" ? " active" : ""}${step === "model" || step === "complete" ? " done" : ""}`}
>
<span className="step-number">1</span>
<span className="step-label">Connect Provider</span>
</div>
<div className="model-onboarding-step-connector" />
<div
className={`model-onboarding-step-indicator${step === "model" ? " active" : ""}${step === "complete" ? " done" : ""}`}
>
<span className="step-number">2</span>
<span className="step-label">Select Model</span>
</div>
</div>
{/* Content */}
<div className="model-onboarding-content">
{step === "providers" && (
<div className="model-onboarding-providers">
<p className="model-onboarding-description">
Connect at least one AI provider to start running tasks.
{oauthProviders.length > 0 &&
apiKeyProviders.length > 0 &&
" OAuth providers open a browser for login; API key providers need a key from the provider's dashboard."}
</p>
{authLoading ? (
<div className="model-onboarding-loading">
<Loader2 size={24} className="animate-spin" />
<span>Loading providers</span>
</div>
) : authProviders.length === 0 ? (
<div className="model-onboarding-empty">
No AI providers are configured. Please check your Fusion
configuration.
</div>
) : (
<>
{/* OAuth Providers */}
{oauthProviders.map((provider) => (
<div key={provider.id} className="onboarding-provider-row">
<div className="onboarding-provider-info">
<strong>{provider.name}</strong>
<span
data-testid={`onboarding-auth-status-${provider.id}`}
className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}
>
{provider.authenticated
? "✓ Authenticated"
: "✗ Not authenticated"}
</span>
</div>
<div>
{authActionInProgress === provider.id ? (
<button className="btn btn-sm" disabled>
{provider.authenticated
? "Logging out…"
: "Waiting for login…"}
</button>
) : provider.authenticated ? (
<button
className="btn btn-sm"
onClick={() => handleLogout(provider.id)}
>
Logout
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleLogin(provider.id)}
>
Login
</button>
)}
</div>
</div>
))}
{/* API Key Providers */}
{apiKeyProviders.map((provider) => (
<div key={provider.id} className="onboarding-provider-row">
<div className="onboarding-provider-info">
<strong>
<Key size={14} style={{ marginRight: 4 }} />
{provider.name}
</strong>
<span
data-testid={`onboarding-auth-status-${provider.id}`}
className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}
>
{provider.authenticated
? "✓ Key saved"
: "✗ No API key"}
</span>
</div>
<div className="onboarding-apikey-actions">
{provider.authenticated ? (
<button
className="btn btn-sm"
onClick={() => handleClearApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
{authActionInProgress === provider.id
? "Removing…"
: "Remove Key"}
</button>
) : (
<div className="onboarding-apikey-input-row">
<input
type="password"
className="onboarding-apikey-input"
placeholder={`Enter ${provider.name} API key`}
value={apiKeyInputs[provider.id] ?? ""}
onChange={(e) =>
setApiKeyInputs((prev) => ({
...prev,
[provider.id]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSaveApiKey(provider.id);
}
}}
data-testid={`onboarding-apikey-input-${provider.id}`}
/>
<button
className="btn btn-primary btn-sm"
onClick={() => handleSaveApiKey(provider.id)}
disabled={
authActionInProgress === provider.id ||
!apiKeyInputs[provider.id]?.trim()
}
data-testid={`onboarding-apikey-save-${provider.id}`}
>
{authActionInProgress === provider.id
? "Saving…"
: "Save"}
</button>
</div>
)}
{apiKeyErrors[provider.id] && (
<small className="field-error">
{apiKeyErrors[provider.id]}
</small>
)}
</div>
</div>
))}
</>
)}
</div>
)}
{step === "model" && (
<div className="model-onboarding-model">
<p className="model-onboarding-description">
Select the default model Fusion will use for AI tasks. You can
change this later in Settings.
</p>
{availableModels.length === 0 ? (
<div className="model-onboarding-empty">
No models available. Please check your provider configuration.
<button
className="btn btn-sm"
style={{ marginTop: 8 }}
onClick={() => setStep("providers")}
>
Back to Providers
</button>
</div>
) : (
<div className="onboarding-model-selector">
<CustomModelDropdown
models={availableModels}
value={selectedModel}
onChange={handleModelSelect}
placeholder="Select a default model…"
label="Default model"
/>
</div>
)}
{selectedModel && (
<div className="onboarding-model-preview">
<small className="settings-muted">
Selected:{" "}
{availableModels.find((m) => m.id === selectedModel)?.name ??
selectedModel}
</small>
</div>
)}
</div>
)}
{step === "complete" && (
<div className="model-onboarding-complete">
<CheckCircle size={48} className="success-icon" />
<p>
You're ready to start using Fusion! You can always change your
model and provider settings from the Settings panel.
</p>
</div>
)}
</div>
{/* Footer */}
<div className="model-onboarding-footer">
{step === "providers" && (
<>
<button
className="btn btn-sm"
onClick={handleDismiss}
disabled={saving}
>
Skip for now
</button>
<button
className="btn btn-primary"
onClick={() => {
// Fetch models now that provider is authenticated
loadModels();
setStep("model");
}}
disabled={!hasAuthenticatedProvider}
>
Continue
</button>
</>
)}
{step === "model" && (
<>
<button
className="btn btn-sm"
onClick={() => setStep("providers")}
>
Back
</button>
<button
className="btn btn-primary"
onClick={handleComplete}
disabled={saving}
>
{saving ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Saving</span>
</>
) : (
"Complete Setup"
)}
</button>
</>
)}
{step === "complete" && (
<button className="btn btn-primary" onClick={handleFinish}>
<CheckCircle size={16} />
<span>Get Started</span>
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { THINKING_LEVELS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent } from "@fusion/core";
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings } from "../api";
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData } from "../api";
import type { ToastType } from "../hooks/useToast";
import { ThemeSelector } from "./ThemeSelector";
@@ -86,6 +86,8 @@ export function SettingsModal({
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
const [authLoading, setAuthLoading] = useState(false);
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Model state
@@ -200,7 +202,7 @@ export function SettingsModal({
addToast(err.message || "Login failed", "error");
setAuthActionInProgress(null);
}
}, [addToast, loadAuthStatus]);
}, [addToast]);
const handleLogout = useCallback(async (providerId: string) => {
setAuthActionInProgress(providerId);
@@ -215,6 +217,57 @@ export function SettingsModal({
}
}, [addToast, loadAuthStatus]);
const handleSaveApiKey = useCallback(async (providerId: string) => {
const key = apiKeyInputs[providerId]?.trim();
if (!key) {
setApiKeyErrors((prev) => ({ ...prev, [providerId]: "API key is required" }));
return;
}
setAuthActionInProgress(providerId);
setApiKeyErrors((prev) => {
const next = { ...prev };
delete next[providerId];
return next;
});
try {
await saveApiKey(providerId, key);
setApiKeyInputs((prev) => {
const next = { ...prev };
delete next[providerId];
return next;
});
await loadAuthStatus();
addToast("API key saved", "success");
} catch (err: any) {
setApiKeyErrors((prev) => ({ ...prev, [providerId]: err.message || "Failed to save API key" }));
} finally {
setAuthActionInProgress(null);
}
}, [apiKeyInputs, addToast, loadAuthStatus]);
const handleClearApiKey = useCallback(async (providerId: string) => {
setAuthActionInProgress(providerId);
try {
await clearApiKey(providerId);
setApiKeyInputs((prev) => {
const next = { ...prev };
delete next[providerId];
return next;
});
setApiKeyErrors((prev) => {
const next = { ...prev };
delete next[providerId];
return next;
});
await loadAuthStatus();
addToast("API key cleared", "success");
} catch (err: any) {
addToast(err.message || "Failed to clear API key", "error");
} finally {
setAuthActionInProgress(null);
}
}, [addToast, loadAuthStatus]);
const handleTestNotification = useCallback(async () => {
// Validate ntfy is enabled and topic is valid
if (!form.ntfyEnabled || !form.ntfyTopic || !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)) {
@@ -1614,7 +1667,7 @@ export function SettingsModal({
<div className="settings-empty-state">Loading authentication status</div>
) : authProviders.length === 0 ? (
<div className="settings-empty-state settings-muted">
No OAuth providers available
No providers available
</div>
) : (
<>
@@ -1634,33 +1687,71 @@ export function SettingsModal({
{provider.authenticated ? "✓ Authenticated" : "✗ Not authenticated"}
</span>
</div>
<div>
{authActionInProgress === provider.id ? (
<button className="btn btn-sm" disabled>
{provider.authenticated ? "Logging out…" : "Waiting for login…"}
</button>
) : provider.authenticated ? (
<button
className="btn btn-sm"
onClick={() => handleLogout(provider.id)}
>
Logout
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleLogin(provider.id)}
>
Login
</button>
)}
</div>
{provider.type === "api_key" ? (
<div className="auth-apikey-section">
<div className="auth-apikey-input-row">
<input
type="password"
className="auth-apikey-input"
placeholder="Enter API key"
value={apiKeyInputs[provider.id] ?? ""}
onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))}
disabled={authActionInProgress === provider.id}
/>
{provider.authenticated ? (
<button
className="btn btn-sm"
onClick={() => handleClearApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
Clear
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleSaveApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
Save
</button>
)}
</div>
{authActionInProgress === provider.id && (
<small className="auth-apikey-progress">Saving</small>
)}
{apiKeyErrors[provider.id] && (
<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>
)}
</div>
) : (
<div>
{authActionInProgress === provider.id ? (
<button className="btn btn-sm" disabled>
{provider.authenticated ? "Logging out…" : "Waiting for login…"}
</button>
) : provider.authenticated ? (
<button
className="btn btn-sm"
onClick={() => handleLogout(provider.id)}
>
Logout
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleLogin(provider.id)}
>
Login
</button>
)}
</div>
)}
</div>
))}
</>
)}
<small className="auth-hint">
Login and logout take effect immediately no need to save.
Authentication changes take effect immediately no need to save.
</small>
</>
);

View File

@@ -22,6 +22,7 @@ vi.mock("../../api", async (importOriginal) => {
fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2 })),
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
fetchGlobalSettings: vi.fn(() => Promise.resolve({})),
fetchAuthStatus: vi.fn(() =>
Promise.resolve({
providers: [
@@ -105,7 +106,7 @@ vi.mock("../../hooks/useTerminal", () => ({
}));
import { App } from "../../App";
import { fetchAuthStatus, fetchSettings, fetchTaskDetail, updateSettings, runScript, fetchScripts } from "../../api";
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, updateSettings, runScript, fetchScripts } from "../../api";
beforeEach(() => {
vi.clearAllMocks();
@@ -513,10 +514,30 @@ describe("App mission wiring", () => {
});
describe("App auto-open Settings on unauthenticated", () => {
it("auto-opens Settings to Authentication tab when all providers are unauthenticated", async () => {
it("auto-opens onboarding modal when all providers are unauthenticated and onboarding not complete", async () => {
// fetchGlobalSettings returns {} by default (modelOnboardingComplete is undefined)
render(<App />);
// Wait for the auth status check and global settings check
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
await waitFor(() => expect(fetchGlobalSettings).toHaveBeenCalled());
// The onboarding modal should be open showing the provider step
await waitFor(() => {
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
});
// Settings modal should NOT be open
expect(screen.queryByText("Settings")).toBeNull();
});
it("auto-opens Settings to Authentication tab when all providers are unauthenticated but onboarding IS complete", async () => {
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
modelOnboardingComplete: true,
});
render(<App />);
// Wait for the auth status check and settings modal to appear
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
// The Settings modal should be open showing Authentication content
@@ -524,34 +545,59 @@ describe("App auto-open Settings on unauthenticated", () => {
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(2));
// Authentication section should be active — auth status is fetched when section is active
// Wait for the auth providers to appear
await waitFor(() => {
expect(screen.getByText("Anthropic")).toBeTruthy();
});
expect(screen.getByText("GitHub")).toBeTruthy();
// General section should NOT be showing
expect(screen.queryByLabelText("Task Prefix")).toBeNull();
// Onboarding modal should NOT be open
expect(screen.queryByText("Set Up AI Provider")).toBeNull();
});
it("does NOT auto-open Settings when at least one provider is authenticated", async () => {
it("does NOT auto-open anything when at least one provider is authenticated and default model is set", async () => {
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true },
{ id: "github", name: "GitHub", authenticated: false },
],
});
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
render(<App />);
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
// Settings modal should NOT be open — no modal overlay
// fetchSettings called once by App useEffect only (not by SettingsModal)
// Settings modal should NOT be open
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(1));
// No settings modal content
expect(screen.queryByText("Settings")).toBeNull();
// Onboarding modal should NOT be open
expect(screen.queryByText("Set Up AI Provider")).toBeNull();
});
it("auto-opens onboarding when providers are authenticated but default model is missing", async () => {
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true },
],
});
// No defaultProvider or defaultModelId → setup incomplete
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
modelOnboardingComplete: false,
});
render(<App />);
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
await waitFor(() => expect(fetchGlobalSettings).toHaveBeenCalled());
// Onboarding modal should be open
await waitFor(() => {
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
});
});
it("does NOT auto-open Settings when fetchAuthStatus fails", async () => {
@@ -564,37 +610,35 @@ describe("App auto-open Settings on unauthenticated", () => {
// Settings modal should NOT be open
expect(screen.queryByText("Settings")).toBeNull();
// Onboarding modal should NOT be open
expect(screen.queryByText("Set Up AI Provider")).toBeNull();
});
it("re-opening Settings via gear icon defaults to General tab after auto-opened close", async () => {
it("re-opening Settings via gear icon defaults to General tab after closing onboarding", async () => {
// fetchGlobalSettings returns {} by default → onboarding opens
render(<App />);
// Wait for auto-open
// Wait for onboarding to auto-open
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(2));
await waitFor(() => {
expect(screen.getByText("Anthropic")).toBeTruthy();
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
});
// Authentication auto-open should not render General fields yet
expect(screen.queryByLabelText("Task Prefix")).toBeNull();
// Dismiss the onboarding modal via Skip for now button
fireEvent.click(screen.getByText("Skip for now"));
// Close the auto-opened settings modal via Cancel button
fireEvent.click(screen.getByText("Cancel"));
// Settings modal should be closed
// Onboarding modal should be closed
await waitFor(() => {
expect(screen.queryByText("Anthropic")).toBeNull();
expect(screen.queryByText("Set Up AI Provider")).toBeNull();
});
// Open settings again via the gear icon button
// Open settings via the gear icon button
const settingsButton = screen.getByTitle("Settings");
fireEvent.click(settingsButton);
// Now it should open to General section (default)
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(3));
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(2));
expect(screen.getByLabelText("Task Prefix")).toBeTruthy();
expect(screen.queryByText("Anthropic")).toBeNull();
});
});

View File

@@ -0,0 +1,450 @@
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 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),
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>
),
}));
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 },
];
beforeEach(() => {
vi.clearAllMocks();
mockFetchAuthStatus.mockResolvedValue({ providers: defaultAuthProviders });
mockFetchModels.mockResolvedValue({ models: defaultModels, favoriteProviders: [], favoriteModels: [] });
mockUpdateGlobalSettings.mockResolvedValue({});
mockLoginProvider.mockResolvedValue({ url: "https://auth.example.com/login" });
mockLogoutProvider.mockResolvedValue({ success: true });
mockSaveApiKey.mockResolvedValue({ success: true });
mockClearApiKey.mockResolvedValue({ success: true });
});
describe("ModelOnboardingModal", () => {
describe("provider step", () => {
it("renders the provider step by default", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
});
expect(screen.getByText("Connect Provider")).toBeTruthy();
expect(screen.getByText("Select Model")).toBeTruthy();
});
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("disables Continue button when no providers are authenticated", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Continue →")).toBeTruthy();
});
expect(screen.getByText("Continue →").closest("button")?.disabled).toBe(true);
});
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("model selection step", () => {
it("advances to model step when Continue is clicked with authenticated provider", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Continue →")).toBeTruthy();
});
// Wait for auto-advance or click Continue
// The auto-advance happens after 600ms, but we can also click
await waitFor(() => {
expect(screen.getByText("Choose Default Model")).toBeTruthy();
}, { timeout: 3000 });
});
it("shows model dropdown on the model step", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Choose Default Model")).toBeTruthy();
}, { timeout: 3000 });
expect(screen.getByTestId("mock-model-dropdown")).toBeTruthy();
});
it("allows model selection", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Choose Default Model")).toBeTruthy();
}, { timeout: 3000 });
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("allows going back to provider step", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Choose Default Model")).toBeTruthy();
}, { timeout: 3000 });
fireEvent.click(screen.getByText("← Back"));
await waitFor(() => {
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
});
});
});
describe("completion", () => {
it("saves model selection and marks onboarding complete", async () => {
const onComplete = vi.fn();
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={onComplete} addToast={vi.fn()} />);
// Wait for auto-advance to model step
await waitFor(() => {
expect(screen.getByText("Choose Default Model")).toBeTruthy();
}, { timeout: 3000 });
// Select a model
const dropdown = screen.getByTestId("mock-model-dropdown");
fireEvent.change(dropdown, { target: { value: "anthropic/claude-sonnet-4-5" } });
// Click Complete Setup
await waitFor(() => {
expect(screen.getByText("Complete Setup")).toBeTruthy();
});
fireEvent.click(screen.getByText("Complete Setup"));
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
modelOnboardingComplete: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
}),
);
});
// 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()} />);
// Wait for auto-advance
await waitFor(() => {
expect(screen.getByText("Choose Default Model")).toBeTruthy();
}, { timeout: 3000 });
// Click Complete Setup without selecting a model
fireEvent.click(screen.getByText("Complete 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 Provider")).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("shows empty state when no models are available on model step", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
mockFetchModels.mockResolvedValueOnce({ models: [], favoriteProviders: [], favoriteModels: [] });
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Choose Default Model")).toBeTruthy();
}, { timeout: 3000 });
expect(screen.getByText(/No models available/)).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 Provider")).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();
});
});
});

View File

@@ -33,6 +33,8 @@ vi.mock("../../api", () => ({
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })),
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
saveApiKey: vi.fn(() => Promise.resolve({ success: true })),
clearApiKey: vi.fn(() => Promise.resolve({ success: true })),
fetchModels: vi.fn(() => Promise.resolve({
models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
@@ -44,7 +46,7 @@ vi.mock("../../api", () => ({
testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })),
}));
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../../api";
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification } from "../../api";
const onClose = vi.fn();
const addToast = vi.fn();
@@ -997,6 +999,257 @@ describe("SettingsModal", () => {
expect(screen.getByText("GitHub")).toBeTruthy();
});
// --- API key provider tests ---
it("renders password input and Save button for api_key providers", async () => {
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
],
});
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
const input = screen.getByPlaceholderText("Enter API key") as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.type).toBe("password");
// The Save button should be inside the auth-apikey-section, not the global save
const apiKeySection = container.querySelector(".auth-apikey-section");
expect(apiKeySection).toBeTruthy();
expect(apiKeySection!.querySelector("button")!.textContent?.trim()).toBe("Save");
// Should NOT show Login button for api_key providers
expect(screen.queryByText("Login")).toBeNull();
});
it("renders Clear button for authenticated api_key providers", async () => {
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
],
});
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
// The Clear button should be inside the auth-apikey-section
const apiKeySection = container.querySelector(".auth-apikey-section");
expect(apiKeySection).toBeTruthy();
expect(apiKeySection!.querySelector("button")!.textContent?.trim()).toBe("Clear");
// Should NOT show Logout button for api_key providers
expect(screen.queryByText("Logout")).toBeNull();
});
it("saves API key when Save is clicked", async () => {
(fetchAuthStatus as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
providers: [
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
],
})
.mockResolvedValueOnce({
providers: [
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
],
});
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
const input = screen.getByPlaceholderText("Enter API key");
fireEvent.change(input, { target: { value: "sk-test-key-123" } });
const apiKeySection = container.querySelector(".auth-apikey-section")!;
fireEvent.click(apiKeySection.querySelector("button")!);
await waitFor(() => expect(saveApiKey).toHaveBeenCalledWith("openrouter", "sk-test-key-123"));
expect(addToast).toHaveBeenCalledWith("API key saved", "success");
});
it("shows error when saving empty API key", async () => {
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
],
});
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
const apiKeySection = container.querySelector(".auth-apikey-section")!;
fireEvent.click(apiKeySection.querySelector("button")!);
expect(screen.getByText("API key is required")).toBeTruthy();
});
it("clears API key when Clear is clicked", async () => {
(fetchAuthStatus as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
providers: [
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
],
})
.mockResolvedValueOnce({
providers: [
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
],
});
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
const apiKeySection = container.querySelector(".auth-apikey-section")!;
fireEvent.click(apiKeySection.querySelector("button")!);
await waitFor(() => expect(clearApiKey).toHaveBeenCalledWith("openrouter"));
expect(addToast).toHaveBeenCalledWith("API key cleared", "success");
});
it("handles API key save error gracefully", async () => {
(saveApiKey as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
],
});
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
const input = screen.getByPlaceholderText("Enter API key");
fireEvent.change(input, { target: { value: "sk-key" } });
const apiKeySection = container.querySelector(".auth-apikey-section")!;
fireEvent.click(apiKeySection.querySelector("button")!);
await waitFor(() => expect(screen.getByText("Network error")).toBeTruthy());
});
it("shows input field after clearing API key (badge updates to not authenticated)", async () => {
(fetchAuthStatus as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
providers: [
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
],
})
.mockResolvedValueOnce({
providers: [
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
],
});
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
// Initially should show "Clear" button (authenticated)
expect(container.querySelector(".auth-apikey-section button")?.textContent).toContain("Clear");
// Click Clear
fireEvent.click(container.querySelector(".auth-apikey-section button")!);
await waitFor(() => expect(clearApiKey).toHaveBeenCalledWith("openrouter"));
// After clearing, fetchAuthStatus should be called again (loadAuthStatus)
// and the UI should show an input field (not authenticated state)
await waitFor(() => {
expect(container.querySelector(".auth-apikey-section input")).toBeTruthy();
});
expect(addToast).toHaveBeenCalledWith("API key cleared", "success");
});
it("handles API key clear error gracefully", async () => {
(clearApiKey as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Clear failed"));
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
],
});
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
const apiKeySection = container.querySelector(".auth-apikey-section")!;
fireEvent.click(apiKeySection.querySelector("button")!);
await waitFor(() => expect(clearApiKey).toHaveBeenCalledWith("openrouter"));
expect(addToast).toHaveBeenCalledWith("Clear failed", "error");
});
it("shows Login for oauth providers and password input for api_key providers side by side", async () => {
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" as const },
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
// OAuth provider shows Login
expect(screen.getByText("Login")).toBeTruthy();
// API key provider shows password input
expect(screen.getByPlaceholderText("Enter API key")).toBeTruthy();
});
it("does not prefill stored key values in the input", async () => {
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
const input = screen.getByPlaceholderText("Enter API key") as HTMLInputElement;
expect(input.value).toBe("");
});
// --- Mobile structure tests (DOM classes that responsive CSS targets) ---
it("has .settings-layout wrapping sidebar and content", async () => {