Files
hermes-desktop/tests/client/kanban-task-drawer.test.ts
T
toor 01d9e301fc
Build / build (push) Canceled after 0s
Playwright / browser (push) Canceled after 0s
initialize Electron desktop app and remove unused files
2026-10-25 01:09:01 +08:00

466 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { mount, flushPromises } from '@vue/test-utils'
import ArcoVue from '@arco-design/web-vue'
const mockGetTask = vi.hoisted(() => vi.fn())
const mockRequest = vi.hoisted(() => vi.fn())
const mockCompleteTasks = vi.hoisted(() => vi.fn())
const mockBlockTask = vi.hoisted(() => vi.fn())
const mockUnblockTasks = vi.hoisted(() => vi.fn())
const mockAssignTask = vi.hoisted(() => vi.fn())
const mockAddComment = vi.hoisted(() => vi.fn())
const mockGetTaskLog = vi.hoisted(() => vi.fn())
const mockGetDiagnostics = vi.hoisted(() => vi.fn())
const mockReclaimTask = vi.hoisted(() => vi.fn())
const mockReassignTask = vi.hoisted(() => vi.fn())
const mockSpecifyTask = vi.hoisted(() => vi.fn())
const mockRouterPush = vi.hoisted(() => vi.fn())
const mockUseMessage = vi.hoisted(() => vi.fn(() => ({
success: vi.fn(),
error: vi.fn(),
})))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('vue-router', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
}))
vi.mock('@/api/client', () => ({
request: mockRequest,
}))
vi.mock('@/api/hermes/kanban', () => ({
getTask: mockGetTask,
}))
vi.mock('@/stores/hermes/kanban', () => ({
useKanbanStore: () => ({
selectedBoard: 'project-a',
assignees: [{ name: 'alice', counts: { todo: 1 } }, { name: 'bob', counts: { ready: 1 } }],
completeTasks: mockCompleteTasks,
blockTask: mockBlockTask,
unblockTasks: mockUnblockTasks,
assignTask: mockAssignTask,
addComment: mockAddComment,
getTaskLog: mockGetTaskLog,
getDiagnostics: mockGetDiagnostics,
reclaimTask: mockReclaimTask,
reassignTask: mockReassignTask,
specifyTask: mockSpecifyTask,
}),
}))
vi.mock('@/components/hermes/chat/HistoryMessageList.vue', () => ({
default: defineComponent({
name: 'HistoryMessageList',
props: { session: { type: Object, required: false } },
template: '<div class="history-message-list-stub">{{ session ? session.id : "none" }}</div>',
}),
}))
/**
* Overlays teleport to `document.body` in jsdom, so the drawer and the messages
* modal are stubbed to render inline. Stubs are keyed by Arco's *internal*
* component name — not the `a-*` template tag — because Arco is registered
* globally via `app.use(ArcoVue)`.
*/
const ADrawerStub = defineComponent({
name: 'Drawer',
props: { visible: { type: Boolean, required: false }, title: { type: String, required: false } },
emits: ['cancel'],
template: '<div v-if="visible" class="arco-drawer-stub" :data-title="title"><slot /></div>',
})
const AModalStub = defineComponent({
name: 'Modal',
props: { visible: { type: Boolean, required: false }, title: { type: String, required: false } },
emits: ['update:visible', 'close'],
template: '<div v-if="visible" class="arco-modal-stub" :data-title="title"><slot /></div>',
})
/** Arco's a-select has no native <select>, so keep one to drive option values. */
const ASelectStub = defineComponent({
name: 'Select',
props: { modelValue: { required: false }, options: { type: Array, default: () => [] }, placeholder: { type: String, required: false } },
emits: ['update:modelValue', 'change'],
template: `<select class="arco-select-stub" :value="modelValue" @change="onChange">
<option value=""></option>
<option v-for="option in options" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>`,
methods: {
onChange(event: Event) {
const value = (event.target as HTMLSelectElement).value || null
this.$emit('update:modelValue', value)
this.$emit('change', value)
},
},
})
const arcoGlobal = {
plugins: [ArcoVue],
stubs: { Drawer: ADrawerStub, Modal: AModalStub, Select: ASelectStub },
}
/** Toolbar buttons are located by their label, never by DOM order. */
function buttonByText(wrapper: any, text: string) {
const button = wrapper.findAll('button').find((node: any) => node.text().trim() === text)
if (!button) throw new Error(`button not found: ${text}`)
return button
}
/** Text inputs are located by the placeholder the view renders for them. */
function inputByPlaceholder(wrapper: any, placeholder: string) {
return wrapper.get(`input[placeholder="${placeholder}"]`)
}
vi.mock('@/composables/useMessage', () => ({
useMessage: () => mockUseMessage(),
}))
import KanbanTaskDrawer from '@/components/hermes/kanban/KanbanTaskDrawer.vue'
describe('KanbanTaskDrawer', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRequest.mockResolvedValue({ results: [] })
mockCompleteTasks.mockResolvedValue(undefined)
mockBlockTask.mockResolvedValue(undefined)
mockUnblockTasks.mockResolvedValue(undefined)
mockAssignTask.mockResolvedValue(undefined)
mockAddComment.mockResolvedValue({ ok: true })
mockGetTaskLog.mockResolvedValue({ task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false })
mockGetDiagnostics.mockResolvedValue([{ task_id: 'task-1' }])
mockReclaimTask.mockResolvedValue({ ok: true })
mockReassignTask.mockResolvedValue({ ok: true })
mockSpecifyTask.mockResolvedValue([{ task_id: 'task-1' }])
mockGetTask.mockResolvedValue({
task: {
id: 'task-1',
title: 'Ship kanban',
body: 'Implement feature',
assignee: 'alice',
status: 'done',
priority: 2,
created_at: 100,
started_at: 110,
completed_at: 120,
tenant: null,
result: 'Done summary',
},
latest_summary: 'Done summary',
comments: [],
events: [],
runs: [{ id: 1, profile: 'alice', status: 'done', started_at: 110, ended_at: 120 }],
session: {
id: 'session-1',
title: 'Hermes session',
source: 'codex',
model: 'gpt-5.5',
started_at: 110,
ended_at: 120,
messages: [
{ id: 'm1', role: 'user', content: 'hello', timestamp: 111 },
{ id: 'm2', role: 'assistant', content: 'world', timestamp: 112 },
{ id: 'm3', role: 'tool', content: 'ignore', timestamp: 113 },
],
},
})
})
it('renders completed-result messages through HistoryMessageList', async () => {
const wrapper = mount(KanbanTaskDrawer, {
props: { taskId: 'task-1' },
global: arcoGlobal,
})
await flushPromises()
await wrapper.find('.result-summary').trigger('click')
await flushPromises()
const modal = wrapper.find('.arco-modal-stub')
expect(modal.exists()).toBe(true)
expect(modal.attributes('data-title')).toBe('Ship kanban')
const history = wrapper.find('.history-message-list-stub')
expect(history.exists()).toBe(true)
expect(history.text()).toBe('session-1')
const sessionProp = wrapper.getComponent({ name: 'HistoryMessageList' }).props('session') as any
expect(sessionProp.messages).toEqual([
{ id: 'm1', role: 'user', content: 'hello', timestamp: 111 },
{ id: 'm2', role: 'assistant', content: 'world', timestamp: 112 },
])
})
it('uses the latest run profile when searching related sessions', async () => {
mockGetTask.mockResolvedValueOnce({
task: {
id: 'task-2',
title: 'Retry task',
body: null,
assignee: 'bob',
status: 'running',
priority: 2,
created_at: 100,
started_at: 110,
completed_at: null,
tenant: null,
result: null,
},
latest_summary: null,
comments: [],
events: [],
runs: [
{ id: 1, profile: 'stale', status: 'failed', started_at: 110, ended_at: 120 },
{ id: 2, profile: 'fresh', status: 'running', started_at: 130, ended_at: null },
],
})
mockRequest.mockResolvedValueOnce({
results: [{ id: 'session-2', title: 'Found session', source: 'codex', model: 'gpt-5.5', started_at: 130 }],
})
const wrapper = mount(KanbanTaskDrawer, { props: { taskId: 'task-2' }, global: arcoGlobal })
await flushPromises()
const sessionsTitle = wrapper.findAll('.section-title').find(node => node.text() === 'kanban.detail.sessions')
await sessionsTitle?.trigger('click')
await flushPromises()
expect(mockRequest).toHaveBeenCalledWith('/api/hermes/kanban/search-sessions?task_id=task-2&profile=fresh&board=project-a')
await wrapper.find('.session-item').trigger('click')
expect(mockRouterPush).toHaveBeenCalledWith({ name: 'hermes.chat', query: { session: 'session-2' } })
})
it('renders task run history through a paged window', async () => {
mockGetTask.mockResolvedValueOnce({
task: {
id: 'task-many-runs',
title: 'Many runs',
body: null,
assignee: 'alice',
status: 'done',
priority: 2,
created_at: 100,
started_at: 110,
completed_at: 220,
tenant: null,
result: null,
},
latest_summary: null,
comments: [],
events: [],
runs: Array.from({ length: 25 }, (_, index) => ({
id: index + 1,
task_id: 'task-many-runs',
profile: `runner-${String(index + 1).padStart(2, '0')}`,
status: 'done',
outcome: null,
summary: `summary-${index + 1}`,
error: null,
metadata: null,
worker_pid: null,
started_at: 110 + index,
ended_at: 120 + index,
})),
})
const wrapper = mount(KanbanTaskDrawer, { props: { taskId: 'task-many-runs' }, global: arcoGlobal })
await flushPromises()
expect(wrapper.findAll('.run-item')).toHaveLength(10)
expect(wrapper.text()).toContain('1-10 / 25')
expect(wrapper.text()).toContain('runner-01')
expect(wrapper.text()).toContain('runner-10')
expect(wrapper.text()).not.toContain('runner-11')
await buttonByText(wrapper, '›').trigger('click')
await flushPromises()
expect(wrapper.findAll('.run-item')).toHaveLength(10)
expect(wrapper.text()).toContain('11-20 / 25')
expect(wrapper.text()).not.toContain('runner-10')
expect(wrapper.text()).toContain('runner-11')
expect(wrapper.text()).toContain('runner-20')
})
it('clears task-scoped operation output when switching tasks', async () => {
const wrapper = mount(KanbanTaskDrawer, { props: { taskId: 'task-1' }, global: arcoGlobal })
await flushPromises()
await buttonByText(wrapper, 'kanban.action.loadLog').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('worker log')
mockGetTask.mockResolvedValueOnce({
task: {
id: 'task-2',
title: 'Next task',
body: null,
assignee: 'bob',
status: 'todo',
priority: 1,
created_at: 200,
started_at: null,
completed_at: null,
tenant: null,
result: null,
},
latest_summary: null,
comments: [],
events: [],
runs: [],
})
await wrapper.setProps({ taskId: 'task-2' })
await flushPromises()
expect(wrapper.text()).not.toContain('worker log')
})
it('does not expose mutation actions for archived tasks', async () => {
mockGetTask.mockResolvedValueOnce({
task: {
id: 'task-archived',
title: 'Archived task',
body: null,
assignee: 'alice',
status: 'archived',
priority: 1,
created_at: 100,
started_at: 110,
completed_at: 120,
tenant: null,
result: 'Archived summary',
},
latest_summary: 'Archived summary',
comments: [],
events: [],
runs: [],
})
const wrapper = mount(KanbanTaskDrawer, { props: { taskId: 'task-archived' }, global: arcoGlobal })
await flushPromises()
expect(wrapper.text()).not.toContain('kanban.action.complete')
expect(wrapper.text()).not.toContain('kanban.action.block')
expect(wrapper.text()).not.toContain('kanban.action.assign')
})
it('executes complete, block, unblock, and assign actions', async () => {
mockGetTask.mockResolvedValueOnce({
task: {
id: 'task-0',
title: 'Todo task',
body: null,
assignee: null,
status: 'todo',
priority: 1,
created_at: 100,
started_at: null,
completed_at: null,
tenant: null,
result: null,
},
latest_summary: null,
comments: [],
events: [],
runs: [],
})
const wrapper = mount(KanbanTaskDrawer, {
props: { taskId: 'task-0' },
global: arcoGlobal,
})
await flushPromises()
await buttonByText(wrapper, 'kanban.action.complete').trigger('click')
await inputByPlaceholder(wrapper, 'kanban.action.completeSummary').setValue('done summary')
await buttonByText(wrapper, 'common.ok').trigger('click')
await flushPromises()
expect(mockCompleteTasks).toHaveBeenCalledWith(['task-0'], 'done summary')
mockGetTask.mockResolvedValueOnce({
task: {
id: 'task-3',
title: 'Blocked task',
body: null,
assignee: 'alice',
status: 'blocked',
priority: 1,
created_at: 100,
started_at: null,
completed_at: null,
tenant: null,
result: null,
},
latest_summary: null,
comments: [],
events: [],
runs: [],
})
await wrapper.setProps({ taskId: 'task-3' })
await flushPromises()
await buttonByText(wrapper, 'kanban.action.unblock').trigger('click')
expect(mockUnblockTasks).toHaveBeenCalledWith(['task-3'])
mockGetTask.mockResolvedValueOnce({
task: {
id: 'task-4',
title: 'Todo task',
body: null,
assignee: null,
status: 'todo',
priority: 1,
created_at: 100,
started_at: null,
completed_at: null,
tenant: null,
result: null,
},
latest_summary: null,
comments: [],
events: [],
runs: [],
})
mockGetTask.mockResolvedValueOnce({
task: {
id: 'task-4',
title: 'Todo task',
body: null,
assignee: 'bob',
status: 'todo',
priority: 1,
created_at: 100,
started_at: null,
completed_at: null,
tenant: null,
result: null,
},
latest_summary: null,
comments: [],
events: [],
runs: [],
})
await wrapper.setProps({ taskId: 'task-4' })
await flushPromises()
await buttonByText(wrapper, 'kanban.action.block').trigger('click')
await inputByPlaceholder(wrapper, 'kanban.action.blockReason').setValue('waiting dependency')
await buttonByText(wrapper, 'common.ok').trigger('click')
expect(mockBlockTask).toHaveBeenCalledWith('task-4', 'waiting dependency')
const select = wrapper.get('select.arco-select-stub')
await select.setValue('bob')
await buttonByText(wrapper, 'kanban.action.assign').trigger('click')
await flushPromises()
expect(mockAssignTask).toHaveBeenCalledWith('task-4', 'bob')
})
})