Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions desktop/src/components/onboarding/ProviderOnboarding.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<StrictMode>
<ProviderOnboarding />
</StrictMode>,
)

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(
<StrictMode>
<ProviderOnboarding />
</StrictMode>,
)

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(
<StrictMode>
<ProviderOnboarding />
</StrictMode>,
)

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)
})
})
22 changes: 19 additions & 3 deletions desktop/src/components/onboarding/ProviderOnboarding.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,35 @@
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'
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 (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-4 bg-[var(--color-surface)]">
<p className="max-w-md px-8 text-center text-sm text-[var(--color-error)]">预设加载失败:{error}</p>
<Button onClick={() => void fetchPresets()}>重试</Button>
</div>
)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--color-surface)]">
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
Expand Down