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
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
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())
|
||||
@@ -66,46 +67,63 @@ vi.mock('@/components/hermes/chat/HistoryMessageList.vue', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NDrawer: defineComponent({
|
||||
name: 'NDrawer',
|
||||
props: { show: { type: Boolean, required: false } },
|
||||
emits: ['update:show'],
|
||||
template: '<div class="n-drawer-stub"><slot /></div>',
|
||||
}),
|
||||
NDrawerContent: defineComponent({
|
||||
name: 'NDrawerContent',
|
||||
props: { title: { type: String, required: false }, closable: { type: Boolean, required: false } },
|
||||
template: '<div class="n-drawer-content-stub"><slot /></div>',
|
||||
}),
|
||||
NButton: defineComponent({
|
||||
name: 'NButton',
|
||||
emits: ['click'],
|
||||
template: '<button class="n-button-stub" @click="$emit(\'click\')"><slot /></button>',
|
||||
}),
|
||||
NSelect: defineComponent({
|
||||
name: 'NSelect',
|
||||
props: { value: { required: false }, options: { type: Array, default: () => [] } },
|
||||
emits: ['update:value'],
|
||||
template: '<select class="n-select-stub" @change="$emit(\'update:value\', $event.target.value || null)"><option value=""></option><option v-for="option in options" :key="option.value" :value="option.value">{{ option.label }}</option></select>',
|
||||
}),
|
||||
NInput: defineComponent({
|
||||
name: 'NInput',
|
||||
props: { value: { required: false }, size: { type: String, required: false }, placeholder: { type: String, required: false } },
|
||||
emits: ['update:value'],
|
||||
template: '<input class="n-input-stub" :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
|
||||
}),
|
||||
NSpin: defineComponent({
|
||||
name: 'NSpin',
|
||||
template: '<div class="n-spin-stub"><slot /></div>',
|
||||
}),
|
||||
NModal: defineComponent({
|
||||
name: 'NModal',
|
||||
props: { show: { type: Boolean, required: false }, title: { type: String, required: false } },
|
||||
emits: ['close'],
|
||||
template: '<div v-if="show" class="n-modal-stub" :data-title="title"><slot /></div>',
|
||||
}),
|
||||
useMessage: mockUseMessage,
|
||||
/**
|
||||
* 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'
|
||||
@@ -161,6 +179,7 @@ describe('KanbanTaskDrawer', () => {
|
||||
it('renders completed-result messages through HistoryMessageList', async () => {
|
||||
const wrapper = mount(KanbanTaskDrawer, {
|
||||
props: { taskId: 'task-1' },
|
||||
global: arcoGlobal,
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
@@ -168,7 +187,7 @@ describe('KanbanTaskDrawer', () => {
|
||||
await wrapper.find('.result-summary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const modal = wrapper.find('.n-modal-stub')
|
||||
const modal = wrapper.find('.arco-modal-stub')
|
||||
expect(modal.exists()).toBe(true)
|
||||
expect(modal.attributes('data-title')).toBe('Ship kanban')
|
||||
|
||||
@@ -210,7 +229,7 @@ describe('KanbanTaskDrawer', () => {
|
||||
results: [{ id: 'session-2', title: 'Found session', source: 'codex', model: 'gpt-5.5', started_at: 130 }],
|
||||
})
|
||||
|
||||
const wrapper = mount(KanbanTaskDrawer, { props: { taskId: 'task-2' } })
|
||||
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')
|
||||
@@ -255,7 +274,7 @@ describe('KanbanTaskDrawer', () => {
|
||||
})),
|
||||
})
|
||||
|
||||
const wrapper = mount(KanbanTaskDrawer, { props: { taskId: 'task-many-runs' } })
|
||||
const wrapper = mount(KanbanTaskDrawer, { props: { taskId: 'task-many-runs' }, global: arcoGlobal })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.run-item')).toHaveLength(10)
|
||||
@@ -264,7 +283,7 @@ describe('KanbanTaskDrawer', () => {
|
||||
expect(wrapper.text()).toContain('runner-10')
|
||||
expect(wrapper.text()).not.toContain('runner-11')
|
||||
|
||||
await wrapper.findAll('.n-button-stub').find(node => node.text() === '›')?.trigger('click')
|
||||
await buttonByText(wrapper, '›').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.run-item')).toHaveLength(10)
|
||||
@@ -275,10 +294,10 @@ describe('KanbanTaskDrawer', () => {
|
||||
})
|
||||
|
||||
it('clears task-scoped operation output when switching tasks', async () => {
|
||||
const wrapper = mount(KanbanTaskDrawer, { props: { taskId: 'task-1' } })
|
||||
const wrapper = mount(KanbanTaskDrawer, { props: { taskId: 'task-1' }, global: arcoGlobal })
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('.n-button-stub').find(node => node.text() === 'kanban.action.loadLog')?.trigger('click')
|
||||
await buttonByText(wrapper, 'kanban.action.loadLog').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('worker log')
|
||||
|
||||
@@ -328,7 +347,7 @@ describe('KanbanTaskDrawer', () => {
|
||||
runs: [],
|
||||
})
|
||||
|
||||
const wrapper = mount(KanbanTaskDrawer, { props: { taskId: 'task-archived' } })
|
||||
const wrapper = mount(KanbanTaskDrawer, { props: { taskId: 'task-archived' }, global: arcoGlobal })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('kanban.action.complete')
|
||||
@@ -358,13 +377,13 @@ describe('KanbanTaskDrawer', () => {
|
||||
})
|
||||
const wrapper = mount(KanbanTaskDrawer, {
|
||||
props: { taskId: 'task-0' },
|
||||
global: arcoGlobal,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const buttons = wrapper.findAll('.n-button-stub')
|
||||
await buttons.find(node => node.text() === 'kanban.action.complete')?.trigger('click')
|
||||
await wrapper.find('.n-input-stub').setValue('done summary')
|
||||
await wrapper.findAll('.n-button-stub').find(node => node.text() === 'common.ok')?.trigger('click')
|
||||
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')
|
||||
|
||||
@@ -389,7 +408,7 @@ describe('KanbanTaskDrawer', () => {
|
||||
})
|
||||
await wrapper.setProps({ taskId: 'task-3' })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('.n-button-stub').find(node => node.text() === 'kanban.action.unblock')?.trigger('click')
|
||||
await buttonByText(wrapper, 'kanban.action.unblock').trigger('click')
|
||||
expect(mockUnblockTasks).toHaveBeenCalledWith(['task-3'])
|
||||
|
||||
mockGetTask.mockResolvedValueOnce({
|
||||
@@ -432,14 +451,14 @@ describe('KanbanTaskDrawer', () => {
|
||||
})
|
||||
await wrapper.setProps({ taskId: 'task-4' })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('.n-button-stub').find(node => node.text() === 'kanban.action.block')?.trigger('click')
|
||||
await wrapper.find('.n-input-stub').setValue('waiting dependency')
|
||||
await wrapper.findAll('.n-button-stub').find(node => node.text() === 'common.ok')?.trigger('click')
|
||||
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.find('.n-select-stub')
|
||||
const select = wrapper.get('select.arco-select-stub')
|
||||
await select.setValue('bob')
|
||||
await wrapper.findAll('.n-button-stub').find(node => node.text() === 'kanban.action.assign')?.trigger('click')
|
||||
await buttonByText(wrapper, 'kanban.action.assign').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mockAssignTask).toHaveBeenCalledWith('task-4', 'bob')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user