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, nextTick, ref } from 'vue'
|
||||
import { defineComponent, h, inject, nextTick, provide, ref, useAttrs } from 'vue'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import ArcoVue from '@arco-design/web-vue'
|
||||
|
||||
const STT_STORAGE_KEY = 'hermes-stt-settings-v1'
|
||||
|
||||
@@ -207,168 +208,132 @@ vi.mock('vue-i18n', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
vi.mock('@/composables/useMessage', () => ({
|
||||
useMessage: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}),
|
||||
NCard: defineComponent({
|
||||
name: 'NCard',
|
||||
inheritAttrs: false,
|
||||
template: '<section v-bind="$attrs"><header><slot name="header" /></header><main><slot /></main><footer><slot name="action" /></footer></section>',
|
||||
}),
|
||||
NTag: defineComponent({
|
||||
name: 'NTag',
|
||||
inheritAttrs: false,
|
||||
template: '<span v-bind="$attrs"><slot /></span>',
|
||||
}),
|
||||
NTooltip: defineComponent({
|
||||
name: 'NTooltip',
|
||||
inheritAttrs: false,
|
||||
template: '<span><slot name="trigger" /><slot /></span>',
|
||||
}),
|
||||
NPopconfirm: defineComponent({
|
||||
name: 'NPopconfirm',
|
||||
inheritAttrs: false,
|
||||
emits: ['positive-click'],
|
||||
template: '<span><span @click="$emit(\'positive-click\')"><slot name="trigger" /></span><slot /></span>',
|
||||
}),
|
||||
NDropdown: defineComponent({
|
||||
name: 'NDropdown',
|
||||
inheritAttrs: false,
|
||||
props: { options: { type: Array as () => Array<{ label: string; key: string; disabled?: boolean }>, default: () => [] } },
|
||||
emits: ['select'],
|
||||
template: '<span><slot /><button v-for="option in options" :key="option.key" type="button" :disabled="option.disabled" :data-testid="`dropdown-option-${option.key}`" @click="$emit(\'select\', option.key)">{{ option.label }}</button></span>',
|
||||
}),
|
||||
NModal: defineComponent({
|
||||
name: 'NModal',
|
||||
inheritAttrs: false,
|
||||
props: { show: { type: Boolean, default: false } },
|
||||
emits: ['update:show'],
|
||||
template: '<div v-if="show" v-bind="$attrs"><slot /><slot name="footer" /></div>',
|
||||
}),
|
||||
NDrawer: defineComponent({
|
||||
name: 'NDrawer',
|
||||
inheritAttrs: false,
|
||||
props: { show: { type: Boolean, default: false } },
|
||||
emits: ['update:show'],
|
||||
template: '<div v-if="show" v-bind="$attrs"><slot /></div>',
|
||||
}),
|
||||
NDrawerContent: defineComponent({
|
||||
name: 'NDrawerContent',
|
||||
inheritAttrs: false,
|
||||
template: '<div v-bind="$attrs"><slot /><slot name="footer" /></div>',
|
||||
}),
|
||||
NForm: defineComponent({
|
||||
name: 'NForm',
|
||||
inheritAttrs: false,
|
||||
template: '<form v-bind="$attrs"><slot /></form>',
|
||||
}),
|
||||
NFormItem: defineComponent({
|
||||
name: 'NFormItem',
|
||||
inheritAttrs: false,
|
||||
props: { label: { type: String, default: '' } },
|
||||
template: '<label v-bind="$attrs"><span>{{ label }}</span><slot /></label>',
|
||||
}),
|
||||
NSpace: defineComponent({
|
||||
name: 'NSpace',
|
||||
inheritAttrs: false,
|
||||
template: '<div v-bind="$attrs"><slot /></div>',
|
||||
}),
|
||||
NGrid: defineComponent({
|
||||
name: 'NGrid',
|
||||
inheritAttrs: false,
|
||||
template: '<div v-bind="$attrs"><slot /></div>',
|
||||
}),
|
||||
NGridItem: defineComponent({
|
||||
name: 'NGridItem',
|
||||
inheritAttrs: false,
|
||||
template: '<div v-bind="$attrs"><slot /></div>',
|
||||
}),
|
||||
NSelect: defineComponent({
|
||||
name: 'NSelect',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
value: { type: [String, Number], default: '' },
|
||||
options: { type: Array as () => Array<{ label: string; value: string }>, default: () => [] },
|
||||
size: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:value'],
|
||||
template: `
|
||||
<div class="mock-select">
|
||||
<input v-bind="$attrs" :value="value" @input="$emit('update:value', $event.target.value)" />
|
||||
<span v-for="option in options" :key="option.value" class="mock-select-option">{{ option.label }}</span>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
NInput: defineComponent({
|
||||
name: 'NInput',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
type: { type: String, default: 'text' },
|
||||
rows: { type: Number, default: 2 },
|
||||
size: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:value'],
|
||||
template: `
|
||||
<textarea
|
||||
v-if="type === 'textarea'"
|
||||
v-bind="$attrs"
|
||||
:rows="rows"
|
||||
:placeholder="placeholder"
|
||||
:value="value"
|
||||
@input="$emit('update:value', $event.target.value)"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
v-bind="$attrs"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:value="value"
|
||||
@input="$emit('update:value', $event.target.value)"
|
||||
/>
|
||||
`,
|
||||
}),
|
||||
NButton: defineComponent({
|
||||
name: 'NButton',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
size: { type: String, default: '' },
|
||||
type: { type: String, default: '' },
|
||||
loading: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['click'],
|
||||
template: '<button type="button" v-bind="$attrs" @click="$emit(\'click\')"><slot /></button>',
|
||||
}),
|
||||
NSlider: defineComponent({
|
||||
name: 'NSlider',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
value: { type: Number, default: 0 },
|
||||
min: { type: Number, default: 0 },
|
||||
max: { type: Number, default: 100 },
|
||||
step: { type: Number, default: 1 },
|
||||
size: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:value'],
|
||||
template: `
|
||||
<input
|
||||
v-bind="$attrs"
|
||||
type="range"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:value="value"
|
||||
@input="$emit('update:value', Number($event.target.value))"
|
||||
/>
|
||||
`,
|
||||
}),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Arco overlays (modal / drawer / dropdown) render into a teleported popup, so
|
||||
* they 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 AModalStub = defineComponent({
|
||||
name: 'Modal',
|
||||
inheritAttrs: false,
|
||||
props: { visible: { type: Boolean, default: false } },
|
||||
emits: ['cancel', 'ok', 'update:visible'],
|
||||
template: '<div v-if="visible" v-bind="$attrs" class="arco-modal-stub"><slot /><slot name="footer" /></div>',
|
||||
})
|
||||
|
||||
const ADrawerStub = defineComponent({
|
||||
name: 'Drawer',
|
||||
inheritAttrs: false,
|
||||
props: { visible: { type: Boolean, default: false } },
|
||||
emits: ['cancel', 'update:visible'],
|
||||
template: '<div v-if="visible" v-bind="$attrs" class="arco-drawer-stub"><slot /></div>',
|
||||
})
|
||||
|
||||
const DOPTION_SELECT = Symbol('arco-doption-select')
|
||||
|
||||
const ADoptionStub = defineComponent({
|
||||
name: 'Doption',
|
||||
inheritAttrs: false,
|
||||
props: { value: { type: [String, Number], default: '' }, disabled: { type: Boolean, default: false } },
|
||||
setup(props, { slots }) {
|
||||
const attrs = useAttrs()
|
||||
const select = inject(DOPTION_SELECT, () => {})
|
||||
return () => h('button', {
|
||||
...attrs,
|
||||
type: 'button',
|
||||
class: 'arco-doption-stub',
|
||||
disabled: props.disabled,
|
||||
'data-value': props.value,
|
||||
onClick: () => select(props.value),
|
||||
}, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
const ADropdownStub = defineComponent({
|
||||
name: 'Dropdown',
|
||||
inheritAttrs: false,
|
||||
emits: ['select'],
|
||||
setup(_props, { slots, emit }) {
|
||||
provide(DOPTION_SELECT, (value: unknown) => emit('select', value))
|
||||
return () => h('div', { class: 'arco-dropdown-stub' }, [slots.default?.(), slots.content?.()])
|
||||
},
|
||||
})
|
||||
|
||||
const ASelectStub = defineComponent({
|
||||
name: 'Select',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
modelValue: { type: [String, Number, Boolean, Object, Array], default: '' },
|
||||
options: { type: Array as () => Array<{ label: string; value: unknown }>, default: () => [] },
|
||||
placeholder: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:modelValue', 'change'],
|
||||
template: `
|
||||
<div class="arco-select-stub">
|
||||
<input
|
||||
class="arco-select-input"
|
||||
:data-testid="$attrs['data-testid']"
|
||||
:placeholder="placeholder"
|
||||
:value="modelValue"
|
||||
@change="onChange"
|
||||
/>
|
||||
<span v-for="option in options" :key="option.value" class="arco-select-option">{{ option.label }}</span>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
onChange(event: Event) {
|
||||
const raw = (event.target as HTMLInputElement).value
|
||||
// Restore the option's declared value type so numeric ids stay numeric.
|
||||
const typed = this.options.find(option => String(option.value) === raw)?.value ?? raw
|
||||
this.$emit('update:modelValue', typed)
|
||||
this.$emit('change', typed)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const arcoGlobal = {
|
||||
plugins: [ArcoVue],
|
||||
stubs: {
|
||||
Modal: AModalStub,
|
||||
Drawer: ADrawerStub,
|
||||
Dropdown: ADropdownStub,
|
||||
Doption: ADoptionStub,
|
||||
Select: ASelectStub,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Arco's a-input / a-input-password wrap the real control in a
|
||||
* `.arco-input-wrapper` span, so field test ids land on the span. Tests keep
|
||||
* asserting against the same test ids and reach the control through the wrapper.
|
||||
*/
|
||||
function field(wrapper: any, testid: string) {
|
||||
return wrapper.get(`[data-testid="${testid}"]`)
|
||||
}
|
||||
|
||||
/** The settable control behind a field test id (the element itself for stubs). */
|
||||
function fieldInput(wrapper: any, testid: string) {
|
||||
const node = field(wrapper, testid)
|
||||
return node.element.tagName === 'INPUT' ? node : node.get('input')
|
||||
}
|
||||
|
||||
/** Menu entries inside an a-dropdown are located by their rendered label. */
|
||||
function dropdownOptionByText(wrapper: any, text: string) {
|
||||
const option = wrapper.findAll('.arco-doption-stub').find((node: any) => node.text().trim() === text)
|
||||
if (!option) throw new Error(`dropdown option not found: ${text}`)
|
||||
return option
|
||||
}
|
||||
|
||||
vi.mock('@/api/hermes/stt-settings', () => ({
|
||||
fetchSttSettings: mockFetchSttSettings,
|
||||
saveSttSettings: mockSaveSttSettings,
|
||||
@@ -759,7 +724,9 @@ describe('VoiceSettings STT UI', () => {
|
||||
const VoiceSettings = await importVoiceSettingsComponent()
|
||||
return mount(VoiceSettings, {
|
||||
global: {
|
||||
...arcoGlobal,
|
||||
stubs: {
|
||||
...arcoGlobal.stubs,
|
||||
SettingRow: defineComponent({
|
||||
name: 'SettingRow',
|
||||
props: {
|
||||
@@ -966,21 +933,21 @@ describe('VoiceSettings STT UI', () => {
|
||||
|
||||
await wrapper.findAll('button').find(button => button.text().includes('Add TTS API'))!.trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-testid="voice-provider-select"]').setValue('tts-custom')
|
||||
await fieldInput(wrapper, 'voice-provider-select').setValue('tts-custom')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('API compatibility')
|
||||
expect(wrapper.text()).toContain('Connection')
|
||||
expect(wrapper.text()).toContain('Model')
|
||||
|
||||
await wrapper.get('[data-testid="voice-provider-base-url"]').setValue('https://custom.example/v1/')
|
||||
await wrapper.get('[data-testid="voice-provider-api-key"]').setValue('raw-custom-secret')
|
||||
await wrapper.get('[data-testid="voice-provider-model"]').setValue('my-manual-model')
|
||||
await fieldInput(wrapper, 'voice-provider-base-url').setValue('https://custom.example/v1/')
|
||||
await fieldInput(wrapper, 'voice-provider-api-key').setValue('raw-custom-secret')
|
||||
await fieldInput(wrapper, 'voice-provider-model').setValue('my-manual-model')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockProbeVoiceProvider).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.get('[data-testid="voice-provider-probe"]').trigger('click')
|
||||
await field(wrapper, 'voice-provider-probe').trigger('click')
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
@@ -993,7 +960,7 @@ describe('VoiceSettings STT UI', () => {
|
||||
apiKey: 'raw-custom-secret',
|
||||
}))
|
||||
expect(wrapper.text()).toContain('Recommended: tts-1-hd')
|
||||
expect((wrapper.get('[data-testid="voice-provider-model"]').element as HTMLInputElement).value).toBe('my-manual-model')
|
||||
expect((fieldInput(wrapper, 'voice-provider-model').element as HTMLInputElement).value).toBe('my-manual-model')
|
||||
|
||||
await wrapper.findAll('button').find(button => button.text().includes('common.add'))!.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -1021,7 +988,7 @@ describe('VoiceSettings STT UI', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Stored')
|
||||
await wrapper.get('[data-testid="dropdown-option-remove"]').trigger('click')
|
||||
await dropdownOptionByText(wrapper, 'Remove').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockDeleteTtsProvider).toHaveBeenCalledWith('mimo')
|
||||
|
||||
Reference in New Issue
Block a user