Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions apps/desktop/src/store/onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ 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,
Expand Down Expand Up @@ -63,6 +65,16 @@ function onboardingContext(requestGateway: OnboardingContext['requestGateway']):
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()
Expand Down Expand Up @@ -116,6 +128,108 @@ describe('refreshOnboarding', () => {
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

Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/store/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,16 @@ async function checkRuntime(ctx: OnboardingContext): Promise<RuntimeReadinessRes
})
}

function shouldPreserveConfiguredOnFallback(
runtime: RuntimeReadinessResult,
state: DesktopOnboardingState
): boolean {
// A fallback result means both runtime probes were non-authoritative
// (transport timeout/disconnect). Keep a previously verified configured
// state instead of forcing the blocking onboarding overlay.
return runtime.source === 'fallback' && state.configured === true && !state.requested
}

function notifyReady(provider: string) {
notify({ kind: 'success', title: 'Hermes is ready', message: `${provider} connected.` })
}
Expand Down Expand Up @@ -515,6 +525,21 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
}

const state = $desktopOnboarding.get()
if (shouldPreserveConfiguredOnFallback(runtime, state)) {
// Gateway probes timed out but the user was already configured — don't
// downgrade to the blocking onboarding overlay. Surface a non-blocking
// notification with a stable id so repeated calls during an outage dedup
// instead of stacking toasts.
notify({
id: 'runtime-not-ready',
kind: 'error',
title: 'Runtime not ready',
message: 'Hermes Desktop could not verify the running backend on startup. Some features may be unavailable until the gateway is reachable.'
})

return false
}

const reason = runtime.reason || state.reason || DEFAULT_ONBOARDING_REASON

writeCachedConfigured(false)
Expand Down
Loading