Files
hermes-ui/tests/client/kanban-store.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

362 lines
17 KiB
TypeScript

// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const mockKanbanApi = vi.hoisted(() => ({
listBoards: vi.fn(),
createBoard: vi.fn(),
archiveBoard: vi.fn(),
getCapabilities: vi.fn(),
listTasks: vi.fn(),
getStats: vi.fn(),
getAssignees: vi.fn(),
createTask: vi.fn(),
completeTasks: vi.fn(),
blockTask: vi.fn(),
unblockTasks: vi.fn(),
assignTask: vi.fn(),
addComment: vi.fn(),
linkTasks: vi.fn(),
unlinkTasks: vi.fn(),
bulkUpdateTasks: vi.fn(),
getTaskLog: vi.fn(),
getDiagnostics: vi.fn(),
reclaimTask: vi.fn(),
reassignTask: vi.fn(),
specifyTask: vi.fn(),
dispatch: vi.fn(),
openKanbanEventStream: vi.fn(),
}))
vi.mock('@/api/hermes/kanban', () => mockKanbanApi)
import { KANBAN_SELECTED_BOARD_STORAGE_KEY, normalizeBoardSlug, useKanbanStore } from '@/stores/hermes/kanban'
describe('Kanban store', () => {
it('normalizes board slugs with canonical underscore, uppercase, and length rules', () => {
const sixtyFour = 'a'.repeat(64)
expect(normalizeBoardSlug(' Team_Alpha ')).toBe('team_alpha')
expect(normalizeBoardSlug(sixtyFour)).toBe(sixtyFour)
expect(normalizeBoardSlug('default')).toBe('default')
expect(normalizeBoardSlug('bad/slug')).toBe('default')
expect(normalizeBoardSlug('bad.slug')).toBe('default')
expect(normalizeBoardSlug('bad slug')).toBe('default')
})
beforeEach(() => {
window.localStorage.clear()
setActivePinia(createPinia())
vi.clearAllMocks()
mockKanbanApi.listBoards.mockResolvedValue([
{ slug: 'default', name: 'Default', archived: false, counts: {}, total: 0 },
{ slug: 'project-a', name: 'Project A', archived: false, counts: { todo: 1 }, total: 1 },
])
mockKanbanApi.getCapabilities.mockResolvedValue({ source: 'hermes-cli', supports: { boardsList: true }, missing: [] })
mockKanbanApi.openKanbanEventStream.mockReturnValue({ close: vi.fn(), onmessage: null, onclose: null, onerror: null })
})
it('persists selected board, including default, and falls back to default for missing boards', async () => {
const store = useKanbanStore()
await store.fetchBoards()
expect(store.setSelectedBoard('project-a')).toBe('project-a')
expect(window.localStorage.getItem(KANBAN_SELECTED_BOARD_STORAGE_KEY)).toBe('project-a')
expect(store.setSelectedBoard('default')).toBe('default')
expect(window.localStorage.getItem(KANBAN_SELECTED_BOARD_STORAGE_KEY)).toBe('default')
const recovered = store.recoverSelectedBoard('missing-board')
expect(recovered).toEqual({ board: 'default', recovered: true })
expect(store.selectedBoard).toBe('default')
expect(store.boardWarning).toContain('missing-board')
})
it('fetchTasks uses active filters and selected board while updating loading', async () => {
mockKanbanApi.listTasks.mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve([{ id: 'task-1', status: 'todo' }]), 0))
)
const store = useKanbanStore()
store.setSelectedBoard('project-a')
store.setFilter('status', 'blocked')
store.setFilter('assignee', 'alice')
const promise = store.fetchTasks()
expect(store.loading).toBe(true)
await promise
expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: 'blocked', assignee: 'alice', includeArchived: true })
expect(store.tasks).toEqual([{ id: 'task-1', status: 'todo' }])
expect(store.loading).toBe(false)
})
it('create and status actions pass selected board, update local task state, and refresh board counts', async () => {
mockKanbanApi.createTask.mockResolvedValue({ id: 'task-2', status: 'todo', assignee: null })
mockKanbanApi.completeTasks.mockResolvedValue({ ok: true })
mockKanbanApi.blockTask.mockResolvedValue({ ok: true })
mockKanbanApi.unblockTasks.mockResolvedValue({ ok: true })
mockKanbanApi.assignTask.mockResolvedValue({ ok: true })
mockKanbanApi.getStats.mockResolvedValue({ total: 2, by_status: { done: 1 }, by_assignee: {} })
mockKanbanApi.getAssignees.mockResolvedValue([{ name: 'bob', on_disk: true, counts: { ready: 1 } }])
const store = useKanbanStore()
store.setSelectedBoard('project-a')
store.tasks = [{ id: 'task-1', status: 'running', assignee: null }] as any
await store.createTask({ title: 'Ship' })
await store.completeTasks(['task-1'], 'done')
await store.blockTask('task-2', 'waiting')
await store.unblockTasks(['task-2'])
await store.assignTask('task-2', 'bob')
expect(mockKanbanApi.createTask).toHaveBeenCalledWith({ title: 'Ship' }, { board: 'project-a' })
expect(mockKanbanApi.completeTasks).toHaveBeenCalledWith(['task-1'], 'done', { board: 'project-a' })
expect(mockKanbanApi.blockTask).toHaveBeenCalledWith('task-2', 'waiting', { board: 'project-a' })
expect(mockKanbanApi.unblockTasks).toHaveBeenCalledWith(['task-2'], { board: 'project-a' })
expect(mockKanbanApi.assignTask).toHaveBeenCalledWith('task-2', 'bob', { board: 'project-a' })
expect(mockKanbanApi.listBoards).toHaveBeenCalledTimes(4)
expect(mockKanbanApi.getAssignees).toHaveBeenCalledWith({ board: 'project-a' })
expect(store.tasks[0]).toMatchObject({ id: 'task-2', status: 'ready', assignee: 'bob' })
expect(store.tasks[1]).toMatchObject({ id: 'task-1', status: 'done' })
})
it('uses capability metadata before calling parity APIs', async () => {
mockKanbanApi.getCapabilities.mockResolvedValue({
source: 'hermes-cli',
supports: { commentsWrite: true, dispatch: false },
missing: ['dispatch'],
})
mockKanbanApi.addComment.mockResolvedValue({ ok: true })
const store = useKanbanStore()
store.setSelectedBoard('project-a')
await store.fetchCapabilities()
expect(store.isCapabilitySupported('commentsWrite')).toBe(true)
expect(store.isCapabilitySupported('dispatch')).toBe(false)
await store.addComment('task-1', 'needs review', 'han')
await expect(store.dispatch({ dryRun: true })).rejects.toThrow('dispatch')
expect(mockKanbanApi.addComment).toHaveBeenCalledWith('task-1', { body: 'needs review', author: 'han' }, { board: 'project-a' })
expect(mockKanbanApi.dispatch).not.toHaveBeenCalled()
})
it('passes selected board to link and partial bulk parity actions', async () => {
mockKanbanApi.getCapabilities.mockResolvedValue({
source: 'hermes-cli',
supports: { links: true, bulk: false },
missing: ['bulk'],
capabilities: [
{ key: 'links', status: 'supported', requiresBoard: true },
{ key: 'bulk', status: 'partial', requiresBoard: true },
],
})
mockKanbanApi.linkTasks.mockResolvedValue({ ok: true })
mockKanbanApi.unlinkTasks.mockResolvedValue({ ok: true })
mockKanbanApi.bulkUpdateTasks.mockResolvedValue({ results: [{ id: 'task-1', ok: true }] })
mockKanbanApi.listTasks.mockResolvedValue([])
mockKanbanApi.getStats.mockResolvedValue({ total: 0, by_status: {}, by_assignee: {} })
mockKanbanApi.getAssignees.mockResolvedValue([])
const store = useKanbanStore()
store.setSelectedBoard('project-a')
await store.fetchCapabilities()
await store.linkTasks('task-1', 'task-2')
await store.unlinkTasks('task-1', 'task-2')
await expect(store.bulkUpdateTasks({ ids: ['task-1'], status: 'done', assignee: null, summary: 'closed' })).resolves.toEqual({ results: [{ id: 'task-1', ok: true }] })
expect(mockKanbanApi.linkTasks).toHaveBeenCalledWith({ parent_id: 'task-1', child_id: 'task-2' }, { board: 'project-a' })
expect(mockKanbanApi.unlinkTasks).toHaveBeenCalledWith({ parent_id: 'task-1', child_id: 'task-2' }, { board: 'project-a' })
expect(mockKanbanApi.bulkUpdateTasks).toHaveBeenCalledWith({ ids: ['task-1'], status: 'done', assignee: null, summary: 'closed' }, { board: 'project-a' })
expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: undefined, assignee: undefined, includeArchived: true })
})
it('opens board-scoped event streams, refreshes on events, and reconnects on board switch', async () => {
vi.useFakeTimers()
const socketA = { close: vi.fn(), onmessage: null as ((event: { data: string }) => void) | null, onclose: null, onerror: null }
const socketB = { close: vi.fn(), onmessage: null as ((event: { data: string }) => void) | null, onclose: null, onerror: null }
mockKanbanApi.openKanbanEventStream
.mockReturnValueOnce(socketA)
.mockReturnValueOnce(socketB)
mockKanbanApi.getCapabilities.mockResolvedValue({
source: 'hermes-cli',
supports: {},
missing: [],
capabilities: [{ key: 'events', status: 'partial', requiresBoard: true }],
})
mockKanbanApi.listTasks.mockResolvedValue([])
mockKanbanApi.getStats.mockResolvedValue({ total: 0, by_status: {}, by_assignee: {} })
mockKanbanApi.getAssignees.mockResolvedValue([])
const store = useKanbanStore()
store.setSelectedBoard('project-a')
await store.fetchCapabilities()
expect(store.startEventStream()).toBe(true)
expect(mockKanbanApi.openKanbanEventStream).toHaveBeenCalledWith({ board: 'project-a' })
socketA.onmessage?.({ data: JSON.stringify({ type: 'event', line: 'changed' }) })
await vi.advanceTimersByTimeAsync(100)
expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: undefined, assignee: undefined, includeArchived: true })
expect(mockKanbanApi.getStats).toHaveBeenCalledWith({ board: 'project-a' })
expect(mockKanbanApi.getAssignees).toHaveBeenCalledWith({ board: 'project-a' })
store.setSelectedBoard('default')
expect(socketA.close).toHaveBeenCalled()
expect(mockKanbanApi.openKanbanEventStream).toHaveBeenLastCalledWith({ board: 'default' })
store.stopEventStream()
expect(socketB.close).toHaveBeenCalled()
vi.useRealTimers()
})
it('passes selected board to parity actions and refreshes affected board state', async () => {
mockKanbanApi.getCapabilities.mockResolvedValue({
source: 'hermes-cli',
supports: { taskLog: true, diagnostics: true, reclaim: true, reassign: true, specify: true, dispatch: true },
missing: [],
})
mockKanbanApi.getTaskLog.mockResolvedValue({ task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false })
mockKanbanApi.getDiagnostics.mockResolvedValue([{ task_id: 'task-1' }])
mockKanbanApi.reclaimTask.mockResolvedValue({ ok: true })
mockKanbanApi.reassignTask.mockResolvedValue({ ok: true })
mockKanbanApi.specifyTask.mockResolvedValue([{ task_id: 'task-1' }])
mockKanbanApi.dispatch.mockResolvedValue({ spawned: 1 })
mockKanbanApi.getStats.mockResolvedValue({ total: 1, by_status: {}, by_assignee: {} })
mockKanbanApi.getAssignees.mockResolvedValue([{ name: 'bob', on_disk: true, counts: {} }])
mockKanbanApi.listTasks.mockResolvedValue([{ id: 'task-1', assignee: 'bob' }])
const store = useKanbanStore()
store.setSelectedBoard('project-a')
store.tasks = [{ id: 'task-1', status: 'running', assignee: 'alice' }] as any
await store.fetchCapabilities()
await expect(store.getTaskLog('task-1', 4000)).resolves.toEqual({ task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false })
await expect(store.getDiagnostics({ task: 'task-1', severity: 'warning' })).resolves.toEqual([{ task_id: 'task-1' }])
await store.reclaimTask('task-1', 'stale')
await store.reassignTask('task-1', 'bob', { reclaim: true, reason: 'handoff' })
await expect(store.specifyTask('task-1', 'han')).resolves.toEqual([{ task_id: 'task-1' }])
await expect(store.dispatch({ dryRun: true, max: 2, failureLimit: 3 })).resolves.toEqual({ spawned: 1 })
expect(mockKanbanApi.getTaskLog).toHaveBeenCalledWith('task-1', { board: 'project-a', tail: 4000 })
expect(mockKanbanApi.getDiagnostics).toHaveBeenCalledWith({ board: 'project-a', task: 'task-1', severity: 'warning' })
expect(mockKanbanApi.reclaimTask).toHaveBeenCalledWith('task-1', { board: 'project-a', reason: 'stale' })
expect(mockKanbanApi.reassignTask).toHaveBeenCalledWith('task-1', 'bob', { board: 'project-a', reclaim: true, reason: 'handoff' })
expect(mockKanbanApi.specifyTask).toHaveBeenCalledWith('task-1', { board: 'project-a', author: 'han' })
expect(mockKanbanApi.dispatch).toHaveBeenCalledWith({ board: 'project-a', dryRun: true, max: 2, failureLimit: 3 })
expect(store.tasks[0]).toMatchObject({ id: 'task-1', assignee: 'bob' })
})
it('creates and archives boards without relying on CLI current board', async () => {
mockKanbanApi.listBoards.mockResolvedValue([
{ slug: 'default', name: 'Default', archived: false, counts: {}, total: 0 },
{ slug: 'new-board', name: 'New Board', archived: false, counts: {}, total: 0 },
])
mockKanbanApi.createBoard.mockResolvedValue({ slug: 'new-board', name: 'New Board', archived: false, counts: {}, total: 0 })
mockKanbanApi.archiveBoard.mockResolvedValue({ ok: true })
mockKanbanApi.listTasks.mockResolvedValue([])
mockKanbanApi.getStats.mockResolvedValue({ total: 0, by_status: {}, by_assignee: {} })
mockKanbanApi.getAssignees.mockResolvedValue([])
const store = useKanbanStore()
await store.createBoard({ slug: 'new-board', name: 'New Board' })
expect(mockKanbanApi.createBoard).toHaveBeenCalledWith({ slug: 'new-board', name: 'New Board' })
expect(store.selectedBoard).toBe('new-board')
await store.archiveSelectedBoard()
expect(mockKanbanApi.archiveBoard).toHaveBeenCalledWith('new-board')
expect(store.selectedBoard).toBe('default')
})
it('refreshAll loads boards, tasks, stats, and assignees for the same board', async () => {
mockKanbanApi.listTasks.mockResolvedValue([{ id: 'task-1' }])
mockKanbanApi.getStats.mockResolvedValue({ total: 1, by_status: {}, by_assignee: {} })
mockKanbanApi.getAssignees.mockResolvedValue([{ name: 'alice', on_disk: true, counts: { todo: 1 } }])
const store = useKanbanStore()
store.setSelectedBoard('project-a')
await store.refreshAll()
expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: undefined, assignee: undefined, includeArchived: true })
expect(mockKanbanApi.getStats).toHaveBeenCalledWith({ board: 'project-a' })
expect(mockKanbanApi.getAssignees).toHaveBeenCalledWith({ board: 'project-a' })
expect(mockKanbanApi.listBoards).toHaveBeenCalledWith({ includeArchived: false })
expect(store.tasks).toEqual([{ id: 'task-1' }])
expect(store.stats).toEqual({ total: 1, by_status: {}, by_assignee: {} })
expect(store.assignees).toEqual([{ name: 'alice', on_disk: true, counts: { todo: 1 } }])
})
it('ignores stale board-list responses after a newer request', async () => {
let resolveSlowBoards: (value: unknown) => void = () => {}
mockKanbanApi.listBoards
.mockImplementationOnce(() => new Promise(resolve => { resolveSlowBoards = resolve }))
.mockResolvedValueOnce([
{ slug: 'default', name: 'Default', archived: false, counts: {}, total: 0 },
{ slug: 'project-a', name: 'Project A', archived: false, counts: { todo: 2 }, total: 2 },
])
const store = useKanbanStore()
store.setSelectedBoard('project-a')
const slowFetch = store.fetchBoards()
await store.fetchBoards()
resolveSlowBoards([{ slug: 'default', name: 'Default', archived: false, counts: {}, total: 0 }])
await slowFetch
expect(store.selectedBoard).toBe('project-a')
expect(store.activeBoards).toEqual(expect.arrayContaining([
expect.objectContaining({ slug: 'project-a', total: 2 }),
]))
})
it('ignores stale same-board fetch responses after a newer request', async () => {
let resolveSlow: (value: unknown) => void = () => {}
mockKanbanApi.listTasks
.mockImplementationOnce(() => new Promise(resolve => { resolveSlow = resolve }))
.mockResolvedValueOnce([{ id: 'new-filter-task' }])
const store = useKanbanStore()
store.setSelectedBoard('project-a')
const slowFetch = store.fetchTasks()
await store.fetchTasks()
resolveSlow([{ id: 'old-filter-task' }])
await slowFetch
expect(store.tasks).toEqual([{ id: 'new-filter-task' }])
})
it('does not leave loading stuck when a silent fetch supersedes a visible fetch', async () => {
let resolveVisible: (value: unknown) => void = () => {}
mockKanbanApi.listTasks
.mockImplementationOnce(() => new Promise(resolve => { resolveVisible = resolve }))
.mockResolvedValueOnce([{ id: 'silent-task' }])
const store = useKanbanStore()
const visibleFetch = store.fetchTasks()
expect(store.loading).toBe(true)
await store.fetchTasks(true)
resolveVisible([{ id: 'visible-task' }])
await visibleFetch
expect(store.tasks).toEqual([{ id: 'silent-task' }])
expect(store.loading).toBe(false)
})
it('ignores stale fetch responses after a board switch', async () => {
let resolveSlow: (value: unknown) => void = () => {}
mockKanbanApi.listTasks
.mockImplementationOnce(() => new Promise(resolve => { resolveSlow = resolve }))
.mockResolvedValueOnce([{ id: 'new-board-task' }])
const store = useKanbanStore()
store.setSelectedBoard('default')
const slowFetch = store.fetchTasks()
store.setSelectedBoard('project-a')
await store.fetchTasks()
resolveSlow([{ id: 'old-board-task' }])
await slowFetch
expect(store.tasks).toEqual([{ id: 'new-board-task' }])
})
})