hermes-agent/apps/desktop/src/store/updates.test.ts
brooklyn! 5cc4009deb
Merge pull request #52828 from helix4u/fix/desktop-backend-update-indicator
fix(desktop): show remote backend updates without counts
2026-06-26 11:49:07 -05:00

456 lines
14 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopUpdateStatus } from '@/global'
const storage = new Map<string, string>()
vi.mock('@/lib/storage', () => ({
persistBoolean: (key: string, value: boolean) => {
storage.set(key, String(value))
},
persistString: (key: string, value: null | string) => {
if (value === null) {
storage.delete(key)
} else {
storage.set(key, value)
}
},
storedBoolean: (key: string, fallback: boolean) => {
const value = storage.get(key)
return value === undefined ? fallback : value === 'true'
},
storedString: (key: string) => storage.get(key) ?? null
}))
const notifySpy = vi.fn()
const dismissSpy = vi.fn()
vi.mock('@/store/notifications', () => ({
notify: (...args: unknown[]) => notifySpy(...args),
dismissNotification: (...args: unknown[]) => dismissSpy(...args)
}))
const checkHermesUpdateSpy = vi.fn()
const updateHermesSpy = vi.fn()
const getActionStatusSpy = vi.fn()
vi.mock('@/hermes', () => ({
checkHermesUpdate: (...args: unknown[]) => checkHermesUpdateSpy(...args),
updateHermes: (...args: unknown[]) => updateHermesSpy(...args),
getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args)
}))
const {
maybeNotifyUpdateAvailable,
checkBackendUpdates,
$backendUpdateStatus,
applyBackendUpdate,
$backendUpdateApply,
reportBackendContract,
applyUpdates,
$updateApply,
$updateOverlayOpen,
resetUpdateApplyState
} = await import('./updates')
const { setConnection } = await import('./session')
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
supported: true,
behind: 3,
targetSha: 'sha-a',
fetchedAt: 0,
...over
})
const lastToast = () => notifySpy.mock.calls.at(-1)?.[0] as { onDismiss: () => void }
describe('maybeNotifyUpdateAvailable', () => {
beforeEach(() => {
storage.clear()
notifySpy.mockClear()
vi.useRealTimers()
})
it('shows when an update is available and not snoozed', () => {
maybeNotifyUpdateAvailable(status())
expect(notifySpy).toHaveBeenCalledTimes(1)
})
it('stays quiet for new commits once the toast was closed', () => {
maybeNotifyUpdateAvailable(status())
lastToast().onDismiss() // user closes it → cooldown starts
notifySpy.mockClear()
// A different commit lands while still within the cooldown window.
maybeNotifyUpdateAvailable(status({ targetSha: 'sha-b', behind: 9 }))
expect(notifySpy).not.toHaveBeenCalled()
})
it('re-shows once the cooldown elapses', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
maybeNotifyUpdateAvailable(status())
lastToast().onDismiss()
notifySpy.mockClear()
vi.setSystemTime(25 * 60 * 60 * 1000) // > 24h cooldown
maybeNotifyUpdateAvailable(status({ targetSha: 'sha-b' }))
expect(notifySpy).toHaveBeenCalledTimes(1)
})
it('does nothing when already up to date', () => {
maybeNotifyUpdateAvailable(status({ behind: 0 }))
expect(notifySpy).not.toHaveBeenCalled()
})
})
describe('reportBackendContract', () => {
beforeEach(() => {
storage.clear()
notifySpy.mockClear()
dismissSpy.mockClear()
vi.useRealTimers()
})
it('dismisses the toast when the backend meets the contract', () => {
reportBackendContract(2)
expect(dismissSpy).toHaveBeenCalledWith('backend-contract-skew')
expect(notifySpy).not.toHaveBeenCalled()
})
it('warns when the backend is behind (or reports no contract)', () => {
reportBackendContract(undefined)
expect(notifySpy).toHaveBeenCalledTimes(1)
reportBackendContract(1)
expect(notifySpy).toHaveBeenCalledTimes(2)
})
it('stays quiet on later session opens once the user closed it', () => {
reportBackendContract(1)
lastToast().onDismiss() // user closes it → cooldown starts
notifySpy.mockClear()
// Opening another pre-existing session re-runs the check within cooldown.
reportBackendContract(1)
expect(notifySpy).not.toHaveBeenCalled()
})
it('reminds again after the cooldown elapses', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
reportBackendContract(1)
lastToast().onDismiss()
notifySpy.mockClear()
vi.setSystemTime(25 * 60 * 60 * 1000) // > 24h cooldown
reportBackendContract(1)
expect(notifySpy).toHaveBeenCalledTimes(1)
})
it('clears the snooze once the backend catches up, so a regression warns again', () => {
reportBackendContract(1)
lastToast().onDismiss()
notifySpy.mockClear()
reportBackendContract(2) // backend updated → satisfied, snooze cleared
reportBackendContract(1) // a later regression must warn immediately
expect(notifySpy).toHaveBeenCalledTimes(1)
})
})
describe('checkBackendUpdates', () => {
beforeEach(() => {
storage.clear()
notifySpy.mockClear()
checkHermesUpdateSpy.mockReset()
$backendUpdateStatus.set(null)
vi.useRealTimers()
})
const setRemote = (on: boolean) =>
setConnection({
baseUrl: 'http://box:9119',
isFullscreen: false,
mode: on ? 'remote' : 'local',
nativeOverlayWidth: 0,
token: 't',
wsUrl: 'ws://box:9119',
logs: [],
windowButtonPosition: null
})
it('maps the backend /update/check onto the backend status, including commits', async () => {
setRemote(true)
checkHermesUpdateSpy.mockResolvedValue({
install_method: 'git',
current_version: '0.16.0',
behind: 2,
update_available: true,
can_apply: true,
update_command: 'hermes update',
message: null,
commits: [{ sha: 'abc1234', summary: 'feat: x', author: 'a', at: 1 }]
})
const result = await checkBackendUpdates()
expect(checkHermesUpdateSpy).toHaveBeenCalled()
expect(result?.behind).toBe(2)
expect(result?.updateAvailable).toBe(true)
expect(result?.commits?.[0]?.sha).toBe('abc1234')
expect(result?.supported).toBe(true)
expect($backendUpdateStatus.get()?.commits?.[0]?.summary).toBe('feat: x')
})
it('preserves backend update_available when the backend cannot count commits', async () => {
setRemote(true)
checkHermesUpdateSpy.mockResolvedValue({
install_method: 'nixos',
current_version: '0.16.0',
behind: -1,
update_available: true,
can_apply: false,
update_command: 'managed outside dashboard',
message: 'Update available.'
})
const result = await checkBackendUpdates()
expect(result?.behind).toBe(0)
expect(result?.updateAvailable).toBe(true)
expect(result?.targetSha).toBe('backend:0.16.0')
})
it('honours can_apply=false (docker/nix): not supported, carries message', async () => {
setRemote(true)
checkHermesUpdateSpy.mockResolvedValue({
install_method: 'docker',
current_version: '0.16.0',
behind: null,
update_available: false,
can_apply: false,
update_command: 'docker pull ...',
message: 'Docker images are immutable.'
})
const result = await checkBackendUpdates()
expect(result?.supported).toBe(false)
expect(result?.message).toBe('Docker images are immutable.')
})
it('is a no-op in local mode (backend check only runs when remote)', async () => {
setRemote(false)
await checkBackendUpdates()
expect(checkHermesUpdateSpy).not.toHaveBeenCalled()
})
})
describe('applyUpdates terminal state', () => {
const applyMock = vi.fn()
beforeEach(() => {
storage.clear()
notifySpy.mockClear()
dismissSpy.mockClear()
applyMock.mockReset()
resetUpdateApplyState()
$updateOverlayOpen.set(true)
;(globalThis as unknown as { window: unknown }).window = {
hermesDesktop: { updates: { apply: applyMock } }
}
vi.useRealTimers()
})
afterEach(() => {
delete (globalThis as unknown as { window?: unknown }).window
})
it('holds the restart view when a relauncher hands off (no close, no toast)', async () => {
applyMock.mockResolvedValue({ ok: true, handedOff: true })
const result = await applyUpdates()
expect(result.handedOff).toBe(true)
// The detached relauncher will quit + reopen us; keep "applying" until then.
expect($updateApply.get().applying).toBe(true)
expect($updateOverlayOpen.get()).toBe(true)
expect(notifySpy).not.toHaveBeenCalled()
})
it('closes the overlay + toasts when updated but not relaunched in place', async () => {
// The Linux AppImage / dev-run path: backend + GUI updated, no in-place
// relaunch. Must not strand the overlay on a closeless spinner.
applyMock.mockResolvedValue({ ok: true, backendUpdated: true })
await applyUpdates()
expect($updateOverlayOpen.get()).toBe(false)
expect($updateApply.get().applying).toBe(false)
expect($updateApply.get().stage).toBe('idle')
expect(notifySpy).toHaveBeenCalledTimes(1)
expect(notifySpy.mock.calls[0]?.[0]).toMatchObject({ kind: 'success' })
})
it('lands on a closeable error state when the apply resolves not-ok', async () => {
applyMock.mockResolvedValue({ ok: false, error: 'rebuild-failed', message: 'rebuild failed' })
await applyUpdates()
expect($updateApply.get().applying).toBe(false)
expect($updateApply.get().stage).toBe('error')
expect($updateApply.get().error).toBe('rebuild-failed')
})
it('keeps the manual command state for CLI installs with no staged updater', async () => {
applyMock.mockResolvedValue({ ok: true, manual: true, command: 'hermes update' })
await applyUpdates()
expect($updateApply.get().stage).toBe('manual')
expect($updateApply.get().command).toBe('hermes update')
expect($updateOverlayOpen.get()).toBe(true)
expect(notifySpy).not.toHaveBeenCalled()
})
it('lands on the guiSkew terminal state for a GUI/backend skew (AppImage/.deb/.rpm), without claiming a GUI update', async () => {
// Linux: backend updated, but the running desktop package was NOT replaced.
// Must NOT toast "loads next launch" — that's the dishonest message #45205
// guards against. Lands on a closeable guiSkew view instead.
applyMock.mockResolvedValue({
ok: true,
backendUpdated: true,
guiUpdated: false,
guiSkew: true,
message: 'Backend updated, but the desktop app package was not changed.'
})
const result = await applyUpdates()
expect(result.guiUpdated).toBe(false)
expect($updateApply.get().stage).toBe('guiSkew')
expect($updateApply.get().applying).toBe(false)
expect($updateApply.get().message).toMatch(/desktop app package was not changed/)
// Overlay stays open on a closeable terminal view; no "all set" toast.
expect($updateOverlayOpen.get()).toBe(true)
expect(notifySpy).not.toHaveBeenCalled()
})
it('lands on a closeable manual-restart state when the rebuilt sandbox blocks auto-relaunch', async () => {
// Under release/*-unpacked but chrome-sandbox isn't launchable: don't quit
// into a dead app — keep a working window on a closeable manual state.
applyMock.mockResolvedValue({
ok: true,
backendUpdated: true,
guiUpdated: false,
manualRestart: true,
sandboxBlocked: true,
message: 'Backend updated. Quit and reopen Hermes to finish.'
})
const result = await applyUpdates()
expect(result.manualRestart).toBe(true)
expect($updateApply.get().stage).toBe('manual')
expect($updateApply.get().command).toBeNull()
expect($updateApply.get().message).toMatch(/Quit and reopen/)
expect($updateOverlayOpen.get()).toBe(true)
expect(notifySpy).not.toHaveBeenCalled()
})
})
describe('applyBackendUpdate recovery', () => {
beforeEach(() => {
storage.clear()
checkHermesUpdateSpy.mockReset()
updateHermesSpy.mockReset()
getActionStatusSpy.mockReset()
$backendUpdateApply.set({
applying: false,
stage: 'idle',
message: '',
percent: null,
error: null,
command: null,
log: []
})
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('waits for the backend to return after the restart drops the connection, then clears the overlay', async () => {
updateHermesSpy.mockResolvedValue({ ok: true, name: 'update', pid: 1 })
getActionStatusSpy.mockRejectedValue(new Error('ECONNREFUSED'))
checkHermesUpdateSpy.mockResolvedValue({
install_method: 'git',
current_version: '0.16.0',
behind: 0,
update_available: false,
can_apply: true,
update_command: 'hermes update',
message: null
})
const promise = applyBackendUpdate()
await vi.advanceTimersByTimeAsync(5000)
const result = await promise
expect(result.ok).toBe(true)
expect($backendUpdateApply.get().stage).toBe('idle')
expect($backendUpdateApply.get().applying).toBe(false)
})
it('surfaces backend update action log lines while the action is running', async () => {
updateHermesSpy.mockResolvedValue({ ok: true, name: 'update', pid: 1 })
getActionStatusSpy
.mockResolvedValueOnce({
exit_code: null,
lines: ['Pulling updates...', 'Installing dependencies...'],
name: 'update',
pid: 1,
running: true
})
.mockRejectedValueOnce(new Error('ECONNREFUSED'))
checkHermesUpdateSpy.mockResolvedValue({
install_method: 'git',
current_version: '0.16.0',
behind: 0,
update_available: false,
can_apply: true,
update_command: 'hermes update',
message: null
})
const promise = applyBackendUpdate()
await vi.advanceTimersByTimeAsync(1500)
expect($backendUpdateApply.get().message).toBe('Installing dependencies...')
expect($backendUpdateApply.get().log.map(entry => entry.message)).toEqual([
'Pulling updates...',
'Installing dependencies...'
])
await vi.advanceTimersByTimeAsync(5000)
await promise
})
it('surfaces an error when the backend never comes back after the restart', async () => {
updateHermesSpy.mockResolvedValue({ ok: true, name: 'update', pid: 1 })
getActionStatusSpy.mockRejectedValue(new Error('ECONNREFUSED'))
checkHermesUpdateSpy.mockRejectedValue(new Error('ECONNREFUSED'))
const promise = applyBackendUpdate()
await vi.advanceTimersByTimeAsync(70000)
const result = await promise
expect(result.ok).toBe(false)
expect($backendUpdateApply.get().stage).toBe('error')
})
})