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
257 lines
8.8 KiB
TypeScript
257 lines
8.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { defineComponent, h } from 'vue'
|
|
import { mount, flushPromises } from '@vue/test-utils'
|
|
import ArcoVue from '@arco-design/web-vue'
|
|
|
|
const mockCreateTask = vi.hoisted(() => vi.fn())
|
|
const mockMessage = vi.hoisted(() => ({
|
|
warning: vi.fn(),
|
|
success: vi.fn(),
|
|
error: vi.fn(),
|
|
}))
|
|
|
|
const mockFetchSkills = vi.hoisted(() => vi.fn(async () => ({
|
|
categories: [
|
|
{
|
|
name: 'local',
|
|
description: '',
|
|
skills: [
|
|
{ name: 'planner', description: 'Plan work' },
|
|
{ name: 'reviewer', description: 'Review work' },
|
|
{ name: 'disabled-skill', description: 'Disabled', enabled: false },
|
|
],
|
|
},
|
|
],
|
|
archived: [],
|
|
})))
|
|
|
|
vi.mock('vue-i18n', () => ({
|
|
useI18n: () => ({
|
|
t: (key: string) => key,
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/stores/hermes/kanban', () => ({
|
|
useKanbanStore: () => ({
|
|
assignees: [{ name: 'alice', counts: { todo: 1 } }],
|
|
createTask: mockCreateTask,
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/api/hermes/skills', () => ({
|
|
fetchSkills: mockFetchSkills,
|
|
}))
|
|
|
|
// KanbanCreateForm reads messages via the Arco adapter layer, not naive-ui.
|
|
vi.mock('@/composables/useMessage', () => ({ useMessage: () => mockMessage }))
|
|
|
|
/**
|
|
* Arco's a-select/a-input-number render custom widgets (no native form
|
|
* controls) and a-modal teleports to body in jsdom, so they are stubbed under
|
|
* Arco's *internal* component names — stubs key off those, not the `a-*`
|
|
* template tags, because Arco is registered globally via `app.use(ArcoVue)`.
|
|
*/
|
|
const ASelectStub = defineComponent({
|
|
name: 'Select',
|
|
props: {
|
|
modelValue: { default: null },
|
|
options: { type: Array, default: () => [] },
|
|
multiple: { type: Boolean, default: false },
|
|
},
|
|
emits: ['update:modelValue', 'change'],
|
|
setup(props, { emit }) {
|
|
return () =>
|
|
h('select', {
|
|
class: 'arco-select-stub',
|
|
multiple: props.multiple,
|
|
onChange: (event: Event) => {
|
|
const target = event.target as HTMLSelectElement
|
|
const raw = props.multiple
|
|
? Array.from(target.selectedOptions).map(option => option.value)
|
|
: target.value
|
|
// Options carry non-string values (e.g. numeric priority); restore the
|
|
// declared value type so the component receives what it would in prod.
|
|
const options = props.options as Array<{ label: string; value: unknown }>
|
|
const typed = (value: string) =>
|
|
options.find(option => String(option.value) === value)?.value ?? value
|
|
const value = props.multiple
|
|
? (raw as string[]).map(typed)
|
|
: typed(raw as string)
|
|
emit('update:modelValue', value)
|
|
emit('change', value)
|
|
},
|
|
}, [
|
|
h('option', { value: '' }),
|
|
...(props.options as Array<{ label: string; value: unknown }>).map(option =>
|
|
h('option', { key: String(option.value), value: String(option.value) }, option.label),
|
|
),
|
|
])
|
|
},
|
|
})
|
|
|
|
const AInputNumberStub = defineComponent({
|
|
name: 'InputNumber',
|
|
props: { modelValue: { type: [Number, String], default: undefined } },
|
|
emits: ['update:modelValue', 'change'],
|
|
setup(_props, { emit }) {
|
|
return () =>
|
|
h('input', {
|
|
class: 'arco-input-number-stub',
|
|
type: 'number',
|
|
onChange: (event: Event) => {
|
|
const raw = (event.target as HTMLInputElement).value
|
|
const value = raw === '' ? undefined : Number(raw)
|
|
emit('update:modelValue', value)
|
|
emit('change', value)
|
|
},
|
|
})
|
|
},
|
|
})
|
|
|
|
const ACheckboxStub = defineComponent({
|
|
name: 'Checkbox',
|
|
props: { modelValue: Boolean },
|
|
emits: ['update:modelValue'],
|
|
setup(props, { emit }) {
|
|
return () =>
|
|
h('label', { class: 'arco-checkbox-stub' }, [
|
|
h('input', {
|
|
type: 'checkbox',
|
|
checked: props.modelValue,
|
|
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).checked),
|
|
}),
|
|
h('span', {}, 'checkbox-slot'),
|
|
])
|
|
},
|
|
})
|
|
|
|
const AModalStub = defineComponent({
|
|
name: 'Modal',
|
|
props: ['visible'],
|
|
template: '<div v-if="visible" class="a-modal-stub"><slot /><slot name="footer" /></div>',
|
|
})
|
|
|
|
const arcoGlobal = {
|
|
plugins: [ArcoVue],
|
|
stubs: {
|
|
Select: ASelectStub,
|
|
InputNumber: AInputNumberStub,
|
|
Checkbox: ACheckboxStub,
|
|
Modal: AModalStub,
|
|
},
|
|
}
|
|
|
|
/**
|
|
* Locate a form control by its Arco form-item label instead of by DOM index, so
|
|
* the assertion keeps targeting the intended field even if field order changes.
|
|
*/
|
|
function fieldByLabel(wrapper: ReturnType<typeof mount>, label: string) {
|
|
const item = wrapper
|
|
.findAll('.arco-form-item')
|
|
.find(node => node.find('.arco-form-item-label').text().trim() === label)
|
|
if (!item) throw new Error(`form item not found: ${label}`)
|
|
return item
|
|
}
|
|
|
|
function selectByLabel(wrapper: ReturnType<typeof mount>, label: string) {
|
|
return fieldByLabel(wrapper, label).get('select.arco-select-stub')
|
|
}
|
|
|
|
function inputNumberByLabel(wrapper: ReturnType<typeof mount>, label: string) {
|
|
return fieldByLabel(wrapper, label).get('input.arco-input-number-stub')
|
|
}
|
|
|
|
function buttonByText(wrapper: ReturnType<typeof mount>, text: string) {
|
|
const button = wrapper.findAll('button').find(node => node.text().trim() === text)
|
|
if (!button) throw new Error(`button not found: ${text}`)
|
|
return button
|
|
}
|
|
|
|
import KanbanCreateForm from '@/components/hermes/kanban/KanbanCreateForm.vue'
|
|
|
|
describe('KanbanCreateForm', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('validates required title before submit', async () => {
|
|
const wrapper = mount(KanbanCreateForm, { global: arcoGlobal })
|
|
|
|
await buttonByText(wrapper, 'common.create').trigger('click')
|
|
|
|
expect(mockMessage.warning).toHaveBeenCalledWith('kanban.form.titleRequired')
|
|
expect(mockCreateTask).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('submits trimmed values and emits created/close', async () => {
|
|
mockCreateTask.mockResolvedValue({ id: 'task-1' })
|
|
const wrapper = mount(KanbanCreateForm, { global: arcoGlobal })
|
|
|
|
await fieldByLabel(wrapper, 'kanban.form.title').get('input').setValue(' Ship kanban ')
|
|
await fieldByLabel(wrapper, 'kanban.form.body').get('textarea').setValue(' write tests ')
|
|
await selectByLabel(wrapper, 'kanban.form.assignee').setValue('alice')
|
|
await selectByLabel(wrapper, 'kanban.form.priority').setValue('3')
|
|
await buttonByText(wrapper, 'common.create').trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(mockCreateTask).toHaveBeenCalledWith({
|
|
title: 'Ship kanban',
|
|
body: 'write tests',
|
|
assignee: 'alice',
|
|
priority: 3,
|
|
})
|
|
expect(mockMessage.success).toHaveBeenCalledWith('kanban.message.taskCreated')
|
|
expect(wrapper.emitted('created')).toBeTruthy()
|
|
expect(wrapper.emitted('close')).toBeTruthy()
|
|
})
|
|
|
|
it('submits advanced dispatcher options', async () => {
|
|
mockCreateTask.mockResolvedValue({ id: 'task-1' })
|
|
const wrapper = mount(KanbanCreateForm, { global: arcoGlobal })
|
|
|
|
await flushPromises()
|
|
await fieldByLabel(wrapper, 'kanban.form.title').get('input').setValue('Build parity')
|
|
await fieldByLabel(wrapper, 'kanban.form.body').get('textarea').setValue('cover cli create flags')
|
|
await selectByLabel(wrapper, 'kanban.form.workspace').setValue('worktree')
|
|
await flushPromises()
|
|
await fieldByLabel(wrapper, 'kanban.form.tenant').get('input').setValue('ops')
|
|
await fieldByLabel(wrapper, 'kanban.form.workspacePath').get('input').setValue('/repo')
|
|
await fieldByLabel(wrapper, 'kanban.form.branch').get('input').setValue('kanban-ui')
|
|
await selectByLabel(wrapper, 'kanban.form.skills').setValue(['planner', 'reviewer'])
|
|
await fieldByLabel(wrapper, 'kanban.form.maxRuntime').get('input').setValue('2h')
|
|
await inputNumberByLabel(wrapper, 'kanban.form.maxRetries').setValue('3')
|
|
// triage and goalMode are the two checkboxes in the advanced row.
|
|
const checkboxes = wrapper.findAll('.advanced-row input[type="checkbox"]')
|
|
await checkboxes[0].setValue(true)
|
|
await checkboxes[1].setValue(true)
|
|
await flushPromises()
|
|
await inputNumberByLabel(wrapper, 'kanban.form.goalMaxTurns').setValue('12')
|
|
|
|
await buttonByText(wrapper, 'common.create').trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(mockCreateTask).toHaveBeenCalledWith({
|
|
title: 'Build parity',
|
|
body: 'cover cli create flags',
|
|
tenant: 'ops',
|
|
triage: true,
|
|
workspace: 'worktree:/repo',
|
|
branch: 'kanban-ui',
|
|
skills: ['planner', 'reviewer'],
|
|
maxRuntime: '2h',
|
|
maxRetries: 3,
|
|
goalMode: true,
|
|
goalMaxTurns: 12,
|
|
})
|
|
})
|
|
|
|
it('uses compact profile names for assignee options', () => {
|
|
const wrapper = mount(KanbanCreateForm, { global: arcoGlobal })
|
|
|
|
expect(wrapper.text()).toContain('alice')
|
|
expect(wrapper.text()).not.toContain('default')
|
|
expect(wrapper.text()).not.toContain('alice · kanban.stats.tasks')
|
|
})
|
|
})
|