Files
hermes-agent/apps/desktop/src/store/onboarding.test.ts
Teknium 9cc47b20cb feat(desktop): add 'choose provider later' skip to first-run onboarding (#39483)
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.
2026-06-04 19:40:54 -07:00

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)
})
})