feat(FN-1935): add API key validation feedback to onboarding modal
- Add provider-specific API key format rules, display names, and fallback validation hints - Render inline format hints plus error/success feedback in the API key form - Add styled input states for error/success and success message auto-clear timers - Expand onboarding modal tests to cover validation, network/server errors, success lifecycle, and skip flow behavior
This commit is contained in:
@@ -103,6 +103,78 @@ const PROVIDER_INFO: Record<string, ProviderInfo> = {
|
||||
},
|
||||
};
|
||||
|
||||
const PROVIDER_KEY_HINTS: Record<string, {
|
||||
pattern: RegExp;
|
||||
hint: string;
|
||||
example: string;
|
||||
}> = {
|
||||
anthropic: { pattern: /^sk-ant-/, hint: "Starts with sk-ant-", example: "sk-ant-api03-..." },
|
||||
openai: { pattern: /^sk-/, hint: "Starts with sk-", example: "sk-..." },
|
||||
"openai-codex": { pattern: /^sk-/, hint: "Starts with sk-", example: "sk-..." },
|
||||
openrouter: { pattern: /^sk-or-/, hint: "Starts with sk-or-", example: "sk-or-v1-..." },
|
||||
google: { pattern: /^AIza/, hint: "Starts with AIza", example: "AIza..." },
|
||||
gemini: { pattern: /^AIza/, hint: "Starts with AIza", example: "AIza..." },
|
||||
minimax: { pattern: /^.{8,}$/, hint: "At least 8 characters", example: "..." },
|
||||
ollama: { pattern: /^.+$/, hint: "Any non-empty value", example: "ollama" },
|
||||
zai: { pattern: /^.{8,}$/, hint: "At least 8 characters", example: "..." },
|
||||
kimi: { pattern: /^.{8,}$/, hint: "At least 8 characters", example: "..." },
|
||||
"kimi-coding": { pattern: /^.{8,}$/, hint: "At least 8 characters", example: "..." },
|
||||
moonshot: { pattern: /^.{8,}$/, hint: "At least 8 characters", example: "..." },
|
||||
};
|
||||
|
||||
const PROVIDER_KEY_HINTS_FALLBACK = {
|
||||
pattern: /^.{8,}$/,
|
||||
hint: "At least 8 characters",
|
||||
example: "...",
|
||||
};
|
||||
|
||||
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
anthropic: "Anthropic",
|
||||
openai: "OpenAI",
|
||||
"openai-codex": "OpenAI Codex",
|
||||
openrouter: "OpenRouter",
|
||||
google: "Google",
|
||||
gemini: "Gemini",
|
||||
minimax: "MiniMax",
|
||||
ollama: "Ollama",
|
||||
zai: "Zhipu AI",
|
||||
kimi: "Kimi",
|
||||
"kimi-coding": "Kimi Coding",
|
||||
moonshot: "Moonshot",
|
||||
};
|
||||
|
||||
function getProviderDisplayName(providerId: string): string {
|
||||
if (PROVIDER_DISPLAY_NAMES[providerId]) {
|
||||
return PROVIDER_DISPLAY_NAMES[providerId];
|
||||
}
|
||||
|
||||
const normalized = providerId.trim();
|
||||
if (!normalized) {
|
||||
return "This provider";
|
||||
}
|
||||
|
||||
return normalized
|
||||
.split(/[-_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function validateApiKeyFormat(providerId: string, key: string): string | null {
|
||||
const trimmedKey = key.trim();
|
||||
if (!trimmedKey) {
|
||||
return "API key is required";
|
||||
}
|
||||
|
||||
const providerHint = PROVIDER_KEY_HINTS[providerId] ?? PROVIDER_KEY_HINTS_FALLBACK;
|
||||
if (providerHint.pattern.test(trimmedKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerName = getProviderDisplayName(providerId);
|
||||
return `${providerName} keys should follow this format: ${providerHint.hint} (e.g. ${providerHint.example})`;
|
||||
}
|
||||
|
||||
const API_KEY_INFO_FALLBACK: ApiKeyInfo = {
|
||||
fieldLabel: "API Key",
|
||||
setupInstructions: "Enter your API key for this provider.",
|
||||
@@ -169,6 +241,7 @@ interface ApiKeyEntryFormProps {
|
||||
inputValue: string;
|
||||
isSaving: boolean;
|
||||
error?: string;
|
||||
success?: string | null;
|
||||
isConnected: boolean;
|
||||
onInputChange: (providerId: string, key: string) => void;
|
||||
onSave: (providerId: string, key: string) => void | Promise<void>;
|
||||
@@ -181,6 +254,7 @@ function ApiKeyEntryForm({
|
||||
inputValue,
|
||||
isSaving,
|
||||
error,
|
||||
success,
|
||||
isConnected,
|
||||
onInputChange,
|
||||
onSave,
|
||||
@@ -188,6 +262,10 @@ function ApiKeyEntryForm({
|
||||
}: ApiKeyEntryFormProps) {
|
||||
const inputId = `onboarding-apikey-input-${provider.id}`;
|
||||
const saveDisabled = isSaving || !inputValue.trim();
|
||||
const providerKeyHint = PROVIDER_KEY_HINTS[provider.id];
|
||||
const inputClassName = `input onboarding-apikey-input${
|
||||
error ? " onboarding-apikey-input--error" : ""
|
||||
}${success ? " onboarding-apikey-input--success" : ""}`;
|
||||
|
||||
if (isConnected) {
|
||||
return (
|
||||
@@ -218,7 +296,7 @@ function ApiKeyEntryForm({
|
||||
<input
|
||||
id={inputId}
|
||||
type="password"
|
||||
className="input onboarding-apikey-input"
|
||||
className={inputClassName}
|
||||
placeholder={apiKeyInfo.inputPlaceholder ?? "Enter API key"}
|
||||
value={inputValue}
|
||||
onChange={(e) => onInputChange(provider.id, e.target.value)}
|
||||
@@ -238,7 +316,20 @@ function ApiKeyEntryForm({
|
||||
{isSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
{providerKeyHint && (
|
||||
<small className="onboarding-apikey-hint">
|
||||
Format: {providerKeyHint.hint}
|
||||
</small>
|
||||
)}
|
||||
{error && <small className="field-error">{error}</small>}
|
||||
{success && !error && (
|
||||
<small
|
||||
className="onboarding-apikey-success"
|
||||
data-testid={`onboarding-apikey-success-${provider.id}`}
|
||||
>
|
||||
{success}
|
||||
</small>
|
||||
)}
|
||||
<p className="onboarding-apikey-instructions">{apiKeyInfo.setupInstructions}</p>
|
||||
{apiKeyInfo.dashboardUrl && (
|
||||
<a
|
||||
@@ -323,6 +414,8 @@ export function ModelOnboardingModal({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
|
||||
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
|
||||
const [apiKeySuccess, setApiKeySuccess] = useState<Record<string, string | null>>({});
|
||||
const apiKeySuccessTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [loginOutcomes, setLoginOutcomes] = useState<Record<string, LoginOutcome>>({});
|
||||
const [isGithubSkipped, setIsGithubSkipped] = useState<boolean>(() => {
|
||||
@@ -575,6 +668,8 @@ export function ModelOnboardingModal({
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
}
|
||||
Object.values(apiKeySuccessTimers.current).forEach(clearTimeout);
|
||||
apiKeySuccessTimers.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -714,6 +809,13 @@ export function ModelOnboardingModal({
|
||||
...prev,
|
||||
[providerId]: value,
|
||||
}));
|
||||
|
||||
const successTimer = apiKeySuccessTimers.current[providerId];
|
||||
if (successTimer) {
|
||||
clearTimeout(successTimer);
|
||||
delete apiKeySuccessTimers.current[providerId];
|
||||
}
|
||||
|
||||
setApiKeyErrors((prev) => {
|
||||
if (!prev[providerId]) {
|
||||
return prev;
|
||||
@@ -722,39 +824,108 @@ export function ModelOnboardingModal({
|
||||
delete next[providerId];
|
||||
return next;
|
||||
});
|
||||
|
||||
setApiKeySuccess((prev) => {
|
||||
if (!prev[providerId]) {
|
||||
return prev;
|
||||
}
|
||||
const next = { ...prev };
|
||||
delete next[providerId];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// API key save handler
|
||||
const handleSaveApiKey = useCallback(
|
||||
async (providerId: string, keyValue?: string) => {
|
||||
const key = (keyValue ?? apiKeyInputs[providerId] ?? "").trim();
|
||||
if (!key) {
|
||||
const validationError = validateApiKeyFormat(providerId, key);
|
||||
if (validationError) {
|
||||
setApiKeyErrors((prev) => ({
|
||||
...prev,
|
||||
[providerId]: "API key is required",
|
||||
[providerId]: validationError,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTimer = apiKeySuccessTimers.current[providerId];
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
delete apiKeySuccessTimers.current[providerId];
|
||||
}
|
||||
|
||||
setAuthActionInProgress(providerId);
|
||||
setApiKeyErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[providerId];
|
||||
return next;
|
||||
});
|
||||
setApiKeySuccess((prev) => {
|
||||
if (!prev[providerId]) {
|
||||
return 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;
|
||||
});
|
||||
setApiKeyErrors((prev) => {
|
||||
if (!prev[providerId]) {
|
||||
return prev;
|
||||
}
|
||||
const next = { ...prev };
|
||||
delete next[providerId];
|
||||
return next;
|
||||
});
|
||||
setApiKeySuccess((prev) => ({
|
||||
...prev,
|
||||
[providerId]: "✓ Key saved",
|
||||
}));
|
||||
|
||||
apiKeySuccessTimers.current[providerId] = setTimeout(() => {
|
||||
setApiKeySuccess((prev) => {
|
||||
if (!prev[providerId]) {
|
||||
return prev;
|
||||
}
|
||||
const next = { ...prev };
|
||||
delete next[providerId];
|
||||
return next;
|
||||
});
|
||||
delete apiKeySuccessTimers.current[providerId];
|
||||
}, 3000);
|
||||
|
||||
addToast("API key saved", "success");
|
||||
} catch (err: unknown) {
|
||||
addToast(
|
||||
err instanceof Error ? err.message : "Failed to save API key",
|
||||
"error",
|
||||
);
|
||||
const errorMessage =
|
||||
err instanceof TypeError && err.message.includes("Failed to fetch")
|
||||
? "Could not reach the server. Check your connection and try again."
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Failed to save API key";
|
||||
|
||||
setApiKeyErrors((prev) => ({
|
||||
...prev,
|
||||
[providerId]: errorMessage,
|
||||
}));
|
||||
setApiKeySuccess((prev) => {
|
||||
if (!prev[providerId]) {
|
||||
return prev;
|
||||
}
|
||||
const next = { ...prev };
|
||||
delete next[providerId];
|
||||
return next;
|
||||
});
|
||||
|
||||
addToast(errorMessage, "error");
|
||||
} finally {
|
||||
setAuthActionInProgress(null);
|
||||
}
|
||||
@@ -1247,6 +1418,7 @@ export function ModelOnboardingModal({
|
||||
inputValue={apiKeyInputs[provider.id] ?? ""}
|
||||
isSaving={authActionInProgress === provider.id}
|
||||
error={apiKeyErrors[provider.id]}
|
||||
success={apiKeySuccess[provider.id]}
|
||||
isConnected={provider.authenticated}
|
||||
onInputChange={handleApiKeyInputChange}
|
||||
onSave={handleSaveApiKey}
|
||||
|
||||
@@ -489,6 +489,257 @@ describe("ModelOnboardingModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("API key validation and error feedback", () => {
|
||||
it("shows required validation when API key is empty", 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.keyDown(input, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("API key is required")).toBeTruthy();
|
||||
});
|
||||
expect(mockSaveApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows format validation for known provider and blocks save", 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: "abc" } });
|
||||
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("OpenAI keys should follow this format: Starts with sk- (e.g. sk-...)"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
expect(mockSaveApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes valid format to server", 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" } });
|
||||
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSaveApiKey).toHaveBeenCalledWith("openai", "sk-test-key");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows actionable network error message", async () => {
|
||||
mockSaveApiKey.mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
||||
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-network-test" } });
|
||||
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Could not reach the server. Check your connection and try again.")).toBeTruthy();
|
||||
});
|
||||
expect(input).toHaveClass("onboarding-apikey-input--error");
|
||||
});
|
||||
|
||||
it("shows server error message inline", async () => {
|
||||
mockSaveApiKey.mockRejectedValueOnce(new Error("Unknown API key provider: xyz"));
|
||||
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-server-test" } });
|
||||
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Unknown API key provider: xyz")).toBeTruthy();
|
||||
});
|
||||
expect(input).toHaveClass("onboarding-apikey-input--error");
|
||||
});
|
||||
|
||||
it("shows inline success confirmation", 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-success-test" } });
|
||||
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("onboarding-apikey-success-openai")).toHaveTextContent("✓ Key saved");
|
||||
});
|
||||
expect(input).toHaveClass("onboarding-apikey-input--success");
|
||||
});
|
||||
|
||||
it("auto-clears success message after timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const input = screen.getByTestId("onboarding-apikey-input-openai");
|
||||
fireEvent.change(input, { target: { value: "sk-timeout-test" } });
|
||||
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("onboarding-apikey-success-openai")).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3100);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("onboarding-apikey-success-openai")).toBeNull();
|
||||
});
|
||||
|
||||
it("clears inline error state when input changes", 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");
|
||||
const formatError = "OpenAI keys should follow this format: Starts with sk- (e.g. sk-...)";
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(formatError)).toBeTruthy();
|
||||
});
|
||||
expect(input).toHaveClass("onboarding-apikey-input--error");
|
||||
|
||||
fireEvent.change(input, { target: { value: "sk-corrected" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(formatError)).toBeNull();
|
||||
});
|
||||
expect(input).not.toHaveClass("onboarding-apikey-input--error");
|
||||
});
|
||||
|
||||
it("clears inline success state when input changes", 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-before-edit" } });
|
||||
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("onboarding-apikey-success-openai")).toBeTruthy();
|
||||
});
|
||||
expect(input).toHaveClass("onboarding-apikey-input--success");
|
||||
|
||||
fireEvent.change(input, { target: { value: "sk-after-edit" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("onboarding-apikey-success-openai")).toBeNull();
|
||||
});
|
||||
expect(input).not.toHaveClass("onboarding-apikey-input--success");
|
||||
});
|
||||
|
||||
it("shows format hint for known providers", async () => {
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Format: Starts with sk-")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses fallback validation for unknown providers", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||
providers: [
|
||||
{ id: "mystery-provider", name: "Mystery AI", authenticated: false, type: "api_key" },
|
||||
],
|
||||
});
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("onboarding-apikey-input-mystery-provider")).toBeTruthy();
|
||||
});
|
||||
|
||||
const input = screen.getByTestId("onboarding-apikey-input-mystery-provider");
|
||||
fireEvent.change(input, { target: { value: "short" } });
|
||||
fireEvent.click(screen.getByTestId("onboarding-apikey-save-mystery-provider"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/At least 8 characters/)).toBeTruthy();
|
||||
});
|
||||
expect(mockSaveApiKey).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(input, { target: { value: "longenoughkey" } });
|
||||
fireEvent.click(screen.getByTestId("onboarding-apikey-save-mystery-provider"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSaveApiKey).toHaveBeenCalledWith("mystery-provider", "longenoughkey");
|
||||
});
|
||||
});
|
||||
|
||||
it("allows skipping and continuing even when validation errors exist", 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: "abc" } });
|
||||
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("OpenAI keys should follow this format: Starts with sk- (e.g. sk-...)"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skip setup →" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Connect GitHub")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "← Back" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Up AI")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next →" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Connect GitHub")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GitHub step", () => {
|
||||
it("GitHub step shows fallback when no GitHub provider", async () => {
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
Reference in New Issue
Block a user