mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 07:12:18 +10:00
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:
co-authored by
Claude Opus 5
Amruth Pillai
parent
104e954b77
commit
a4bc2693be
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user