The first-run provider picker was a hard gate — the only way out was connecting a provider. Add an 'I'll choose a provider later' link that dismisses the overlay and persists the skip to localStorage so it never re-nags on subsequent launches. Users connect a provider any time from Settings -> Providers (manual onboarding already bypasses the skip gate). - onboarding.ts: firstRunSkipped state seeded from localStorage (hermes-onboarding-skipped-v1) + dismissFirstRunOnboarding() action; completeDesktopOnboarding clears the flag once a provider connects. - overlay: skip gate (firstRunSkipped && !manual returns null); ChooseLaterLink rendered in both the OAuth picker footer and the API-key fallback, first-run only. - tests: skip persists + hidden in manual mode; full-state fixtures updated.
278 lines
8.1 KiB
TypeScript
278 lines
8.1 KiB
TypeScript
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> = {}): 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<unknown>) {
|
|
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)
|
|
})
|
|
})
|