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:
@@ -1,7 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ArcoVue from '@arco-design/web-vue'
|
||||
|
||||
const mockMessage = vi.hoisted(() => ({
|
||||
warning: vi.fn(),
|
||||
@@ -86,38 +87,102 @@ vi.mock('vue-i18n', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NModal: defineComponent({
|
||||
template: '<div class="n-modal-stub"><slot /><slot name="footer" /></div>',
|
||||
}),
|
||||
NForm: defineComponent({ template: '<form><slot /></form>' }),
|
||||
NFormItem: defineComponent({ template: '<div><slot /></div>' }),
|
||||
NInput: defineComponent({
|
||||
props: { value: { type: String, required: false } },
|
||||
emits: ['update:value'],
|
||||
template: '<input class="n-input-stub" :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
|
||||
}),
|
||||
NInputNumber: defineComponent({
|
||||
props: { value: { required: false } },
|
||||
emits: ['update:value'],
|
||||
template: '<input class="n-input-number-stub" :value="value" type="number" @input="$emit(\'update:value\', Number($event.target.value))" />',
|
||||
}),
|
||||
NSelect: defineComponent({
|
||||
props: {
|
||||
value: { required: false },
|
||||
options: { type: Array, default: () => [] },
|
||||
multiple: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['update:value'],
|
||||
template: '<select class="n-select-stub" :multiple="multiple" :disabled="disabled" @change="$emit(\'update:value\', multiple ? Array.from($event.target.selectedOptions).map(option => option.value) : $event.target.value)"><option v-for="option in options" :key="option.value" :value="option.value" :disabled="option.disabled">{{ option.label }}</option></select>',
|
||||
}),
|
||||
NButton: defineComponent({
|
||||
emits: ['click'],
|
||||
template: '<button class="n-button-stub" @click.prevent="$emit(\'click\')"><slot /></button>',
|
||||
}),
|
||||
useMessage: () => mockMessage,
|
||||
}))
|
||||
// Arco's imperative Message API needs a live DOM, so mock the adapter composable.
|
||||
vi.mock('@/composables/useMessage', () => ({ useMessage: () => mockMessage }))
|
||||
|
||||
/**
|
||||
* Arco's a-select renders a custom dropdown instead of a native <select>, and
|
||||
* a-modal teleports to body in jsdom. Both 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: { type: [String, Number, Array], default: null },
|
||||
options: { type: Array, default: () => [] },
|
||||
multiple: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
placeholder: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:modelValue', 'change'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
'select',
|
||||
{
|
||||
class: 'arco-select-stub',
|
||||
multiple: props.multiple,
|
||||
disabled: props.disabled,
|
||||
onChange: (event: Event) => {
|
||||
const target = event.target as HTMLSelectElement
|
||||
const value = props.multiple
|
||||
? Array.from(target.selectedOptions).map(option => option.value)
|
||||
: target.value
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
},
|
||||
},
|
||||
[
|
||||
!props.multiple && h('option', { value: '' }, props.placeholder),
|
||||
...(props.options as Array<{ label: string; value: string; disabled?: boolean }>).map(option =>
|
||||
h('option', { key: option.value, value: option.value, disabled: option.disabled }, 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 value = Number((event.target as HTMLInputElement).value)
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
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, 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 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 JobFormModal from '@/components/hermes/jobs/JobFormModal.vue'
|
||||
|
||||
@@ -130,6 +195,7 @@ describe('JobFormModal deliver targets', () => {
|
||||
it('loads platform settings when the store has not been hydrated', async () => {
|
||||
mount(JobFormModal, {
|
||||
props: { jobId: null },
|
||||
global: arcoGlobal,
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
@@ -146,12 +212,14 @@ describe('JobFormModal deliver targets', () => {
|
||||
}
|
||||
const wrapper = mount(JobFormModal, {
|
||||
props: { jobId: null },
|
||||
global: arcoGlobal,
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSettingsStore.fetchSettings).not.toHaveBeenCalled()
|
||||
const labels = wrapper.findAll('.n-select-stub')[4].text()
|
||||
const deliverSelect = selectByLabel(wrapper, 'jobs.deliverTarget')
|
||||
const labels = deliverSelect.text()
|
||||
expect(labels).toContain('Telegram')
|
||||
expect(labels).toContain('Discord')
|
||||
expect(labels).toContain('Slack')
|
||||
@@ -163,7 +231,7 @@ describe('JobFormModal deliver targets', () => {
|
||||
expect(labels).toContain('DingTalk')
|
||||
expect(labels).toContain('QQBot')
|
||||
|
||||
const options = wrapper.findAll('.n-select-stub')[4].findAll('option')
|
||||
const options = deliverSelect.findAll('option')
|
||||
const optionByValue = Object.fromEntries(options.map(option => [option.attributes('value'), option]))
|
||||
expect(optionByValue.telegram.attributes('disabled')).toBeUndefined()
|
||||
expect(optionByValue.qqbot.attributes('disabled')).toBeUndefined()
|
||||
@@ -176,15 +244,15 @@ describe('JobFormModal deliver targets', () => {
|
||||
mockJobsStore.createJob.mockResolvedValue({ id: 'job-1' })
|
||||
const wrapper = mount(JobFormModal, {
|
||||
props: { jobId: null },
|
||||
global: arcoGlobal,
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
const inputs = wrapper.findAll('.n-input-stub')
|
||||
await inputs[0].setValue('Daily research')
|
||||
await inputs[1].setValue('0 9 * * *')
|
||||
await inputs[2].setValue('summarize updates')
|
||||
await wrapper.findAll('.n-select-stub')[3].setValue(['planner', 'reviewer'])
|
||||
await wrapper.findAll('.n-button-stub')[1].trigger('click')
|
||||
await fieldByLabel(wrapper, 'jobs.name').get('input').setValue('Daily research')
|
||||
await fieldByLabel(wrapper, 'jobs.schedule').get('input').setValue('0 9 * * *')
|
||||
await fieldByLabel(wrapper, 'jobs.prompt').get('textarea').setValue('summarize updates')
|
||||
await selectByLabel(wrapper, 'jobs.skills').setValue(['planner', 'reviewer'])
|
||||
await buttonByText(wrapper, 'common.create').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockJobsStore.createJob).toHaveBeenCalledWith({
|
||||
@@ -204,17 +272,17 @@ describe('JobFormModal deliver targets', () => {
|
||||
mockJobsStore.createJob.mockResolvedValue({ id: 'job-1' })
|
||||
const wrapper = mount(JobFormModal, {
|
||||
props: { jobId: null },
|
||||
global: arcoGlobal,
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
const inputs = wrapper.findAll('.n-input-stub')
|
||||
await inputs[0].setValue('Daily research')
|
||||
await inputs[1].setValue('0 9 * * *')
|
||||
await inputs[2].setValue('summarize updates')
|
||||
await wrapper.findAll('.n-select-stub')[0].setValue('openai')
|
||||
await fieldByLabel(wrapper, 'jobs.name').get('input').setValue('Daily research')
|
||||
await fieldByLabel(wrapper, 'jobs.schedule').get('input').setValue('0 9 * * *')
|
||||
await fieldByLabel(wrapper, 'jobs.prompt').get('textarea').setValue('summarize updates')
|
||||
await selectByLabel(wrapper, 'jobs.provider').setValue('openai')
|
||||
await flushPromises()
|
||||
await wrapper.findAll('.n-select-stub')[1].setValue('gpt-4.1-mini')
|
||||
await wrapper.findAll('.n-button-stub')[1].trigger('click')
|
||||
await selectByLabel(wrapper, 'jobs.model').setValue('gpt-4.1-mini')
|
||||
await buttonByText(wrapper, 'common.create').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockJobsStore.createJob).toHaveBeenCalledWith(expect.objectContaining({
|
||||
|
||||
Reference in New Issue
Block a user