Files
hermes-desktop/tests/client/job-form-modal.test.ts
T
toor 01d9e301fc
Build / build (push) Canceled after 0s
Playwright / browser (push) Canceled after 0s
initialize Electron desktop app and remove unused files
2026-10-25 01:09:01 +08:00

294 lines
9.6 KiB
TypeScript

// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
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(),
success: vi.fn(),
error: vi.fn(),
}))
const mockSettingsStore = vi.hoisted(() => ({
platforms: {} as Record<string, any>,
fetchSettings: vi.fn(async () => {
mockSettingsStore.platforms = {
telegram: { token: 'telegram-token' },
discord: { token: 'discord-token' },
slack: { token: 'slack-token' },
whatsapp: { enabled: true },
matrix: { token: 'matrix-token' },
weixin: { token: 'weixin-token' },
wecom: { extra: { bot_id: 'wecom-bot' } },
feishu: { extra: { app_id: 'feishu-app' } },
dingtalk: { extra: { client_id: 'dingtalk-client' } },
qqbot: { extra: { app_id: 'qq-app', client_secret: 'qq-secret' } },
}
}),
}))
const mockJobsStore = vi.hoisted(() => ({
createJob: vi.fn(),
updateJob: vi.fn(),
}))
const mockAppStore = vi.hoisted(() => ({
modelGroups: [
{ provider: 'openai', label: 'OpenAI', models: ['gpt-4.1', 'gpt-4.1-mini'] },
{ provider: 'anthropic', label: 'Anthropic', models: ['claude-sonnet-4'] },
],
loadModels: vi.fn(async () => undefined),
displayModelName: vi.fn((model: string) => model),
}))
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('@/stores/hermes/settings', () => ({
useSettingsStore: () => mockSettingsStore,
}))
vi.mock('@/stores/hermes/jobs', () => ({
useJobsStore: () => mockJobsStore,
}))
vi.mock('@/stores/hermes/app', () => ({
useAppStore: () => mockAppStore,
}))
vi.mock('@/api/hermes/jobs', async () => {
const actual = await vi.importActual<any>('@/api/hermes/jobs')
return {
...actual,
getJob: vi.fn(),
}
})
vi.mock('@/api/hermes/skills', () => ({
fetchSkills: mockFetchSkills,
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
// 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'
describe('JobFormModal deliver targets', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSettingsStore.platforms = {}
})
it('loads platform settings when the store has not been hydrated', async () => {
mount(JobFormModal, {
props: { jobId: null },
global: arcoGlobal,
})
await flushPromises()
expect(mockSettingsStore.fetchSettings).toHaveBeenCalledOnce()
expect(mockAppStore.loadModels).toHaveBeenCalledOnce()
})
it('shows every supported platform channel in deliver target options', async () => {
mockSettingsStore.platforms = {
telegram: { token: 'telegram-token' },
whatsapp: { enabled: false },
qqbot: { extra: { app_id: 'qq-app', client_secret: 'qq-secret' } },
}
const wrapper = mount(JobFormModal, {
props: { jobId: null },
global: arcoGlobal,
})
await flushPromises()
expect(mockSettingsStore.fetchSettings).not.toHaveBeenCalled()
const deliverSelect = selectByLabel(wrapper, 'jobs.deliverTarget')
const labels = deliverSelect.text()
expect(labels).toContain('Telegram')
expect(labels).toContain('Discord')
expect(labels).toContain('Slack')
expect(labels).toContain('WhatsApp')
expect(labels).toContain('Matrix')
expect(labels).toContain('WeChat')
expect(labels).toContain('WeCom')
expect(labels).toContain('Feishu')
expect(labels).toContain('DingTalk')
expect(labels).toContain('QQBot')
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()
expect(optionByValue.discord.attributes('disabled')).toBe('')
expect(optionByValue.whatsapp.attributes('disabled')).toBe('')
})
it('submits selected skills when creating a job', async () => {
mockSettingsStore.platforms = { telegram: { token: 'telegram-token' } }
mockJobsStore.createJob.mockResolvedValue({ id: 'job-1' })
const wrapper = mount(JobFormModal, {
props: { jobId: null },
global: arcoGlobal,
})
await flushPromises()
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({
name: 'Daily research',
schedule: '0 9 * * *',
prompt: 'summarize updates',
deliver: 'origin',
skills: ['planner', 'reviewer'],
repeat: undefined,
provider: undefined,
model: undefined,
})
})
it('submits selected provider and model when creating a job', async () => {
mockSettingsStore.platforms = { telegram: { token: 'telegram-token' } }
mockJobsStore.createJob.mockResolvedValue({ id: 'job-1' })
const wrapper = mount(JobFormModal, {
props: { jobId: null },
global: arcoGlobal,
})
await flushPromises()
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 selectByLabel(wrapper, 'jobs.model').setValue('gpt-4.1-mini')
await buttonByText(wrapper, 'common.create').trigger('click')
await flushPromises()
expect(mockJobsStore.createJob).toHaveBeenCalledWith(expect.objectContaining({
provider: 'openai',
model: 'gpt-4.1-mini',
}))
})
})