diff --git a/desktop/src/components/onboarding/ProviderOnboarding.test.tsx b/desktop/src/components/onboarding/ProviderOnboarding.test.tsx new file mode 100644 index 0000000..95bed2a --- /dev/null +++ b/desktop/src/components/onboarding/ProviderOnboarding.test.tsx @@ -0,0 +1,92 @@ +import { StrictMode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderPreset } from '../../types/providerPreset' + +const providersApiMock = vi.hoisted(() => ({ + presets: vi.fn(), +})) + +vi.mock('../../api/providers', () => ({ + providersApi: providersApiMock, +})) + +import { useProviderStore } from '../../stores/providerStore' +import { ProviderOnboarding } from './ProviderOnboarding' + +const dreamfieldPreset: ProviderPreset = { + id: 'dreamfield', + name: 'DreamField', + baseUrl: 'https://example.invalid/api', + apiFormat: 'anthropic', + defaultModels: { + main: 'dreamfield-main', + haiku: '', + sonnet: '', + opus: '', + }, + needsApiKey: true, + websiteUrl: 'https://example.invalid', +} + +describe('ProviderOnboarding', () => { + beforeEach(() => { + vi.clearAllMocks() + useProviderStore.setState({ + providers: [], + activeId: null, + hasLoadedProviders: true, + presets: [], + isLoading: false, + isPresetsLoading: false, + error: null, + }) + }) + + it('loads presets once after mounting and advances past the loading screen', async () => { + providersApiMock.presets.mockResolvedValue({ presets: [dreamfieldPreset] }) + + render( + + + , + ) + + expect(await screen.findByRole('textbox', { name: /^API Key/ })).toBeInTheDocument() + expect(providersApiMock.presets).toHaveBeenCalledTimes(1) + }) + + it('does not refetch presets that are already available', () => { + useProviderStore.setState({ presets: [dreamfieldPreset] }) + + render( + + + , + ) + + expect(screen.getByRole('textbox', { name: /^API Key/ })).toBeInTheDocument() + expect(providersApiMock.presets).not.toHaveBeenCalled() + }) + + it('shows an error with a retry button when preset loading fails, and recovers on retry', async () => { + providersApiMock.presets + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce({ presets: [dreamfieldPreset] }) + + render( + + + , + ) + + const retryButton = await screen.findByRole('button', { name: '重试' }) + expect(screen.getByText(/network down/)).toBeInTheDocument() + + fireEvent.click(retryButton) + + expect(await screen.findByRole('textbox', { name: /^API Key/ })).toBeInTheDocument() + expect(providersApiMock.presets).toHaveBeenCalledTimes(2) + }) +}) diff --git a/desktop/src/components/onboarding/ProviderOnboarding.tsx b/desktop/src/components/onboarding/ProviderOnboarding.tsx index 81102de..0a900ef 100644 --- a/desktop/src/components/onboarding/ProviderOnboarding.tsx +++ b/desktop/src/components/onboarding/ProviderOnboarding.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useProviderStore } from '../../stores/providerStore' import { useSettingsStore } from '../../stores/settingsStore' import { Input } from '../shared/Input' @@ -6,14 +6,30 @@ import { Button } from '../shared/Button' import { DreamCoderIcon } from '../shared/DreamCoderIcon' export function ProviderOnboarding() { - const { presets, createProvider, activateProvider, fetchPresets } = useProviderStore() + const { presets, error, isPresetsLoading, createProvider, activateProvider, fetchPresets } = useProviderStore() const setOnboardingCompleted = useSettingsStore((s) => s.setOnboardingCompleted) const fetchSettings = useSettingsStore((s) => s.fetchAll) const [apiKey, setApiKey] = useState('') const [loading, setLoading] = useState(false) + const requestedPresetsRef = useRef(false) + + useEffect(() => { + if (presets.length > 0 || requestedPresetsRef.current) return + requestedPresetsRef.current = true + void fetchPresets() + }, [fetchPresets, presets.length]) if (presets.length === 0) { - fetchPresets() + // fetchPresets 失败只会写入 store 的 error,这里必须给出重试入口, + // 否则全新 profile 会永远停在加载动画上(issue #40 的遗留场景)。 + if (error && !isPresetsLoading) { + return ( +
+

预设加载失败:{error}

+ +
+ ) + } return (