Files
hermes-ui/tests/client/kanban-task-drawer.test.ts
T
toor 836d0228d2 Phase 007: migrate every remaining view from naive-ui to Arco Design Vue
The project now has exactly one UI library. Every naive-ui component in
packages/client/src is replaced by its Arco equivalent across 24 views and
roughly 80 components, and naive-ui is dropped from package.json along with
its transitive vueuc / @css-render / vdirs / seemly dependencies.

The migration is rendering-layer only. No store logic, no API contract, and
no /chat-run payload was touched.

Three classes of leftover were invisible to `grep naive-ui` and are now
covered by tests/client/no-naive-ui.test.ts, which asserts all three:
  - dead .n-* class names in style blocks
  - dead --n-* custom properties
  - naive-ui in package.json

Notable behavioural traps handled along the way, recorded in
docs/arco-migration-rules.md:
  - a-input-password defaults to masked; passing default-visibility="false"
    would have exposed API keys in clear text
  - a-upload fires a real XHR unless auto-upload is false
  - nested Trigger components fight over the first element child, so the
    group-chat settings dropdown was flattened
  - Arco renders tooltip default-slot text inline, which was silently
    collapsing the chat and group-chat toolbars
2026-10-18 22:57:25 +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')
})
})