diff --git a/ui/src/components/setup/wizard/index.tsx b/ui/src/components/setup/wizard/index.tsx index 290b8298..5170c8d2 100644 --- a/ui/src/components/setup/wizard/index.tsx +++ b/ui/src/components/setup/wizard/index.tsx @@ -61,6 +61,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { ); const accounts = useMemo(() => providerAuth?.accounts || [], [providerAuth?.accounts]); const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]); + const fetchedCatalogsReady = Boolean(catalogData); // Reset on close useEffect(() => { @@ -104,7 +105,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { const result = await applyDefaultPreset( selectedProvider, undefined, - catalogs[selectedProvider] + fetchedCatalogsReady ? catalogs[selectedProvider] : undefined ); if (result.success && result.presetName) { toast.success(`Applied "${result.presetName}" preset`); diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 5a05cd95..ee21191f 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -264,6 +264,7 @@ export function CliproxyPage() { const isRemoteMode = authData?.source === 'remote'; const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]); const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]); + const fetchedCatalogsReady = Boolean(catalogData); // Wrapper to persist provider selection to localStorage const setSelectedProvider = (provider: string | null) => { @@ -561,7 +562,11 @@ export function CliproxyPage() { onClose={() => setAddAccountProvider(null)} provider={addAccountProvider?.provider || ''} displayName={addAccountProvider?.displayName || ''} - catalog={addAccountProvider?.provider ? catalogs[addAccountProvider.provider] : undefined} + catalog={ + fetchedCatalogsReady && addAccountProvider?.provider + ? catalogs[addAccountProvider.provider] + : undefined + } isFirstAccount={addAccountProvider?.isFirstAccount || false} /> diff --git a/ui/tests/unit/components/setup/quick-setup-wizard.test.tsx b/ui/tests/unit/components/setup/quick-setup-wizard.test.tsx new file mode 100644 index 00000000..abaa5de8 --- /dev/null +++ b/ui/tests/unit/components/setup/quick-setup-wizard.test.tsx @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils'; + +const hookState = vi.hoisted(() => ({ + catalogData: undefined as + | { + catalogs: Record< + string, + { + provider: string; + displayName: string; + defaultModel: string; + models: Array<{ id: string; name: string }>; + } + >; + } + | undefined, +})); + +const applyDefaultPresetMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/hooks/use-cliproxy', () => ({ + useCliproxyAuth: () => ({ + data: { + authStatus: [ + { + provider: 'gemini', + displayName: 'Gemini', + authenticated: false, + accounts: [], + }, + ], + }, + refetch: vi.fn(), + }), + useCliproxyCatalog: () => ({ + data: hookState.catalogData, + }), + useCreateVariant: () => ({ + isPending: false, + mutateAsync: vi.fn(), + }), + useStartAuth: () => ({ + isPending: false, + mutate: (_args: unknown, options?: { onSuccess?: (data: Record) => void }) => { + options?.onSuccess?.({}); + }, + }), + useCancelAuth: () => ({ + mutate: vi.fn(), + }), +})); + +vi.mock('@/lib/preset-utils', () => ({ + applyDefaultPreset: applyDefaultPresetMock, +})); + +vi.mock('@/components/setup/wizard/steps/provider-step', () => ({ + ProviderStep: ({ onSelect }: { onSelect: (providerId: string) => void }) => ( + + ), +})); + +vi.mock('@/components/setup/wizard/steps/auth-step', () => ({ + AuthStep: ({ onStartAuth }: { onStartAuth: () => void }) => ( + + ), +})); + +vi.mock('@/components/setup/wizard/steps/account-step', () => ({ + AccountStep: () =>
account-step
, +})); + +vi.mock('@/components/setup/wizard/steps/variant-step', () => ({ + VariantStep: () =>
variant-step
, +})); + +vi.mock('@/components/setup/wizard/steps/success-step', () => ({ + SuccessStep: () =>
success-step
, +})); + +import { QuickSetupWizard } from '@/components/setup/wizard'; + +describe('QuickSetupWizard preset catalog reuse', () => { + beforeEach(() => { + hookState.catalogData = undefined; + applyDefaultPresetMock.mockReset(); + applyDefaultPresetMock.mockResolvedValue({ success: true, presetName: 'Gemini Pro' }); + }); + + it('does not pass a synthesized static catalog before the catalog query resolves', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'choose-gemini' })); + await userEvent.click(screen.getByRole('button', { name: 'start-auth' })); + + await waitFor(() => + expect(applyDefaultPresetMock).toHaveBeenCalledWith('gemini', undefined, undefined) + ); + }); + + it('passes the fetched provider catalog once the query has resolved', async () => { + hookState.catalogData = { + catalogs: { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + defaultModel: 'gemini-3.9-pro-preview', + models: [{ id: 'gemini-3.9-pro-preview', name: 'Gemini 3.9 Pro Preview' }], + }, + }, + }; + + render(); + + await userEvent.click(screen.getByRole('button', { name: 'choose-gemini' })); + await userEvent.click(screen.getByRole('button', { name: 'start-auth' })); + + await waitFor(() => + expect(applyDefaultPresetMock).toHaveBeenCalledWith( + 'gemini', + undefined, + expect.objectContaining({ + defaultModel: 'gemini-3.9-pro-preview', + }) + ) + ); + }); +}); diff --git a/ui/tests/unit/pages/cliproxy-page.test.tsx b/ui/tests/unit/pages/cliproxy-page.test.tsx new file mode 100644 index 00000000..bd651bfb --- /dev/null +++ b/ui/tests/unit/pages/cliproxy-page.test.tsx @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, userEvent } from '@tests/setup/test-utils'; + +const hookState = vi.hoisted(() => ({ + catalogData: undefined as + | { + catalogs: Record< + string, + { + provider: string; + displayName: string; + defaultModel: string; + models: Array<{ id: string; name: string }>; + } + >; + } + | undefined, +})); + +vi.mock('@/hooks/use-cliproxy', () => ({ + useCliproxy: () => ({ + data: { variants: [] }, + isFetching: false, + }), + useCliproxyAuth: () => ({ + data: { + authStatus: [ + { + provider: 'gemini', + displayName: 'Gemini', + authenticated: true, + accounts: [{ id: 'acct-1', provider: 'gemini' }], + }, + ], + source: 'local', + }, + isLoading: false, + }), + useCliproxyCatalog: () => ({ + data: hookState.catalogData, + }), + useCliproxyUpdateCheck: () => ({ + data: undefined, + }), + useSetDefaultAccount: () => ({ mutate: vi.fn(), isPending: false }), + useRemoveAccount: () => ({ mutate: vi.fn(), isPending: false }), + usePauseAccount: () => ({ mutate: vi.fn(), isPending: false }), + useResumeAccount: () => ({ mutate: vi.fn(), isPending: false }), + useSoloAccount: () => ({ mutate: vi.fn(), isPending: false }), + useBulkPauseAccounts: () => ({ mutate: vi.fn(), isPending: false }), + useBulkResumeAccounts: () => ({ mutate: vi.fn(), isPending: false }), + useDeleteVariant: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock('@/components/quick-setup-wizard', () => ({ + QuickSetupWizard: () => null, +})); + +vi.mock('@/components/monitoring/proxy-status-widget', () => ({ + ProxyStatusWidget: () => null, +})); + +vi.mock('@/components/account/account-safety-warning-card', () => ({ + AccountSafetyWarningCard: () => null, +})); + +vi.mock('@/components/cliproxy/provider-logo', () => ({ + ProviderLogo: () =>
provider-logo
, +})); + +vi.mock('@/components/cliproxy/provider-editor', () => ({ + ProviderEditor: ({ onAddAccount }: { onAddAccount: () => void }) => ( + + ), +})); + +vi.mock('@/components/account/add-account-dialog', () => ({ + AddAccountDialog: ({ open, catalog }: { open: boolean; catalog?: { defaultModel?: string } }) => + open ? ( +
+ ) : null, +})); + +import { CliproxyPage } from '@/pages/cliproxy'; + +describe('CliproxyPage add-account catalog gating', () => { + beforeEach(() => { + hookState.catalogData = undefined; + }); + + it('does not pass a static fallback catalog before the catalog query resolves', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'open-add-account' })); + + expect(screen.getByTestId('add-account-dialog')).toHaveAttribute('data-catalog', 'missing'); + }); + + it('passes the fetched provider catalog after the catalog query resolves', async () => { + hookState.catalogData = { + catalogs: { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + defaultModel: 'gemini-3.9-pro-preview', + models: [{ id: 'gemini-3.9-pro-preview', name: 'Gemini 3.9 Pro Preview' }], + }, + }, + }; + + render(); + + await userEvent.click(screen.getByRole('button', { name: 'open-add-account' })); + + expect(screen.getByTestId('add-account-dialog')).toHaveAttribute( + 'data-catalog', + 'gemini-3.9-pro-preview' + ); + }); +});