154 lines
5.0 KiB
TypeScript
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([])
|
|
})
|
|
})
|