import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OAuthProvider } from '@/types/hermes' import { $desktopOnboarding, type DesktopOnboardingState, type OnboardingContext, refreshOnboarding, requestDesktopOnboarding, saveOnboardingLocalEndpoint } from './onboarding' function provider(id: string, name = id): OAuthProvider { return { cli_command: `hermes login ${id}`, docs_url: `https://example.com/${id}`, flow: 'pkce', id, name, status: { logged_in: false } } } function baseState(overrides: Partial = {}): DesktopOnboardingState { return { configured: false, flow: { status: 'idle' }, mode: 'oauth', providers: null, reason: null, requested: false, firstRunSkipped: false, manual: false, ...overrides } } function installApiMock(api: (request: { path: string }) => Promise) { Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { api } }) } function runtimeMismatchGateway(): OnboardingContext['requestGateway'] { return async method => { if (method === 'setup.status') { return { provider_configured: true } as never } if (method === 'setup.runtime_check') { return { error: 'Selected runtime is not available.', ok: false } as never } throw new Error(`unexpected gateway method: ${method}`) } } function onboardingContext(requestGateway: OnboardingContext['requestGateway']): OnboardingContext { return { requestGateway } } describe('refreshOnboarding', () => { beforeEach(() => { window.localStorage.clear() $desktopOnboarding.set(baseState()) }) afterEach(() => { window.localStorage.clear() $desktopOnboarding.set(baseState()) vi.restoreAllMocks() }) it('refreshes OAuth providers again when onboarding was explicitly requested', async () => { const api = vi.fn(async ({ path }: { path: string }) => { if (path === '/api/providers/oauth') { return { providers: [provider('fresh')] } } throw new Error(`unexpected api path: ${path}`) }) installApiMock(api) $desktopOnboarding.set(baseState({ providers: [provider('cached')] })) requestDesktopOnboarding('Need provider setup') const ready = await refreshOnboarding(onboardingContext(runtimeMismatchGateway())) expect(ready).toBe(false) expect(api).toHaveBeenCalledTimes(1) expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['fresh']) expect($desktopOnboarding.get().reason).toContain('Selected runtime is not available.') expect($desktopOnboarding.get().reason).toContain('setup.status reports configured credentials') }) it('keeps cached providers when onboarding was not re-requested', async () => { const api = vi.fn(async ({ path }: { path: string }) => { if (path === '/api/providers/oauth') { return { providers: [provider('fresh')] } } throw new Error(`unexpected api path: ${path}`) }) installApiMock(api) $desktopOnboarding.set(baseState({ providers: [provider('cached')] })) const ready = await refreshOnboarding(onboardingContext(runtimeMismatchGateway())) expect(ready).toBe(false) expect(api).not.toHaveBeenCalled() expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['cached']) }) it('deduplicates concurrent provider refresh calls', async () => { let resolveProviders!: (value: { providers: OAuthProvider[] }) => void const providersPromise = new Promise<{ providers: OAuthProvider[] }>(resolve => { resolveProviders = value => { resolve(value) } }) const api = vi.fn(async ({ path }: { path: string }) => { if (path === '/api/providers/oauth') { return providersPromise } throw new Error(`unexpected api path: ${path}`) }) installApiMock(api) $desktopOnboarding.set(baseState({ requested: true })) const first = refreshOnboarding(onboardingContext(runtimeMismatchGateway())) const second = refreshOnboarding(onboardingContext(runtimeMismatchGateway())) await vi.waitFor(() => expect(api).toHaveBeenCalledTimes(1)) resolveProviders({ providers: [provider('shared')] }) await Promise.all([first, second]) expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['shared']) }) }) describe('saveOnboardingLocalEndpoint', () => { beforeEach(() => { window.localStorage.clear() $desktopOnboarding.set(baseState()) }) afterEach(() => { window.localStorage.clear() $desktopOnboarding.set(baseState()) vi.restoreAllMocks() }) function readyGateway(): OnboardingContext['requestGateway'] { return async method => { if (method === 'reload.env') { return {} as never } if (method === 'setup.status') { return { provider_configured: true } as never } if (method === 'setup.runtime_check') { return { ok: true } as never } throw new Error(`unexpected gateway method: ${method}`) } } it('errors when the endpoint advertises no models (nothing to route to)', async () => { const calls: string[] = [] installApiMock(async ({ path }: { path: string }) => { calls.push(path) if (path === '/api/providers/validate') { return { ok: true, reachable: true, message: '', models: [] } } throw new Error(`unexpected api path: ${path}`) }) const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', { requestGateway: readyGateway() }) expect(result.ok).toBe(false) expect(result.message).toContain('no models') // Must not attempt to persist an assignment without a model. expect(calls).not.toContain('/api/model/set') }) it('auto-discovers the model and persists provider=custom + base_url, then finishes', async () => { const calls: { body?: unknown; path: string }[] = [] const api = vi.fn(async ({ body, path }: { body?: unknown; path: string }) => { calls.push({ body, path }) if (path === '/api/providers/validate') { return { ok: true, reachable: true, message: '', models: ['llama-3.1-8b', 'qwen2.5-7b'] } } if (path === '/api/model/set') { return { ok: true, provider: 'custom', model: 'llama-3.1-8b', base_url: 'http://127.0.0.1:8000/v1' } } throw new Error(`unexpected api path: ${path}`) }) installApiMock(api) const onCompleted = vi.fn() const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', { onCompleted, requestGateway: readyGateway() }) expect(result.ok).toBe(true) const assign = calls.find(c => c.path === '/api/model/set') expect(assign?.body).toMatchObject({ scope: 'main', provider: 'custom', model: 'llama-3.1-8b', base_url: 'http://127.0.0.1:8000/v1' }) expect(onCompleted).toHaveBeenCalledTimes(1) expect($desktopOnboarding.get().configured).toBe(true) }) it('reports the runtime reason when resolution still fails after saving', async () => { installApiMock(async ({ path }: { path: string }) => { if (path === '/api/providers/validate') { return { ok: true, reachable: true, message: '', models: ['llama-3.1-8b'] } } if (path === '/api/model/set') { return { ok: true } } throw new Error(`unexpected api path: ${path}`) }) const failingGateway: OnboardingContext['requestGateway'] = async method => { if (method === 'reload.env') { return {} as never } if (method === 'setup.status') { return { provider_configured: false } as never } if (method === 'setup.runtime_check') { return { ok: false, error: 'No provider can serve the selected model.' } as never } throw new Error(`unexpected gateway method: ${method}`) } const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', { requestGateway: failingGateway }) expect(result.ok).toBe(false) expect(result.message).toContain('No provider can serve the selected model.') expect($desktopOnboarding.get().configured).not.toBe(true) }) })