fix(ai): bound the provider test and explain why it failed (#3319)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
Kaushik N
2026-08-16 12:45:00 +02:00
committed by GitHub
co-authored by Claude Opus 5 Amruth Pillai
parent 104e954b77
commit a4bc2693be
62 changed files with 1231 additions and 82 deletions
@@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { toast } from "sonner";
type MutationName = "create" | "test" | "update" | "delete";
@@ -191,6 +192,35 @@ describe("AISettingsSection", () => {
expect(updater([created])).toEqual([tested]);
});
// The server returns a provider-side failure as data (see the API package's e2e coverage), so the
// reason has to reach the card rather than being flattened into a generic transport error.
it("shows the server's reason on the card and keeps the toast to the outcome", async () => {
const failed = provider({
enabled: false,
testStatus: "failure",
testError: "OpenAI rejected the API key.",
lastTestedAt: new Date("2026-08-15T00:00:00Z"),
});
providers.data = [provider({})];
mutations.test.mockResolvedValue(failed);
renderSection();
fireEvent.click(screen.getByRole("button", { name: "Test" }));
await waitFor(() => expect(toast.error).toHaveBeenCalled());
// Toast reports only the outcome; the card carries the detail.
expect(toast.error).toHaveBeenCalledWith("Connection failed.");
expect(toast.error).not.toHaveBeenCalledWith(expect.stringContaining("rejected the API key"));
providers.data = [failed];
renderSection();
expect(screen.getAllByText("OpenAI rejected the API key.").length).toBeGreaterThan(0);
expect(screen.getAllByText("Connection failed").length).toBeGreaterThan(0);
});
it("updates a configured provider's model", async () => {
providers.data = [provider({})];
mutations.update.mockResolvedValue(provider({ model: "gpt-5-mini", testStatus: "untested", enabled: false }));
@@ -14,7 +14,7 @@ import {
XCircleIcon,
} from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { AI_PROVIDER_DEFAULT_BASE_URLS } from "@reactive-resume/ai/types";
import { Badge } from "@reactive-resume/ui/components/badge";
@@ -193,6 +193,36 @@ function providerLabel(provider: AIProvider) {
return providerOptions.find((option) => option.value === provider)?.label ?? provider;
}
// A provider test can legitimately take tens of seconds against a cold local model. Without a sense
// of time passing, a bare spinner reads as a freeze, so start narrating the wait once it gets long.
const SHOW_ELAPSED_AFTER_SECONDS = 5;
const STILL_WAITING_AFTER_SECONDS = 20;
function useElapsedSeconds(isRunning: boolean) {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
if (!isRunning) {
setSeconds(0);
return;
}
const startedAt = Date.now();
const interval = setInterval(() => setSeconds(Math.floor((Date.now() - startedAt) / 1000)), 1000);
return () => clearInterval(interval);
}, [isRunning]);
return seconds;
}
function testingLabel(elapsedSeconds: number, provider: string) {
if (elapsedSeconds >= STILL_WAITING_AFTER_SECONDS) return t`Still waiting for ${provider}${elapsedSeconds}s`;
if (elapsedSeconds >= SHOW_ELAPSED_AFTER_SECONDS) return t`Testing… ${elapsedSeconds}s`;
return null;
}
function upsertProvider(providers: SavedProvider[] | undefined, provider: SavedProvider) {
if (!providers) return [provider];
if (!providers.some((entry) => entry.id === provider.id)) return [...providers, provider];
@@ -217,6 +247,8 @@ function ProviderRow({ provider }: ProviderRowProps) {
const { mutate: updateProvider, isPending: isUpdating } = useMutation(orpc.aiProviders.update.mutationOptions());
const { mutate: deleteProvider, isPending: isDeleting } = useMutation(orpc.aiProviders.delete.mutationOptions());
const isMutating = isTesting || isUpdating || isDeleting;
const testElapsedSeconds = useElapsedSeconds(isTesting);
const testLabel = testingLabel(testElapsedSeconds, String(providerLabel(provider.provider)));
const saveModel = () => {
const nextModel = model.trim();
if (!nextModel || nextModel === provider.model) {
@@ -314,7 +346,8 @@ function ProviderRow({ provider }: ProviderRowProps) {
if (response.testStatus === "success") {
toast.success(t`Provider connection verified.`);
} else {
toast.error(response.testError ?? t`Could not verify provider connection.`);
// The reason persists on the card below, so the toast only reports the outcome.
toast.error(t`Connection failed.`);
}
void invalidate();
},
@@ -327,7 +360,7 @@ function ProviderRow({ provider }: ProviderRowProps) {
}
>
{isTesting ? <Spinner /> : provider.testStatus === "success" ? <CheckCircleIcon /> : <WarningCircleIcon />}
<Trans>Test</Trans>
{testLabel ?? <Trans>Test</Trans>}
</Button>
<Button
@@ -393,6 +426,7 @@ function CreateProviderForm() {
orpc.aiProviders.test.mutationOptions({ meta: { noInvalidate: true } }),
);
const isSaving = isCreating || isTesting;
const testElapsedSeconds = useElapsedSeconds(isTesting);
// Model/label are prefilled from provider defaults, so step 1 (Provider + API Key) is enough to save.
const model = form.model.trim();
@@ -559,7 +593,13 @@ function CreateProviderForm() {
<div className="mt-4 flex justify-end">
<Button disabled={!canSave || isSaving} onClick={() => void save()}>
{isSaving ? <Spinner /> : <KeyIcon />}
{isTesting ? <Trans>Testing</Trans> : <Trans>Save & Test Provider</Trans>}
{isTesting ? (
(testingLabel(testElapsedSeconds, String(selectedOption?.label ?? form.provider)) ?? (
<Trans>Testing</Trans>
))
) : (
<Trans>Save & Test Provider</Trans>
)}
</Button>
</div>
</div>