hermes-agent/apps/desktop/src/store/composer-status.test.ts

154 lines
5.0 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $backgroundStatusBySession, dismissBackgroundProcess, reconcileBackgroundProcesses } from './composer-status'
const SID = 'sess-1'
const running = (id: string, command = `cmd ${id}`) => ({ command, session_id: id, status: 'running' })
const exited = (id: string, exit_code = 0, command = `cmd ${id}`) => ({
command,
exit_code,
session_id: id,
status: 'exited'
})
const items = () => $backgroundStatusBySession.get()[SID] ?? []
describe('reconcileBackgroundProcesses', () => {
beforeEach(() => {
// Fake timers so the success self-clear (a real setTimeout) is deterministic
// and never leaks a pending timer between tests.
vi.useFakeTimers()
$backgroundStatusBySession.set({})
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
})
it('maps registry entries to status items', () => {
reconcileBackgroundProcesses(SID, [running('a'), exited('b', 0), exited('c', 1)])
expect(items().map(i => [i.id, i.state])).toEqual([
['a', 'running'],
['b', 'done'],
['c', 'failed']
])
expect(items()[2]!.exitCode).toBe(1)
})
it('keeps row order stable when a process flips state or the snapshot reorders', () => {
reconcileBackgroundProcesses(SID, [running('a'), running('b')])
// Snapshot arrives reordered AND `a` has exited — rows must not move.
reconcileBackgroundProcesses(SID, [running('b'), exited('a', 0)])
expect(items().map(i => [i.id, i.state])).toEqual([
['a', 'done'],
['b', 'running']
])
})
it('appends new processes after existing rows', () => {
reconcileBackgroundProcesses(SID, [running('a')])
reconcileBackgroundProcesses(SID, [running('b'), running('a')])
expect(items().map(i => i.id)).toEqual(['a', 'b'])
})
it('preserves object identity for unchanged rows (memo stability)', () => {
reconcileBackgroundProcesses(SID, [running('a'), running('b')])
const [a1] = items()
reconcileBackgroundProcesses(SID, [running('a'), exited('b', 0)])
const [a2, b2] = items()
expect(a2).toBe(a1)
expect(b2!.state).toBe('done')
})
it('is a no-op store write when nothing changed', () => {
reconcileBackgroundProcesses(SID, [running('a')])
const before = $backgroundStatusBySession.get()
reconcileBackgroundProcesses(SID, [running('a')])
expect($backgroundStatusBySession.get()).toBe(before)
})
it('never resurrects a dismissed process while the registry still reports it', () => {
reconcileBackgroundProcesses(SID, [exited('a', 0), running('b')])
dismissBackgroundProcess(SID, 'a')
reconcileBackgroundProcesses(SID, [exited('a', 0), running('b')])
expect(items().map(i => i.id)).toEqual(['b'])
})
it('forgets a dismissal once the registry prunes the process', () => {
reconcileBackgroundProcesses(SID, [exited('a', 0)])
dismissBackgroundProcess(SID, 'a')
// Registry pruned it…
reconcileBackgroundProcesses(SID, [])
// …so a future process reusing the id (new spawn) shows again.
reconcileBackgroundProcesses(SID, [running('a')])
expect(items().map(i => i.id)).toEqual(['a'])
})
it('drops the session key entirely when the last row goes away', () => {
reconcileBackgroundProcesses(SID, [running('a')])
reconcileBackgroundProcesses(SID, [])
expect($backgroundStatusBySession.get()).toEqual({})
})
// The self-clear path calls dismissBackgroundProcess, which records the id in
// the module-level dismissed set; use a fresh session per test so that record
// can't bleed into another test's reconcile.
const itemsOf = (sid: string) => $backgroundStatusBySession.get()[sid] ?? []
it('self-clears a finished success after a short linger', () => {
reconcileBackgroundProcesses('sess-clear', [exited('a', 0)])
expect(itemsOf('sess-clear').map(i => i.id)).toEqual(['a'])
vi.advanceTimersByTime(5_000)
expect(itemsOf('sess-clear')).toEqual([])
})
it('self-clears a failed task too, but only after a longer linger', () => {
reconcileBackgroundProcesses('sess-fail', [exited('a', 1)])
// Still visible after the success window — the failure gets a longer one so
// its exit code stays readable.
vi.advanceTimersByTime(5_000)
expect(itemsOf('sess-fail').map(i => [i.id, i.state])).toEqual([['a', 'failed']])
vi.advanceTimersByTime(10_000)
expect(itemsOf('sess-fail')).toEqual([])
})
it('never self-clears a still-running task', () => {
reconcileBackgroundProcesses('sess-run', [running('a')])
vi.advanceTimersByTime(60_000)
expect(itemsOf('sess-run').map(i => i.id)).toEqual(['a'])
})
it('arms the self-clear only once a task finishes', () => {
reconcileBackgroundProcesses('sess-arm', [running('a')])
vi.advanceTimersByTime(60_000)
// Still running after a minute — nothing scheduled yet.
expect(itemsOf('sess-arm').map(i => i.id)).toEqual(['a'])
reconcileBackgroundProcesses('sess-arm', [exited('a', 0)])
vi.advanceTimersByTime(5_000)
expect(itemsOf('sess-arm')).toEqual([])
})
})