// @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: '
{{ session ? session.id : "none" }}
', }), })) /** * 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: '
', }) const AModalStub = defineComponent({ name: 'Modal', props: { visible: { type: Boolean, required: false }, title: { type: String, required: false } }, emits: ['update:visible', 'close'], template: '
', }) /** Arco's a-select has no native `, 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') }) })