539 lines
16 KiB
TypeScript
539 lines
16 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import type { OAuthProvider } from '@/types/hermes'
|
|
|
|
import * as notifications from '@/store/notifications'
|
|
|
|
import {
|
|
$desktopOnboarding,
|
|
type DesktopOnboardingState,
|
|
type OnboardingContext,
|
|
refreshOnboarding,
|
|
requestDesktopOnboarding,
|
|
saveOnboardingLocalEndpoint,
|
|
submitOnboardingCode
|
|
} 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,
|
|
localEndpoint: 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 }
|
|
}
|
|
|
|
function fallbackTimeoutGateway(): OnboardingContext['requestGateway'] {
|
|
return async method => {
|
|
if (method === 'setup.status' || method === 'setup.runtime_check') {
|
|
throw new Error(`request timed out: ${method}`)
|
|
}
|
|
|
|
throw new Error(`unexpected gateway method: ${method}`)
|
|
}
|
|
}
|
|
|
|
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('does not downgrade configured=true on fallback-only readiness failures', 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)
|
|
// Simulate a returning user: cache is set and store is configured.
|
|
window.localStorage.setItem('hermes-desktop-onboarded-v1', '1')
|
|
$desktopOnboarding.set(
|
|
baseState({
|
|
configured: true,
|
|
providers: [provider('cached')],
|
|
reason: null,
|
|
requested: false
|
|
})
|
|
)
|
|
|
|
const ready = await refreshOnboarding(onboardingContext(fallbackTimeoutGateway()))
|
|
|
|
expect(ready).toBe(false)
|
|
expect(api).not.toHaveBeenCalled()
|
|
expect($desktopOnboarding.get().configured).toBe(true)
|
|
expect($desktopOnboarding.get().reason).toBeNull()
|
|
// The cache must survive the refresh — proving we didn't downgrade.
|
|
expect(window.localStorage.getItem('hermes-desktop-onboarded-v1')).toBe('1')
|
|
})
|
|
|
|
it('shows a non-blocking notification when preserving configured on fallback', async () => {
|
|
const notifySpy = vi.spyOn(notifications, 'notify')
|
|
|
|
installApiMock(vi.fn())
|
|
$desktopOnboarding.set(
|
|
baseState({
|
|
configured: true,
|
|
providers: [provider('cached')],
|
|
reason: null,
|
|
requested: false
|
|
})
|
|
)
|
|
|
|
await refreshOnboarding(onboardingContext(fallbackTimeoutGateway()))
|
|
|
|
expect(notifySpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: 'runtime-not-ready',
|
|
kind: 'error'
|
|
})
|
|
)
|
|
expect($desktopOnboarding.get().configured).toBe(true)
|
|
})
|
|
|
|
it('does not preserve configured 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({
|
|
configured: true,
|
|
providers: [provider('cached')],
|
|
reason: null,
|
|
requested: true
|
|
})
|
|
)
|
|
|
|
const ready = await refreshOnboarding(onboardingContext(fallbackTimeoutGateway()))
|
|
|
|
expect(ready).toBe(false)
|
|
// requested overrides preservation — should downgrade.
|
|
expect($desktopOnboarding.get().configured).toBe(false)
|
|
expect(api).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('still surfaces onboarding when fallback failure happens before configured state', 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({ configured: false, providers: null, requested: true }))
|
|
|
|
const ready = await refreshOnboarding(onboardingContext(fallbackTimeoutGateway()))
|
|
|
|
expect(ready).toBe(false)
|
|
expect(api).toHaveBeenCalledTimes(1)
|
|
expect($desktopOnboarding.get().configured).toBe(false)
|
|
expect($desktopOnboarding.get().reason).toContain('request timed out')
|
|
})
|
|
|
|
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('OAuth onboarding', () => {
|
|
beforeEach(() => {
|
|
window.localStorage.clear()
|
|
$desktopOnboarding.set(baseState())
|
|
})
|
|
|
|
afterEach(() => {
|
|
window.localStorage.clear()
|
|
$desktopOnboarding.set(baseState())
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('clears stale readiness errors after OAuth succeeds and model confirmation is shown', async () => {
|
|
const model = 'anthropic/claude-opus-4.8'
|
|
const calls: { body?: unknown; path: string }[] = []
|
|
|
|
installApiMock(async ({ body, path }: { body?: unknown; path: string }) => {
|
|
calls.push({ body, path })
|
|
|
|
if (path === '/api/providers/oauth/nous/submit') {
|
|
return { ok: true, status: 'approved' }
|
|
}
|
|
|
|
if (path === '/api/model/options') {
|
|
return {
|
|
providers: [
|
|
{
|
|
name: 'Nous Portal',
|
|
slug: 'nous',
|
|
models: [model]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
if (path.startsWith('/api/model/recommended-default?')) {
|
|
return { provider: 'nous', model, free_tier: false }
|
|
}
|
|
|
|
if (path === '/api/model/set') {
|
|
return { ok: true, provider: 'nous', model, gateway_tools: [] }
|
|
}
|
|
|
|
throw new Error(`unexpected api path: ${path}`)
|
|
})
|
|
|
|
const requestGateway: OnboardingContext['requestGateway'] = async (method, params) => {
|
|
if (method === 'reload.env') {
|
|
return {} as never
|
|
}
|
|
|
|
if (method === 'setup.status') {
|
|
return { provider_configured: true } as never
|
|
}
|
|
|
|
if (method === 'setup.runtime_check') {
|
|
expect(params).toEqual({ provider: 'nous' })
|
|
|
|
return { ok: true } as never
|
|
}
|
|
|
|
throw new Error(`unexpected gateway method: ${method}`)
|
|
}
|
|
|
|
$desktopOnboarding.set(
|
|
baseState({
|
|
flow: {
|
|
status: 'awaiting_user',
|
|
provider: provider('nous', 'Nous Portal'),
|
|
start: {
|
|
auth_url: 'https://portal.example/auth',
|
|
expires_in: 600,
|
|
flow: 'pkce',
|
|
session_id: 'portal-session'
|
|
},
|
|
code: 'fresh-code'
|
|
},
|
|
reason:
|
|
'No access token found for Nous Portal login. setup.status reports configured credentials, but runtime resolution still failed.',
|
|
requested: true
|
|
})
|
|
)
|
|
|
|
await submitOnboardingCode(onboardingContext(requestGateway))
|
|
|
|
const state = $desktopOnboarding.get()
|
|
expect(state.reason).toBeNull()
|
|
expect(state.flow.status).toBe('confirming_model')
|
|
|
|
if (state.flow.status === 'confirming_model') {
|
|
expect(state.flow.label).toBe('Nous Portal')
|
|
expect(state.flow.currentModel).toBe(model)
|
|
}
|
|
|
|
expect(calls.some(c => c.path === '/api/model/set')).toBe(true)
|
|
|
|
const optionsIndex = calls.findIndex(c => c.path === '/api/model/options')
|
|
const recommendedIndex = calls.findIndex(c => c.path.startsWith('/api/model/recommended-default'))
|
|
const setIndex = calls.findIndex(c => c.path === '/api/model/set')
|
|
|
|
expect(optionsIndex).toBeGreaterThanOrEqual(0)
|
|
expect(recommendedIndex).toBeGreaterThan(optionsIndex)
|
|
expect(setIndex).toBeGreaterThan(recommendedIndex)
|
|
})
|
|
})
|
|
|
|
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('forwards the API key to the probe and persists it for auth-gated endpoints', 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: ['gpt-oss-120b'] }
|
|
}
|
|
|
|
if (path === '/api/model/set') {
|
|
return { ok: true, provider: 'custom', model: 'gpt-oss-120b', base_url: 'https://text.example.com/v1' }
|
|
}
|
|
|
|
throw new Error(`unexpected api path: ${path}`)
|
|
})
|
|
|
|
installApiMock(api)
|
|
|
|
const result = await saveOnboardingLocalEndpoint('https://text.example.com/v1', 'sk-secret', {
|
|
requestGateway: readyGateway()
|
|
})
|
|
|
|
expect(result.ok).toBe(true)
|
|
|
|
// The probe must receive the key so an auth-gated /v1/models enumerates.
|
|
const probe = calls.find(c => c.path === '/api/providers/validate')
|
|
expect(probe?.body).toMatchObject({ key: 'OPENAI_BASE_URL', value: 'https://text.example.com/v1', api_key: 'sk-secret' })
|
|
|
|
// And the key must be persisted alongside the endpoint for runtime auth.
|
|
const assign = calls.find(c => c.path === '/api/model/set')
|
|
expect(assign?.body).toMatchObject({
|
|
scope: 'main',
|
|
provider: 'custom',
|
|
model: 'gpt-oss-120b',
|
|
base_url: 'https://text.example.com/v1',
|
|
api_key: 'sk-secret'
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|