2026-05-08 11:32:47 +08:00
import { beforeEach , describe , expect , it , vi } from 'vitest'
const mockExecFileAsync = vi . hoisted (() => vi . fn ())
const mockLoggerError = vi . hoisted (() => vi . fn ())
vi . mock ( 'util' , () => ({
promisify : () => mockExecFileAsync ,
}))
vi . mock ( '../../packages/server/src/services/logger' , () => ({
logger : {
error : mockLoggerError ,
},
}))
import * as service from '../../packages/server/src/services/hermes/hermes-kanban'
describe ( 'hermes kanban service' , () => {
beforeEach (() => {
vi . clearAllMocks ()
})
2026-05-10 13:58:44 +02:00
it ( 'lists boards without mutating or depending on CLI current' , async () => {
mockExecFileAsync . mockResolvedValueOnce ({ stdout : JSON.stringify ([{ slug : 'default' }]) })
await expect ( service . listBoards ({ includeArchived : true })). resolves . toEqual ([{ slug : 'default' }])
expect ( mockExecFileAsync . mock . calls [ 0 ][ 1 ]). toEqual ([ 'kanban' , 'boards' , 'list' , '--json' , '--all' ])
})
it ( 'creates and archives boards through canonical CLI board commands' , async () => {
mockExecFileAsync
. mockResolvedValueOnce ({ stdout : '' })
. mockResolvedValueOnce ({ stdout : JSON.stringify ([{ slug : 'project-a' , name : 'Project A' }]) })
. mockResolvedValueOnce ({ stdout : '' })
await expect ( service . createBoard ({ slug : 'project-a' , name : 'Project A' , description : 'desc' , icon : '📌' , color : '#8b5cf6' , switchCurrent : true })). resolves . toEqual ({ slug : 'project-a' , name : 'Project A' })
await expect ( service . archiveBoard ( 'project-a' )). resolves . toBeUndefined ()
expect ( mockExecFileAsync . mock . calls [ 0 ][ 1 ]). toEqual ([ 'kanban' , 'boards' , 'create' , 'project-a' , '--name' , 'Project A' , '--description' , 'desc' , '--icon' , '📌' , '--color' , '#8b5cf6' , '--switch' ])
expect ( mockExecFileAsync . mock . calls [ 1 ][ 1 ]). toEqual ([ 'kanban' , 'boards' , 'list' , '--json' , '--all' ])
expect ( mockExecFileAsync . mock . calls [ 2 ][ 1 ]). toEqual ([ 'kanban' , 'boards' , 'rm' , 'project-a' ])
})
it ( 'exposes capability metadata for WUI/canonical parity gaps' , async () => {
await expect ( service . getCapabilities ()). resolves . toMatchObject ({
source : 'hermes-cli' ,
2026-05-13 01:32:38 +02:00
supports : { boardsList : true , boardCreate : true , commentsWrite : true , dispatch : true , links : true },
missing : expect.arrayContaining ([ 'cliCurrentSwitch' , 'bulk' , 'homeSubscriptions' ]),
2026-05-11 15:26:24 +02:00
capabilities : expect.arrayContaining ([
expect . objectContaining ({ key : 'commentsWrite' , status : 'supported' , canonicalCommand : 'comment' , requiresBoard : true }),
2026-05-13 01:32:38 +02:00
expect . objectContaining ({ key : 'links' , status : 'supported' , canonicalRoute : '/links' , canonicalCommand : 'link/unlink' , requiresBoard : true }),
expect . objectContaining ({ key : 'bulk' , status : 'partial' , canonicalRoute : '/tasks/bulk' , requiresBoard : true }),
expect . objectContaining ({ key : 'events' , status : 'partial' , canonicalRoute : '/events' , canonicalCommand : 'watch' , requiresBoard : true }),
2026-05-11 15:26:24 +02:00
]),
2026-05-10 13:58:44 +02:00
})
})
2026-05-13 01:32:38 +02:00
it ( 'builds board-scoped watch args for the kanban event bridge' , () => {
expect ( service . buildWatchArgs ({ board : 'Project_A' , interval : 0.25 })). toEqual ([ 'kanban' , '--board' , 'project_a' , 'watch' , '--interval' , '0.25' ])
expect ( service . buildWatchArgs ()). toEqual ([ 'kanban' , '--board' , 'default' , 'watch' , '--interval' , '0.5' ])
})
it ( 'builds link/unlink and bulk-equivalent task commands with explicit board' , async () => {
mockExecFileAsync
. mockResolvedValueOnce ({ stdout : 'linked\n' })
. mockResolvedValueOnce ({ stdout : 'unlinked\n' })
. mockResolvedValueOnce ({ stdout : '' })
. mockResolvedValueOnce ({ stdout : '' })
. mockRejectedValueOnce ( new Error ( 'cannot complete task-2' ))
await expect ( service . linkTasks ( 'task-1' , 'task-2' , { board : 'project-a' })). resolves . toEqual ({ ok : true , output : 'linked\n' })
await expect ( service . unlinkTasks ( 'task-1' , 'task-2' , { board : 'project-a' })). resolves . toEqual ({ ok : true , output : 'unlinked\n' })
await expect ( service . bulkUpdateTasks ({ board : 'project-a' , ids : [ 'task-1' , 'task-2' ], status : 'done' , assignee : 'alice' , summary : 'closed' })). resolves . toEqual ({
results : [
{ id : 'task-1' , ok : true },
{ id : 'task-2' , ok : false , error : 'Failed to complete kanban tasks: cannot complete task-2' },
],
})
expect ( mockExecFileAsync . mock . calls [ 0 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'project-a' , 'link' , 'task-1' , 'task-2' ])
expect ( mockExecFileAsync . mock . calls [ 1 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'project-a' , 'unlink' , 'task-1' , 'task-2' ])
expect ( mockExecFileAsync . mock . calls [ 2 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'project-a' , 'complete' , 'task-1' , '--summary' , 'closed' ])
expect ( mockExecFileAsync . mock . calls [ 3 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'project-a' , 'assign' , 'task-1' , 'alice' ])
expect ( mockExecFileAsync . mock . calls [ 4 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'project-a' , 'complete' , 'task-2' , '--summary' , 'closed' ])
})
it ( 'treats zero-exit stderr from mutation CLI calls as failures' , async () => {
mockExecFileAsync
. mockResolvedValueOnce ({ stdout : '' , stderr : 'kanban: unknown task(s): missing-a, missing-b\n' })
. mockResolvedValueOnce ({ stdout : '' , stderr : 'No such link: missing-a -> missing-b\n' })
. mockResolvedValueOnce ({ stdout : '' , stderr : 'kanban: unknown task(s): task-1\n' })
. mockResolvedValueOnce ({ stdout : '' , stderr : 'kanban: unknown task(s): task-2\n' })
await expect ( service . linkTasks ( 'missing-a' , 'missing-b' , { board : 'project-a' })). rejects . toThrow ( 'Failed to link kanban tasks: kanban: unknown task(s): missing-a, missing-b' )
await expect ( service . unlinkTasks ( 'missing-a' , 'missing-b' , { board : 'project-a' })). rejects . toThrow ( 'Failed to unlink kanban tasks: No such link: missing-a -> missing-b' )
await expect ( service . bulkUpdateTasks ({ board : 'project-a' , ids : [ 'task-1' , 'task-2' ], status : 'done' })). resolves . toEqual ({
results : [
{ id : 'task-1' , ok : false , error : 'Failed to complete kanban tasks: kanban: unknown task(s): task-1' },
{ id : 'task-2' , ok : false , error : 'Failed to complete kanban tasks: kanban: unknown task(s): task-2' },
],
})
})
it ( 'returns per-task bulk errors for unsupported direct status patches before shelling out' , async () => {
await expect ( service . bulkUpdateTasks ({ board : 'project-a' , ids : [ 'task-1' ], status : 'running' })). resolves . toEqual ({
results : [{ id : 'task-1' , ok : false , error : 'Bulk status running is not supported by the CLI bridge' }],
})
expect ( mockExecFileAsync ). not . toHaveBeenCalled ()
})
2026-05-11 15:26:24 +02:00
it ( 'builds comment/log/diagnostics commands with explicit board' , async () => {
mockExecFileAsync
. mockResolvedValueOnce ({ stdout : 'comment added\n' })
. mockResolvedValueOnce ({ stdout : 'worker log\n' })
. mockResolvedValueOnce ({ stdout : JSON.stringify ([{ task_id : 'task-1' , severity : 'warning' }]) })
await expect ( service . addComment ( 'task-1' , '--not-an-option' , { board : 'default' , author : 'han' })). resolves . toEqual ({ ok : true , output : 'comment added\n' })
await expect ( service . getTaskLog ( 'task-1' , { board : 'default' , tail : 4000 })). resolves . toEqual ({ task_id : 'task-1' , path : null , exists : true , size_bytes : 11 , content : 'worker log\n' , truncated : false })
await expect ( service . getDiagnostics ({ board : 'default' , task : 'task-1' , severity : 'warning' })). resolves . toEqual ([{ task_id : 'task-1' , severity : 'warning' }])
expect ( mockExecFileAsync . mock . calls [ 0 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'default' , 'comment' , 'task-1' , '--not-an-option' , '--author' , 'han' ])
expect ( mockExecFileAsync . mock . calls [ 1 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'default' , 'log' , 'task-1' , '--tail' , '4000' ])
expect ( mockExecFileAsync . mock . calls [ 2 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'default' , 'diagnostics' , '--json' , '--task' , 'task-1' , '--severity' , 'warning' ])
})
it ( 'maps no-log task logs to canonical empty-log shape' , async () => {
mockExecFileAsync
. mockRejectedValueOnce ({ code : 1 , stderr : '(no log for task-1 — task may not have spawned yet)' })
. mockResolvedValueOnce ({ stdout : JSON.stringify ({ task : { id : 'task-1' }, runs : [], comments : [], events : [] }) })
await expect ( service . getTaskLog ( 'task-1' , { board : 'default' })). resolves . toEqual ({
task_id : 'task-1' ,
path : null ,
exists : false ,
size_bytes : 0 ,
content : '' ,
truncated : false ,
})
expect ( mockExecFileAsync . mock . calls [ 0 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'default' , 'log' , 'task-1' ])
expect ( mockExecFileAsync . mock . calls [ 1 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'default' , 'show' , 'task-1' , '--json' ])
})
it ( 'builds recovery and dispatch commands with explicit board' , async () => {
mockExecFileAsync
. mockResolvedValueOnce ({ stdout : 'reclaimed\n' })
. mockResolvedValueOnce ({ stdout : 'reassigned\n' })
. mockResolvedValueOnce ({ stdout : '{"task_id":"task-1","created":true}\n' })
. mockResolvedValueOnce ({ stdout : JSON.stringify ({ spawned : 1 }) })
await expect ( service . reclaimTask ( 'task-1' , { board : 'project-a' , reason : 'stale lock' })). resolves . toEqual ({ ok : true , output : 'reclaimed\n' })
await expect ( service . reassignTask ( 'task-1' , 'bob' , { board : 'project-a' , reclaim : true , reason : 'handoff' })). resolves . toEqual ({ ok : true , output : 'reassigned\n' })
await expect ( service . specifyTask ( 'task-1' , { board : 'project-a' , author : 'han' })). resolves . toEqual ([{ task_id : 'task-1' , created : true }])
await expect ( service . dispatch ({ board : 'project-a' , dryRun : true , max : 2 , failureLimit : 3 })). resolves . toEqual ({ spawned : 1 })
expect ( mockExecFileAsync . mock . calls [ 0 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'project-a' , 'reclaim' , 'task-1' , '--reason' , 'stale lock' ])
expect ( mockExecFileAsync . mock . calls [ 1 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'project-a' , 'reassign' , 'task-1' , 'bob' , '--reclaim' , '--reason' , 'handoff' ])
expect ( mockExecFileAsync . mock . calls [ 2 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'project-a' , 'specify' , 'task-1' , '--json' , '--author' , 'han' ])
expect ( mockExecFileAsync . mock . calls [ 3 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'project-a' , 'dispatch' , '--json' , '--dry-run' , '--max' , '2' , '--failure-limit' , '3' ])
})
2026-05-10 13:58:44 +02:00
it ( 'builds list/create/stats CLI calls with global --board before the action' , async () => {
2026-05-08 11:32:47 +08:00
mockExecFileAsync
. mockResolvedValueOnce ({ stdout : JSON.stringify ([{ id : 'task-1' }]) })
. mockResolvedValueOnce ({ stdout : JSON.stringify ({ id : 'task-2' }) })
. mockResolvedValueOnce ({ stdout : JSON.stringify ({ total : 1 , by_status : {}, by_assignee : {} }) })
2026-05-11 15:09:58 +02:00
. mockResolvedValueOnce ({ stdout : JSON.stringify ([{ id : 'archived-1' , status : 'archived' }, { id : 'archived-2' , status : 'archived' }]) })
2026-05-08 11:32:47 +08:00
2026-05-11 15:09:58 +02:00
await expect ( service . listTasks ({ board : 'project-a' , status : 'todo' , assignee : 'alice' , tenant : 'ops' , includeArchived : true })). resolves . toEqual ([{ id : 'task-1' }])
2026-05-10 13:58:44 +02:00
await expect ( service . createTask ( 'Ship' , { board : 'project-a' , body : 'write' , assignee : 'alice' , priority : 3 , tenant : 'ops' })). resolves . toEqual ({ id : 'task-2' })
2026-05-11 15:09:58 +02:00
await expect ( service . getStats ({ board : 'project-a' })). resolves . toEqual ({ total : 3 , by_status : { archived : 2 }, by_assignee : {} })
2026-05-08 11:32:47 +08:00
2026-05-11 15:09:58 +02:00
expect ( mockExecFileAsync . mock . calls [ 0 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'project-a' , 'list' , '--json' , '--archived' , '--status' , 'todo' , '--assignee' , 'alice' , '--tenant' , 'ops' ])
2026-05-10 13:58:44 +02:00
expect ( mockExecFileAsync . mock . calls [ 1 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'project-a' , 'create' , 'Ship' , '--json' , '--body' , 'write' , '--assignee' , 'alice' , '--priority' , '3' , '--tenant' , 'ops' ])
expect ( mockExecFileAsync . mock . calls [ 2 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'project-a' , 'stats' , '--json' ])
2026-05-11 15:09:58 +02:00
expect ( mockExecFileAsync . mock . calls [ 3 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'project-a' , 'list' , '--json' , '--archived' , '--status' , 'archived' ])
2026-05-10 13:58:44 +02:00
})
it ( 'normalizes omitted board to default instead of falling through to CLI current' , async () => {
mockExecFileAsync
. mockResolvedValueOnce ({ stdout : JSON.stringify ([]) })
. mockResolvedValueOnce ({ stdout : JSON.stringify ({ total : 0 , by_status : {}, by_assignee : {} }) })
2026-05-11 15:09:58 +02:00
. mockResolvedValueOnce ({ stdout : JSON.stringify ([]) })
2026-05-10 13:58:44 +02:00
await service . listTasks ()
await service . getStats ()
expect ( mockExecFileAsync . mock . calls [ 0 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'default' , 'list' , '--json' ])
expect ( mockExecFileAsync . mock . calls [ 1 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'default' , 'stats' , '--json' ])
2026-05-11 15:09:58 +02:00
expect ( mockExecFileAsync . mock . calls [ 2 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'default' , 'list' , '--json' , '--archived' , '--status' , 'archived' ])
2026-05-08 11:32:47 +08:00
})
it ( 'builds action CLI calls and maps not-found show to null' , async () => {
mockExecFileAsync
. mockRejectedValueOnce ({ code : 1 })
. mockResolvedValueOnce ({})
. mockResolvedValueOnce ({})
. mockResolvedValueOnce ({})
. mockResolvedValueOnce ({})
. mockResolvedValueOnce ({ stdout : JSON.stringify ([{ name : 'alice' }]) })
2026-05-10 13:58:44 +02:00
await expect ( service . getTask ( 'missing' , { board : 'default' })). resolves . toBeNull ()
await service . completeTasks ([ 'task-1' ], 'done' , { board : 'default' })
await service . blockTask ( 'task-1' , 'wait' , { board : 'default' })
await service . unblockTasks ([ 'task-1' ], { board : 'default' })
await service . assignTask ( 'task-1' , 'alice' , { board : 'default' })
await expect ( service . getAssignees ({ board : 'default' })). resolves . toEqual ([{ name : 'alice' }])
2026-05-08 11:32:47 +08:00
2026-05-10 13:58:44 +02:00
expect ( mockExecFileAsync . mock . calls [ 0 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'default' , 'show' , 'missing' , '--json' ])
expect ( mockExecFileAsync . mock . calls [ 1 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'default' , 'complete' , 'task-1' , '--summary' , 'done' ])
expect ( mockExecFileAsync . mock . calls [ 2 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'default' , 'block' , 'task-1' , 'wait' ])
expect ( mockExecFileAsync . mock . calls [ 3 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'default' , 'unblock' , 'task-1' ])
expect ( mockExecFileAsync . mock . calls [ 4 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'default' , 'assign' , 'task-1' , 'alice' ])
expect ( mockExecFileAsync . mock . calls [ 5 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'default' , 'assignees' , '--json' ])
})
it ( 'rejects invalid board slugs before shelling out' , async () => {
await expect ( service . listTasks ({ board : 'bad;slug' })). rejects . toThrow ( 'Invalid kanban board slug' )
expect ( mockExecFileAsync ). not . toHaveBeenCalled ()
2026-05-08 11:32:47 +08:00
})
2026-05-11 15:26:24 +02:00
it ( 'normalizes board slugs using canonical upstream-compatible rules' , async () => {
const sixtyFourChars = 'a' . repeat ( 64 )
mockExecFileAsync
. mockResolvedValueOnce ({ stdout : JSON.stringify ([]) })
. mockResolvedValueOnce ({ stdout : JSON.stringify ([]) })
. mockResolvedValueOnce ({ stdout : JSON.stringify ([]) })
await service . listTasks ({ board : 'Team_Alpha' })
await service . listTasks ({ board : sixtyFourChars })
await service . listTasks ({ board : 'default' })
expect ( mockExecFileAsync . mock . calls [ 0 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'team_alpha' , 'list' , '--json' ])
expect ( mockExecFileAsync . mock . calls [ 1 ][ 1 ]). toEqual ([ 'kanban' , '--board' , sixtyFourChars , 'list' , '--json' ])
expect ( mockExecFileAsync . mock . calls [ 2 ][ 1 ]). toEqual ([ 'kanban' , '--board' , 'default' , 'list' , '--json' ])
await expect ( service . listTasks ({ board : 'bad/slug' })). rejects . toThrow ( 'Invalid kanban board slug' )
await expect ( service . listTasks ({ board : 'bad.slug' })). rejects . toThrow ( 'Invalid kanban board slug' )
await expect ( service . listTasks ({ board : '..' })). rejects . toThrow ( 'Invalid kanban board slug' )
await expect ( service . listTasks ({ board : 'bad slug' })). rejects . toThrow ( 'Invalid kanban board slug' )
await expect ( service . listTasks ({ board : ' ' })). rejects . toThrow ( 'Invalid kanban board slug' )
})
it ( 'does not hide non-no-log failures from the kanban log command' , async () => {
mockExecFileAsync
. mockRejectedValueOnce ({ code : 1 , stderr : 'permission denied' , message : 'permission denied' })
. mockResolvedValueOnce ({ stdout : JSON.stringify ({ task : { id : 'task-1' }, runs : [], comments : [], events : [] }) })
await expect ( service . getTaskLog ( 'task-1' , { board : 'default' })). rejects . toThrow ( 'Failed to read kanban task log: permission denied' )
expect ( mockLoggerError ). toHaveBeenCalled ()
})
it ( 'does not treat misleading no-log fragments as canonical no-log messages' , async () => {
mockExecFileAsync
. mockRejectedValueOnce ({ code : 1 , stderr : 'permission denied: no log for diagnostic file' , message : 'permission denied' })
. mockResolvedValueOnce ({ stdout : JSON.stringify ({ task : { id : 'task-1' }, runs : [], comments : [], events : [] }) })
await expect ( service . getTaskLog ( 'task-1' , { board : 'default' })). rejects . toThrow ( 'Failed to read kanban task log: permission denied' )
})
2026-05-08 11:32:47 +08:00
it ( 'wraps CLI failures with service-specific errors' , async () => {
mockExecFileAsync . mockRejectedValue ( new Error ( 'boom' ))
await expect ( service . listTasks ()). rejects . toThrow ( 'Failed to list kanban tasks: boom' )
expect ( mockLoggerError ). toHaveBeenCalled ()
})
})