Files
hermes-agent/apps/desktop/src/store/coding-status.test.ts
T

288 lines
9.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesRepoStatus } from '@/global'
import {
$repoStatus,
$repoStatusByCwd,
$repoStatusLoading,
_resetCodingStatusForTests,
refreshAllRepoStatuses,
refreshRepoStatus,
registerRepoStatusCwd,
repoChangeKindForPath,
repoStatusForCwd
} from './coding-status'
import { $currentCwd, $selectedStoredSessionId } from './session'
const sampleStatus: HermesRepoStatus = {
branch: 'feature/login',
defaultBranch: 'main',
detached: false,
ahead: 1,
behind: 0,
staged: 1,
unstaged: 2,
untracked: 0,
conflicted: 0,
changed: 3,
added: 12,
removed: 4,
files: []
}
const otherStatus: HermesRepoStatus = {
...sampleStatus,
branch: 'bb/other-worktree',
added: 3,
removed: 1,
changed: 1,
staged: 0,
unstaged: 1
}
function stubProbe(impl: (cwd: string) => Promise<HermesRepoStatus | null>) {
;(window as unknown as { hermesDesktop?: unknown }).hermesDesktop = { git: { repoStatus: impl } }
}
describe('refreshRepoStatus', () => {
beforeEach(() => {
vi.useFakeTimers()
_resetCodingStatusForTests()
$currentCwd.set('')
$selectedStoredSessionId.set(null)
// Drain the cwd/session subscribe side-effects the sets above kick off.
vi.advanceTimersByTime(200)
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
_resetCodingStatusForTests()
})
afterEach(() => {
_resetCodingStatusForTests()
vi.clearAllTimers()
vi.useRealTimers()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
it('populates the per-cwd cache and the primary computed for that cwd', async () => {
stubProbe(async () => sampleStatus)
$currentCwd.set('/repo')
await refreshRepoStatus('/repo')
expect(repoStatusForCwd('/repo').get()).toEqual(sampleStatus)
expect($repoStatus.get()).toEqual(sampleStatus)
})
it('falls back to the active session cwd when none is passed', async () => {
const probe = vi.fn(async () => sampleStatus)
stubProbe(probe)
$currentCwd.set('/active/repo')
await refreshRepoStatus()
expect(probe).toHaveBeenCalledWith('/active/repo')
expect($repoStatus.get()).toEqual(sampleStatus)
})
it('leaves the cache alone (primary reads null) when there is no cwd', async () => {
stubProbe(async () => sampleStatus)
$currentCwd.set('/repo')
await refreshRepoStatus('/repo')
expect($repoStatus.get()).toEqual(sampleStatus)
// Blank target is a no-op: other worktrees' cached truth must stay put.
await refreshRepoStatus(' ')
expect(repoStatusForCwd('/repo').get()).toEqual(sampleStatus)
$currentCwd.set('')
expect($repoStatus.get()).toBeNull()
})
it('clears every cached status when the probe is unavailable (remote backend)', async () => {
$currentCwd.set('/repo')
$repoStatusByCwd.set({ '/repo': sampleStatus, '/other': otherStatus })
await refreshRepoStatus('/repo')
expect($repoStatusByCwd.get()).toEqual({})
expect($repoStatus.get()).toBeNull()
})
it('clears only the failing cwd when the probe throws', async () => {
stubProbe(async cwd => {
if (cwd === '/bad') {
throw new Error('not a repo')
}
return sampleStatus
})
$currentCwd.set('/bad')
$repoStatusByCwd.set({ '/bad': otherStatus, '/good': sampleStatus })
await refreshRepoStatus('/bad')
expect(repoStatusForCwd('/bad').get()).toBeNull()
expect(repoStatusForCwd('/good').get()).toEqual(sampleStatus)
expect($repoStatus.get()).toBeNull()
})
it('never publishes an old worktree status onto the primary after the active cwd moves', async () => {
let resolveOld!: (status: HermesRepoStatus | null) => void
stubProbe(
() =>
new Promise(resolve => {
resolveOld = resolve
})
)
// Explicit probe (not the debounced cwd edge) so the hang is fully under
// our control — same race window the coding rail used to hit after a
// session switch mid-probe.
const inflight = refreshRepoStatus('/repo-a')
$currentCwd.set('/repo-a')
await Promise.resolve()
$currentCwd.set('/repo-b')
expect($repoStatus.get()).toBeNull()
resolveOld(sampleStatus)
await inflight
// Primary follows the NEW cwd (empty). The old worktree may still cache the
// late result under its own key — that's what lets a tile rail paint
// instantly — but it must not leak onto the main rail via $repoStatus.
expect($repoStatus.get()).toBeNull()
expect(repoStatusForCwd('/repo-a').get()).toEqual(sampleStatus)
})
it('keeps independent statuses for two live worktrees', async () => {
stubProbe(async cwd => (cwd === '/repo-a' ? sampleStatus : otherStatus))
await refreshRepoStatus('/repo-a')
await refreshRepoStatus('/repo-b')
expect(repoStatusForCwd('/repo-a').get()).toEqual(sampleStatus)
expect(repoStatusForCwd('/repo-b').get()).toEqual(otherStatus)
$currentCwd.set('/repo-a')
expect($repoStatus.get()).toEqual(sampleStatus)
$currentCwd.set('/repo-b')
expect($repoStatus.get()).toEqual(otherStatus)
})
it('runs one probe at a time and coalesces overlap into one trailing refresh per drain', async () => {
const resolvers: Array<(status: HermesRepoStatus | null) => void> = []
const calls: string[] = []
let active = 0
let maxActive = 0
stubProbe(
cwd =>
new Promise(resolve => {
calls.push(cwd)
active++
maxActive = Math.max(maxActive, active)
resolvers.push(status => {
active--
resolve(status)
})
})
)
$currentCwd.set('/repo-c')
const first = refreshRepoStatus('/repo-a')
const second = refreshRepoStatus('/repo-b')
const third = refreshRepoStatus('/repo-c')
expect(calls).toEqual(['/repo-a'])
expect(maxActive).toBe(1)
expect($repoStatusLoading.get()).toBe(true)
resolvers.shift()?.(sampleStatus)
await Promise.resolve()
await Promise.resolve()
expect(maxActive).toBe(1)
expect(calls.length).toBe(2)
resolvers.shift()?.(otherStatus)
await Promise.resolve()
await Promise.resolve()
resolvers.shift()?.(sampleStatus)
await Promise.all([first, second, third])
expect(maxActive).toBe(1)
expect(calls).toEqual(['/repo-a', '/repo-b', '/repo-c'])
expect(repoStatusForCwd('/repo-a').get()).toEqual(sampleStatus)
expect(repoStatusForCwd('/repo-b').get()).toEqual(otherStatus)
expect(repoStatusForCwd('/repo-c').get()).toEqual(sampleStatus)
expect($repoStatus.get()).toEqual(sampleStatus)
expect($repoStatusLoading.get()).toBe(false)
})
it('refreshes when the stored session id changes even if the cwd is unchanged', async () => {
const probe = vi.fn(async () => sampleStatus)
stubProbe(probe)
$currentCwd.set('/repo')
$selectedStoredSessionId.set('session-a')
// The cwd subscription fires on the set above; drain the debounced refresh.
vi.advanceTimersByTime(200)
await vi.runAllTicks()
probe.mockClear()
// Switch to a different session in the SAME repo dir. The cwd atom value is
// identical, so its subscription would not re-fire — but the stored-session
// id did change, which must still trigger a probe so the branch label
// tracks the new session's checked-out branch.
$selectedStoredSessionId.set('session-b')
vi.advanceTimersByTime(200)
await vi.runAllTicks()
expect(probe).toHaveBeenCalledWith('/repo')
})
it('registerRepoStatusCwd keeps that worktree in unscoped refreshes', async () => {
const probe = vi.fn(async cwd => (cwd === '/tile' ? otherStatus : sampleStatus))
stubProbe(probe)
$currentCwd.set('/main')
const release = registerRepoStatusCwd('/tile')
// Drain the register kick + cwd edge so the assert only covers the
// unscoped fan-out.
vi.advanceTimersByTime(200)
await refreshAllRepoStatuses()
probe.mockClear()
// Unscoped fan-out: every registered worktree + primary, not main only.
await refreshAllRepoStatuses()
const probed = [...new Set(probe.mock.calls.map(call => call[0]))].sort()
expect(probed).toEqual(['/main', '/tile'])
expect(repoStatusForCwd('/tile').get()).toEqual(otherStatus)
expect(repoStatusForCwd('/main').get()).toEqual(sampleStatus)
release?.()
})
})
describe('repoChangeKindForPath', () => {
it('does not notify a row when only another path changes', () => {
$currentCwd.set('/repo')
$repoStatusByCwd.set({ '/repo': { ...sampleStatus, files: [] } })
const row = repoChangeKindForPath('/repo/a.ts')
const listener = vi.fn()
const unsubscribe = row.subscribe(listener)
$repoStatusByCwd.set({
'/repo': {
...sampleStatus,
files: [{ path: 'b.ts', untracked: true } as HermesRepoStatus['files'][number]]
}
})
expect(listener).toHaveBeenCalledTimes(1)
$repoStatusByCwd.set({
'/repo': {
...sampleStatus,
files: [{ path: 'a.ts', untracked: true } as HermesRepoStatus['files'][number]]
}
})
expect(listener).toHaveBeenCalledTimes(2)
expect(listener.mock.calls.at(-1)?.[0]).toBe('added')
unsubscribe()
})
})