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:
2026-10-18 22:57:25 +08:00
parent c3eeea4187
commit 836d0228d2
115 changed files with 4067 additions and 3891 deletions
+46 -83
View File
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import ArcoVue from '@arco-design/web-vue'
const mockSettingsStore = vi.hoisted(() => ({
sessionReset: { mode: 'both', idle_minutes: 60, at_hour: 0 },
@@ -10,7 +11,7 @@ const mockSettingsStore = vi.hoisted(() => ({
updateLocal: vi.fn((section: string, values: Record<string, any>) => {
Object.assign((mockSettingsStore as any)[section], values)
}),
saveSection: vi.fn(),
saveSection: vi.fn(() => Promise.resolve()),
}))
const mockPrefsStore = vi.hoisted(() => ({
@@ -34,19 +35,40 @@ vi.mock('vue-i18n', () => ({
}),
}))
vi.mock('naive-ui', async () => {
const actual = await vi.importActual<any>('naive-ui')
return {
...actual,
useMessage: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}
})
vi.mock('@/composables/useMessage', () => ({
useMessage: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}))
import SessionSettings from '@/components/hermes/settings/SessionSettings.vue'
const SettingRowStub = {
props: ['label', 'hint'],
template: '<div class="setting-row"><div class="setting-row-label">{{ label }}</div><slot /></div>',
}
// `app.use(ArcoVue)` registers the components globally, so test-utils stubs only
// match Arco's internal component names, not the kebab-case template tags.
const arcoGlobal = {
plugins: [ArcoVue],
stubs: {
SettingRow: SettingRowStub,
Select: true,
InputNumber: true,
},
}
/** The Arco switch rendered inside the SettingRow carrying `label`. */
function switchByLabel(wrapper: any, label: string) {
const row = wrapper
.findAll('.setting-row')
.find((node: any) => node.get('.setting-row-label').text() === label)
if (!row) throw new Error(`no SettingRow labelled "${label}"`)
return row.get('.arco-switch')
}
describe('SessionSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -56,41 +78,15 @@ describe('SessionSettings', () => {
})
it('surfaces the human-only preference in the Session tab', async () => {
let emittedValue: boolean | undefined
const wrapper = mount(SessionSettings, {
global: {
stubs: {
SettingRow: {
props: ['label', 'hint'],
template: '<div class="setting-row"><div class="setting-row-label">{{ label }}</div><slot /></div>',
},
NSelect: true,
NInputNumber: true,
NButton: { template: '<button><slot /></button>' },
NTag: { template: '<span><slot /></span>' },
NSwitch: {
props: ['value'],
emits: ['update:value'],
template: '<div class="n-switch" @click="$emit(\'update:value\', !value)"></div>',
setup(props: any, { emit }: any) {
return {
onClick: () => {
emittedValue = !props.value
emit('update:value', emittedValue)
},
}
},
},
},
},
})
const wrapper = mount(SessionSettings, { global: arcoGlobal })
await flushPromises()
expect(wrapper.text()).toContain('settings.session.liveMonitorHumanOnly')
// requireAuth + memory + skills write approval + human-only
expect(wrapper.findAll('.arco-switch').length).toBe(4)
const toggles = wrapper.findAll('.n-switch')
expect(toggles.length).toBe(4)
const humanOnlyToggle = toggles[3]
const humanOnlyToggle = switchByLabel(wrapper, 'settings.session.liveMonitorHumanOnly')
expect(humanOnlyToggle.attributes('aria-checked')).toBe('true')
await humanOnlyToggle.trigger('click')
await Promise.resolve()
@@ -99,34 +95,17 @@ describe('SessionSettings', () => {
})
it('saves write approval toggles to memory and skills config sections', async () => {
const wrapper = mount(SessionSettings, {
global: {
stubs: {
SettingRow: {
props: ['label', 'hint'],
template: '<div class="setting-row"><div class="setting-row-label">{{ label }}</div><slot /></div>',
},
NSelect: true,
NInputNumber: true,
NButton: { template: '<button><slot /></button>' },
NTag: { template: '<span><slot /></span>' },
NSwitch: {
props: ['value'],
emits: ['update:value'],
template: '<button class="n-switch" @click="$emit(\'update:value\', !value)"></button>',
},
},
},
})
const wrapper = mount(SessionSettings, { global: arcoGlobal })
await flushPromises()
expect(wrapper.text()).toContain('settings.session.memoryWriteApproval')
expect(wrapper.text()).toContain('settings.session.skillsWriteApproval')
const memoryToggle = switchByLabel(wrapper, 'settings.session.memoryWriteApproval')
const skillsToggle = switchByLabel(wrapper, 'settings.session.skillsWriteApproval')
expect(memoryToggle.attributes('aria-checked')).toBe('false')
expect(skillsToggle.attributes('aria-checked')).toBe('true')
const toggles = wrapper.findAll('.n-switch')
await toggles[1].trigger('click')
await memoryToggle.trigger('click')
await Promise.resolve()
await toggles[2].trigger('click')
await skillsToggle.trigger('click')
await Promise.resolve()
expect(mockSettingsStore.updateLocal).toHaveBeenCalledWith('memory', { write_approval: true })
@@ -136,23 +115,7 @@ describe('SessionSettings', () => {
})
it('shows write approval toggles without probing Hermes Agent support', async () => {
const wrapper = mount(SessionSettings, {
global: {
stubs: {
SettingRow: {
props: ['label', 'hint'],
template: '<div class="setting-row"><div class="setting-row-label">{{ label }}</div><slot /></div>',
},
NSelect: true,
NInputNumber: true,
NSwitch: {
props: ['value'],
emits: ['update:value'],
template: '<button class="n-switch" @click="$emit(\'update:value\', !value)"></button>',
},
},
},
})
const wrapper = mount(SessionSettings, { global: arcoGlobal })
await flushPromises()