fix: improve maintenance and synchronize live conversations

This commit is contained in:
WangShen007 committed 2026-10-29 09:46:21 +08:00
1 parent bb541bd212
commit ef86a5d3af
297 files changed
+2983 -1274

No files matched your search

@@ -40,6 +40,9 @@ const mountStep = async () => {
return wrapper
}
const buttonByText = (wrapper: ReturnType<typeof mount>, label: string) =>
wrapper.findAll('button').find((button) => button.text() === label)!
const clickContinue = async (wrapper: any) => {
const cont = wrapper.findAll('button').find((b: any) => b.text().includes('继续'))!
await cont.trigger('click')
@@ -48,7 +51,7 @@ const clickContinue = async (wrapper: any) => {
const typeAndAdd = async (wrapper: any, url: string) => {
await wrapper.find('input[type="text"]').setValue(url)
await wrapper.findAll('button').find((b: any) => b.text() === '添加')!.trigger('click')
await buttonByText(wrapper, '添加').trigger('click')
}
const staged = (wrapper: any) =>
@@ -206,13 +209,13 @@ describe('StepTeach - a website that fails fast', () => {
expect(wrapper.find('.crawl-failure').text()).toContain("无法读取 https://erfanpv.com。This site could not be found")
expect(wrapper.find('.btn-accent').text()).toContain('跳过并继续')
await wrapper.findAll('button').find((b: any) => b.text().includes('Paste some text instead'))!.trigger('click')
await buttonByText(wrapper, '改为粘贴文本').trigger('click')
addText.mockResolvedValue({ message: 'ok' })
await wrapper.find('textarea').setValue('We sell handmade candles and ship across India within five days.')
await wrapper.findAll('button').find((b: any) => b.text() === 'Add this text')!.trigger('click')
await buttonByText(wrapper, '添加文本').trigger('click')
await flushPromises()
expect(addText).toHaveBeenCalledWith('org-1', expect.stringMatching(/^About us \(/), expect.stringContaining('handmade candles'), 'agent-1')
expect(addText).toHaveBeenCalledWith('org-1', expect.stringMatching(/^关于我们(.+\)$/), expect.stringContaining('handmade candles'), 'agent-1')
expect(wrapper.emitted('next')).toBeTruthy()
})
@@ -246,7 +249,7 @@ describe('StepTeach - a website that fails fast', () => {
expect(wrapper.find('.btn-accent').text()).toContain('正在读取网站')
expect(wrapper.find('.btn-accent').attributes('disabled')).toBeDefined()
for (const label of ['添加', '上传 PDF', '返回']) {
expect(wrapper.findAll('button').find((b: any) => b.text() === label)!.attributes('disabled')).toBeDefined()
expect(buttonByText(wrapper, label).attributes('disabled')).toBeDefined()
}
expect(wrapper.find('input[type="text"]').attributes('disabled')).toBeDefined()
expect(wrapper.find('.source-remove').attributes('disabled')).toBeDefined()
@@ -288,7 +288,7 @@ describe('StepTeach - paste text instead', () => {
await wrapper.find('textarea').setValue(longText)
await button('添加文本')!.trigger('click')
await flushPromises()
expect(addText).toHaveBeenCalledWith('org-1', expect.stringMatching(/^About us \(/), longText, 'agent-1')
expect(addText).toHaveBeenCalledWith('org-1', expect.stringMatching(/^关于我们(.+\)$/), longText, 'agent-1')
expect(wrapper.emitted('next')).toBeTruthy()
})
@@ -0,0 +1,123 @@
/*
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
// Keep the real components and composables; replace only their external edges.
vi.mock('webfontloader', () => ({
default: { load: ({ active }: { active?: () => void }) => active?.() },
}))
vi.mock('@/services/widget', () => ({
widgetService: { getWidgets: vi.fn().mockResolvedValue([{ id: 'widget-1', agent_id: 'agent-1' }]) },
}))
vi.mock('@/services/groups', () => ({ listGroups: vi.fn().mockResolvedValue([]) }))
vi.mock('@/services/jira', () => ({
checkJiraConnection: vi.fn().mockResolvedValue({ connected: false }),
getAgentJiraConfig: vi.fn().mockResolvedValue({ enabled: false }),
getJiraProjects: vi.fn().mockResolvedValue([]),
getJiraIssueTypes: vi.fn().mockResolvedValue([]),
saveAgentJiraConfig: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/services/agent', () => ({
agentService: { getAgentById: vi.fn(async () => agent) },
}))
import AgentChatPreviewPanel from '@/components/agent/AgentChatPreviewPanel.vue'
import AgentDetail from '@/components/agent/AgentDetail.vue'
import { useAgentChat } from '@/composables/useAgentChat'
const agent = {
id: 'agent-1', name: '小鲸', display_name: '小鲸', agent_type: 'CUSTOMER_SUPPORT',
is_active: true, organization_id: 'org-1', use_workflow: false, require_token_auth: false,
instructions: [], groups: [], customization: { id: 1, agent_id: 'agent-1', chat_style: 'CHATBOT' },
}
const previewProps = {
isActive: true, agentId: 'agent-1', agentName: '小鲸', agentType: 'CUSTOMER_SUPPORT',
customization: { id: 1, agent_id: 'agent-1', chat_style: 'CHATBOT' as const },
}
beforeEach(() => {
window.history.replaceState({}, '', '/')
localStorage.clear()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('agent preview and close', () => {
it('keeps sample messages, disabled composer and style-switch restoration', async () => {
const wrapper = mount(AgentChatPreviewPanel, { props: previewProps })
await flushPromises()
expect(wrapper.text()).toContain('你好!有什么可以帮你?')
expect(wrapper.text()).toContain('你们支持免费退货吗?')
expect(wrapper.text()).toContain('支持——所有商品 30 天内免费退货。')
expect(wrapper.get('.chat-input input').attributes('disabled')).toBeDefined()
expect(wrapper.get('.chat-input button').attributes('disabled')).toBeDefined()
await wrapper.setProps({ customization: { ...previewProps.customization, chat_style: 'ASK_ANYTHING' } })
expect(wrapper.text()).toContain('欢迎来到 小鲸')
expect(wrapper.text()).not.toContain('你们支持免费退货吗?')
expect(wrapper.get('.welcome-message-input input').attributes('disabled')).toBeDefined()
expect(wrapper.get('.welcome-message-input button').attributes('disabled')).toBeDefined()
await wrapper.setProps({ customization: previewProps.customization })
expect(wrapper.text()).toContain('你们支持免费退货吗?')
wrapper.unmount()
})
it('still shows the offline state without enabling the preview composer', () => {
const wrapper = mount(AgentChatPreviewPanel, { props: { ...previewProps, isActive: false } })
expect(wrapper.text()).toContain('客服已离线')
expect(wrapper.get('.chat-input input').attributes('disabled')).toBeDefined()
wrapper.unmount()
})
it('preserves the chat composable public initialization, send and cleanup interface', async () => {
const chat = useAgentChat('agent-1')
chat.initChat()
expect(chat.messages.value).toEqual([{ role: 'bot', content: '你好!有什么可以帮你的吗?' }])
chat.currentInput.value = '测试消息'
await chat.sendMessage('测试消息')
expect(chat.messages.value.at(-1)).toEqual({ role: 'user', content: '测试消息' })
expect(chat.currentInput.value).toBe('')
expect(() => chat.cleanup()).not.toThrow()
})
it('emits close from the detail button and cleans the mounted preview on unmount', async () => {
const router = createRouter({ history: createMemoryHistory(), routes: [] })
const wrapper = mount(AgentDetail, {
props: { agent },
shallow: true,
global: { plugins: [router], stubs: { AgentChatPreviewPanel: false, 'font-awesome-icon': true } },
})
await flushPromises()
const tab = wrapper.findAll('button').find((button) => button.text() === '聊天自定义')
expect(tab).toBeDefined()
await tab!.trigger('click')
expect(wrapper.findComponent(AgentChatPreviewPanel).exists()).toBe(true)
expect(wrapper.text()).toContain('你们支持免费退货吗?')
expect(document.getElementById('chat-panel-font-agent-1')).not.toBeNull()
await wrapper.get('button[aria-label="返回客服列表"]').trigger('click')
expect(wrapper.emitted('close')).toEqual([[]])
wrapper.unmount()
expect(document.getElementById('chat-panel-font-agent-1')).toBeNull()
})
})
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import AISetup from '@/components/ai/AISetup.vue'
const { loadModule, fetchPlans, availablePlans } = vi.hoisted(() => ({
loadModule: vi.fn(),
fetchPlans: vi.fn(),
availablePlans: [] as Array<{ id: string; name: string; max_messages: number }>,
}))
vi.mock('@/composables/useEnterpriseFeatures', () => ({
useEnterpriseFeatures: () => ({
hasEnterpriseModule: true,
moduleImports: { subscriptionStore: '/src/modules/enterprise/composables/useSubscriptionStore.ts' },
loadModule,
}),
}))
vi.mock('@/utils/storage', () => ({
useSubscriptionStorage: () => ({
getCurrentSubscription: () => null,
getAvailablePlans: () => availablePlans,
isSubscriptionActive: () => false,
hasFeature: () => false,
}),
}))
vi.mock('@/composables/useAISetup', async () => {
const { ref } = await import('vue')
return {
useAISetup: () => ({
isLoading: ref(false),
error: ref(''),
providers: ref([]),
setupConfig: ref({ provider: 'openai', model: 'gpt-4o-mini', apiKey: '', baseUrl: '' }),
saveAISetup: vi.fn(),
updateAISetup: vi.fn(),
hasExistingConfig: ref(false),
}),
}
})
describe('AISetup subscription plan loading', () => {
beforeEach(() => {
vi.clearAllMocks()
availablePlans.splice(0)
fetchPlans.mockImplementation(async () => {
availablePlans.push({ id: 'starter', name: 'Starter', max_messages: 500 })
})
loadModule.mockResolvedValue({ subscriptionStore: { fetchPlans } })
})
it('loads the optional enterprise catalog once during initialization', async () => {
const wrapper = mount(AISetup)
await flushPromises()
expect(loadModule).toHaveBeenCalledTimes(1)
expect(fetchPlans).toHaveBeenCalledTimes(1)
expect(availablePlans).toHaveLength(1)
wrapper.unmount()
})
})
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { AxiosError } from 'axios'
import AIAgentSetup from '@/components/aiagent/AIAgentSetup.vue'
const { getOrganizationAgents, getOrganizationConfig, setAgents, route } = vi.hoisted(() => ({
getOrganizationAgents: vi.fn(),
getOrganizationConfig: vi.fn(),
setAgents: vi.fn(),
route: { query: {} as Record<string, string> },
}))
vi.mock('@/services/agent', () => ({ agentService: { getOrganizationAgents } }))
vi.mock('@/services/ai', () => ({
aiService: {
getOrganizationConfig,
getProviders: vi.fn().mockResolvedValue([]),
},
}))
vi.mock('@/utils/storage', () => ({
useAgentStorage: () => ({ setAgents }),
useSubscriptionStorage: () => ({
getCurrentSubscription: () => null,
isSubscriptionActive: () => false,
hasFeature: () => false,
getAvailablePlans: () => [],
}),
}))
vi.mock('@/composables/useEnterpriseFeatures', () => ({
useEnterpriseFeatures: () => ({
hasEnterpriseModule: false,
moduleImports: { subscriptionStore: '/src/modules/enterprise/composables/useSubscriptionStore.ts' },
loadModule: vi.fn(),
}),
}))
vi.mock('vue-router', async (importOriginal) => ({
...await importOriginal<typeof import('vue-router')>(),
useRoute: () => route,
useRouter: () => ({ replace: vi.fn() }),
}))
describe('AIAgentSetup initial configuration check', () => {
beforeEach(() => {
vi.clearAllMocks()
getOrganizationAgents.mockResolvedValue([{ id: 'agent-1', name: 'Test agent' }])
getOrganizationConfig.mockResolvedValue({ provider: 'openai' })
})
it('checks existing agents once and shows the configured agent list', async () => {
const wrapper = mount(AIAgentSetup, {
props: { model: 'openai' },
global: { stubs: { AgentList: true, AISetup: true, AIAgentOnboarding: true } },
})
await flushPromises()
expect(getOrganizationAgents).toHaveBeenCalledTimes(1)
expect(getOrganizationConfig).toHaveBeenCalledTimes(1)
expect(setAgents).toHaveBeenCalledWith([{ id: 'agent-1', name: 'Test agent' }])
expect(wrapper.text()).not.toContain('配置 AI 服务商')
wrapper.unmount()
})
it('opens AI setup when an existing organization has no AI configuration', async () => {
const notFound = new AxiosError('AI configuration not found')
Object.defineProperty(notFound, 'response', { value: { status: 404 } })
getOrganizationConfig.mockRejectedValue(notFound)
const wrapper = mount(AIAgentSetup, {
props: { model: 'openai' },
global: { stubs: { AgentList: true, AISetup: true, AIAgentOnboarding: true } },
})
await flushPromises()
expect(getOrganizationAgents).toHaveBeenCalledTimes(1)
expect(getOrganizationConfig).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('配置 AI 服务商')
wrapper.unmount()
})
})
@@ -14,11 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { describe, it, expect } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import ChatMessage from '../../../components/aiagent/ChatMessage.vue'
import robotIcon from '@/assets/robot.png'
import userIcon from '@/assets/user.png'
describe('ChatMessage', () => {
let wrapper: VueWrapper
@@ -143,4 +142,4 @@ describe('ChatMessage', () => {
expect(wrapper.find('.robot-icon').attributes('alt')).toBe('AI 助手')
})
})
})
@@ -0,0 +1,255 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import FaqWorkspace from '@/components/faq/FaqWorkspace.vue'
const { updateSettingsMock, settingsSeed, persistedSettings } = vi.hoisted(() => ({
updateSettingsMock: vi.fn(),
persistedSettings: { value: null as Record<string, unknown> | null },
settingsSeed: {
enabled: true,
slug: 'help-center',
title: 'Support',
description: null,
logo_url: null,
favicon_url: null,
brand_color: '#000000',
header_links: [] as Array<{ label: string; url: string }>,
cta_text: 'Contact us',
cta_url: '',
cta_enabled: true,
auto_generate: false,
agent_id: null,
ai_search_enabled: false,
chat_widget_enabled: false,
live_url: 'https://support.example.test/help-center',
published_count: 0,
plan_allowed: true,
agents: [],
domain: null,
},
}))
vi.mock('@/composables/useFaqWorkspace', async () => {
const { ref } = await import('vue')
const noop = vi.fn()
const noopAsync = vi.fn(async () => {})
return {
useFaqWorkspace: () => ({
faqs: ref([]),
job: ref(null),
settings: ref({ ...settingsSeed, header_links: [...settingsSeed.header_links] }),
estimate: ref(null),
fetchEstimate: noopAsync,
sourceCount: ref(0),
pageCount: ref(0),
phase: ref('empty'),
barPhase: ref('idle'),
publishedCount: ref(0),
groupedFaqs: ref(new Map()),
searchQuery: ref(''),
categoryFilter: ref(null),
statusFilter: ref('all'),
categories: ref([]),
hasActiveFilters: ref(false),
filteredCount: ref(0),
resetFilters: noop,
toggleCategory: noop,
isCategoryOpen: () => true,
editingId: ref(null),
isNewFaq: ref(false),
draftQuestion: ref(''),
draftAnswer: ref(''),
draftCategory: ref('General'),
draftSlug: ref(''),
draftUrlPath: ref(''),
draftMetaTitle: ref(''),
draftMetaDescription: ref(''),
isSaving: ref(false),
selectedIds: ref(new Set()),
selectionActive: ref(false),
toggleSelect: noop,
setSelected: noop,
clearSelection: noop,
bulkSetStatus: noopAsync,
bulkDelete: noopAsync,
refresh: noopAsync,
startPolling: noop,
stopPolling: noop,
startGeneration: noopAsync,
submitImport: noopAsync,
submitPdfImport: noopAsync,
togglePublish: noopAsync,
startEdit: noop,
startAdd: noop,
cancelEdit: noop,
saveEdit: noopAsync,
deleteFaq: noopAsync,
}),
}
})
vi.mock('@/services/faq', () => ({
faqService: {
updateSettings: updateSettingsMock,
uploadLogo: vi.fn(),
removeLogo: vi.fn(),
uploadFavicon: vi.fn(),
removeFavicon: vi.fn(),
setDomain: vi.fn(),
verifyDomain: vi.fn(),
removeDomain: vi.fn(),
},
}))
describe('Help Center appearance updates', () => {
let wrapper: VueWrapper
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
persistedSettings.value = { ...settingsSeed, header_links: [] }
updateSettingsMock.mockImplementation(async (patch: Record<string, unknown>) => {
persistedSettings.value = { ...persistedSettings.value, ...patch }
return { ...persistedSettings.value }
})
})
afterEach(() => {
wrapper?.unmount()
vi.useRealTimers()
})
async function openAppearance() {
wrapper = mount(FaqWorkspace, {
global: {
stubs: {
FaqGenerateBar: true,
FaqEmptyState: true,
FaqGenerationProgress: true,
FaqCategoryGroup: true,
FaqCard: true,
HelpCenterPublic: true,
FaqImportModal: true,
FaqSourcePicker: true,
},
},
})
const customTab = wrapper.findAll('button.tab').find((button) => button.text().includes('自定义'))
expect(customTab).toBeDefined()
await customTab!.trigger('click')
return wrapper
}
it('updates the parent draft immediately and saves text after the existing debounce', async () => {
await openAppearance()
const ctaUrl = wrapper.get('input[placeholder="app.yoursite.com"]')
await ctaUrl.setValue('app.example.test')
expect((ctaUrl.element as HTMLInputElement).value).toBe('app.example.test')
expect(updateSettingsMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(799)
expect(updateSettingsMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await flushPromises()
expect(updateSettingsMock).toHaveBeenCalledOnce()
expect(updateSettingsMock).toHaveBeenCalledWith({ cta_url: 'app.example.test' })
})
it('merges text edits made inside one debounce window into the saved patch', async () => {
await openAppearance()
const ctaText = wrapper.find('input[placeholder="名称"]')
const ctaUrl = wrapper.get('input[placeholder="app.yoursite.com"]')
await ctaText.setValue('Talk to our team')
await ctaUrl.setValue('app.example.test')
await vi.advanceTimersByTimeAsync(800)
await flushPromises()
expect(updateSettingsMock).toHaveBeenCalledOnce()
expect(updateSettingsMock).toHaveBeenCalledWith({
cta_text: 'Talk to our team',
cta_url: 'app.example.test',
})
})
it('flushes pending text edits together with an immediate color save', async () => {
await openAppearance()
const ctaText = wrapper.find('input[placeholder="名称"]')
await ctaText.setValue('Talk to our team')
await wrapper.findAll('button.swatch')[0].trigger('click')
await flushPromises()
expect(updateSettingsMock).toHaveBeenCalledOnce()
expect(updateSettingsMock).toHaveBeenCalledWith({
cta_text: 'Talk to our team',
brand_color: '#4338CA',
})
await vi.advanceTimersByTimeAsync(800)
await flushPromises()
expect(updateSettingsMock).toHaveBeenCalledOnce()
})
it('keeps a newer draft when an earlier save response arrives in flight', async () => {
await openAppearance()
let resolveFirstSave!: (settings: Record<string, unknown>) => void
const firstSaveResponse = new Promise<Record<string, unknown>>((resolve) => {
resolveFirstSave = resolve
})
updateSettingsMock.mockImplementationOnce(async (patch: Record<string, unknown>) => {
persistedSettings.value = { ...persistedSettings.value, ...patch }
const responseSnapshot = { ...persistedSettings.value }
await firstSaveResponse
return responseSnapshot
})
const ctaText = wrapper.find('input[placeholder="名称"]')
const ctaUrl = wrapper.get('input[placeholder="app.yoursite.com"]')
await ctaText.setValue('First saved value')
await vi.advanceTimersByTimeAsync(800)
expect(updateSettingsMock).toHaveBeenCalledOnce()
await ctaUrl.setValue('newer.example.test')
expect((ctaUrl.element as HTMLInputElement).value).toBe('newer.example.test')
resolveFirstSave({ ...persistedSettings.value })
await flushPromises()
expect((wrapper.get('input[placeholder="app.yoursite.com"]').element as HTMLInputElement).value)
.toBe('newer.example.test')
await vi.advanceTimersByTimeAsync(800)
await flushPromises()
expect(updateSettingsMock).toHaveBeenCalledTimes(2)
expect(updateSettingsMock).toHaveBeenLastCalledWith({ cta_url: 'newer.example.test' })
expect(persistedSettings.value).toMatchObject({
cta_text: 'First saved value',
cta_url: 'newer.example.test',
})
})
it('saves header-link edits through parent updates and custom colors immediately', async () => {
await openAppearance()
await wrapper.get('button.add-link').trigger('click')
const linkLabel = wrapper.get('.link-rows .text-input--label')
await linkLabel.setValue('Documentation')
expect(updateSettingsMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(800)
await flushPromises()
expect(updateSettingsMock).toHaveBeenLastCalledWith({
header_links: [{ label: 'Documentation', url: '' }],
})
await wrapper.findAll('button.swatch')[0].trigger('click')
await flushPromises()
expect(updateSettingsMock).toHaveBeenLastCalledWith({ brand_color: '#4338CA' })
})
})
@@ -14,9 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect } from 'vitest'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import KnowledgeAddSourceModal from '../../../components/knowledge/KnowledgeAddSourceModal.vue'
import { useKnowledgeExplorer, type AddSourcePayload } from '../../../composables/useKnowledgeExplorer'
const knowledge = vi.hoisted(() => ({
addUrls: vi.fn().mockResolvedValue({}),
getKnowledgeByOrganization: vi.fn().mockResolvedValue({ knowledge: [] }),
getKnowledgeByAgent: vi.fn().mockResolvedValue({ knowledge: [] }),
getOrgQueueItems: vi.fn().mockResolvedValue({ queue_items: [] }),
getAgentQueueItems: vi.fn().mockResolvedValue({ queue_items: [] }),
}))
vi.mock('@/services/knowledge', () => ({ knowledgeService: knowledge }))
const mountModal = async (url: string) => {
const wrapper = mount(KnowledgeAddSourceModal)
@@ -28,9 +39,15 @@ const scopeTitles = (wrapper: ReturnType<typeof mount>) =>
wrapper.findAll('.scope__title').map((node) => node.text())
const scopeOption = (wrapper: ReturnType<typeof mount>, title: string) =>
wrapper.findAll('.scope__opt').find((node) => node.find('.scope__title').text() === title)
wrapper.findAll('button[aria-pressed]').find((node) => node.text().startsWith(title))
const submitWebsite = async (wrapper: ReturnType<typeof mount>) => {
await wrapper.findAll('button').find((button) => button.text() === '加入抓取队列')!.trigger('click')
}
describe('KnowledgeAddSourceModal crawl scope', () => {
beforeEach(() => vi.clearAllMocks())
it('offers no section option for a homepage URL', async () => {
// "Pages under / " is not a section — it is the whole site, which the next
// option already covers.
@@ -47,25 +64,39 @@ describe('KnowledgeAddSourceModal crawl scope', () => {
)
})
it('falls back to this-site when the section option disappears', async () => {
it('falls back to this-site and submits it to the organization knowledge base', async () => {
const wrapper = await mountModal('https://help.example.com/hc/en')
await scopeOption(wrapper, '本栏目')!.trigger('click')
await wrapper.find('#kb-add-url').setValue('https://help.example.com')
await wrapper.find('button.btn--primary').trigger('click')
await submitWebsite(wrapper)
expect(wrapper.emitted('submit')![0][0]).toMatchObject({
const payload = wrapper.emitted('submit')![0][0] as AddSourcePayload
expect(payload).toEqual({
type: 'website',
url: 'https://help.example.com',
scope: 'host',
})
const explorer = useKnowledgeExplorer('org', undefined, 'org-1')
await expect(explorer.submitSource(payload)).resolves.toBe(true)
expect(knowledge.addUrls).toHaveBeenCalledWith('org-1', ['https://help.example.com'], undefined, undefined, undefined, 'host')
expect(knowledge.getKnowledgeByOrganization).toHaveBeenCalledWith('org-1', 1, 100)
expect(knowledge.getOrgQueueItems).toHaveBeenCalledWith('org-1')
expect(knowledge.getKnowledgeByAgent).not.toHaveBeenCalled()
})
it('submits the selected scope', async () => {
it('submits the selected section for the agent knowledge base', async () => {
const wrapper = await mountModal('https://help.example.com/hc/en')
await scopeOption(wrapper, '本栏目')!.trigger('click')
await wrapper.find('button.btn--primary').trigger('click')
await submitWebsite(wrapper)
expect(wrapper.emitted('submit')![0][0]).toMatchObject({ scope: 'path' })
const payload = wrapper.emitted('submit')![0][0] as AddSourcePayload
expect(payload).toEqual({ type: 'website', url: 'https://help.example.com/hc/en', scope: 'path' })
const explorer = useKnowledgeExplorer('agent', 'agent-1', 'org-1')
await expect(explorer.submitSource(payload)).resolves.toBe(true)
expect(knowledge.addUrls).toHaveBeenCalledWith('org-1', ['https://help.example.com/hc/en'], 'agent-1', undefined, undefined, 'path')
expect(knowledge.getKnowledgeByAgent).toHaveBeenCalledWith('agent-1', 1, 100)
expect(knowledge.getAgentQueueItems).toHaveBeenCalledWith('agent-1')
expect(knowledge.getKnowledgeByOrganization).not.toHaveBeenCalled()
})
})
@@ -32,7 +32,7 @@ vi.mock('@/pwa/register', () => ({
const setNotificationPermission = (permission: string | null) => {
if (permission === null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (window as any).Notification
} else {
vi.stubGlobal('Notification', { permission })
@@ -53,7 +53,7 @@ describe('EnablePushPrompt', () => {
setNotificationPermission('default')
const wrapper = await mountPrompt()
expect(wrapper.find('.push-prompt').exists()).toBe(true)
expect(wrapper.text()).toContain('Enable notifications')
expect(wrapper.text()).toContain('开启通知')
})
it('stays hidden when permission is already granted', async () => {
@@ -95,7 +95,7 @@ describe('EnablePushPrompt', () => {
setNotificationPermission(null)
vi.stubGlobal('navigator', { userAgent: 'iPhone Safari' })
const wrapper = await mountPrompt()
expect(wrapper.text()).toContain('Add ReplyPier to your Home Screen')
expect(wrapper.text()).toContain('请先把 ReplyPier 添加到主屏幕')
expect(wrapper.find('.primary-btn').exists()).toBe(false)
})
})
@@ -209,6 +209,6 @@ describe('TicketInvestigationPanel guided re-run', () => {
const denied = mountPanel({}, { canReinvestigate: true, canEditSettings: false })
await denied.find('.rerun-btn').trigger('click')
expect(denied.find('.rerun-scope a').exists()).toBe(false)
expect(denied.find('.rerun-scope').text()).toContain('Ticketing settings')
expect(denied.find('.rerun-scope').text()).toContain('工单设置')
})
})
@@ -61,7 +61,7 @@ describe('TicketSidePanel CSAT card', () => {
)
expect(card.text()).toContain('4/5')
expect(card.findAll('.csat-stars font-awesome-icon-stub')).toHaveLength(5)
expect(card.text()).toContain('Rated by the customer')
expect(card.text()).toContain('客户已在关联会话中评分')
})
it('shows pending while the ask is out', () => {
@@ -0,0 +1,169 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, reactive } from 'vue'
import { mount } from '@vue/test-utils'
import type { ChatDetail } from '@/types/chat'
const mocks = vi.hoisted(() => {
const handlers = new Map<string, (value?: any) => void>()
const reconnect = new Set<() => void>()
const acknowledgements = new Map<string, () => void>()
return {
handlers, reconnect, acknowledgements,
getChatDetail: vi.fn(),
socket: {
connect: vi.fn(),
on: vi.fn((name: string, handler: (value?: any) => void) => handlers.set(name, handler)),
off: vi.fn((name: string) => handlers.delete(name)),
onReconnect: vi.fn((handler: () => void) => reconnect.add(handler)),
offReconnect: vi.fn((handler: () => void) => reconnect.delete(handler)),
emit: vi.fn((event: string, value: { session_id: string }, ack?: () => void) => {
if (event === 'join_room' && ack) acknowledgements.set(value.session_id, ack)
}),
},
}
})
vi.mock('@/services/socket', () => ({ socketService: mocks.socket }))
vi.mock('@/services/chat', () => ({ chatService: { getChatDetail: mocks.getChatDetail } }))
vi.mock('@/services/user', () => ({
userService: {
getUserId: () => 'user-1',
getCurrentUser: () => ({ organization_id: 'org-1', role: { permissions: [{ name: 'view_unassigned_chats' }] } }),
},
}))
import { useConversationsList } from '@/composables/useConversationsList'
const detail = (sessionId: string, messages: ChatDetail['messages'] = []): ChatDetail => ({
session_id: sessionId,
customer: { id: 'customer', email: '' },
agent: { id: 'agent', name: 'AI', display_name: null },
messages, status: 'open', user_id: null, group_id: null,
created_at: '2026-10-29T00:00:00Z', updated_at: '2026-10-29T00:00:00Z',
})
const message = (id: number, text: string) => ({
id, message: text, message_type: id % 2 ? 'user' : 'bot',
created_at: '2026-10-29T00:00:00Z', session_id: 's1',
})
function setup() {
let model!: ReturnType<typeof useConversationsList>
const emit = vi.fn()
const props = reactive({ conversations: [], loading: false, error: '' })
const wrapper = mount(defineComponent({
setup() { model = useConversationsList(props, emit); return () => h('div') },
}))
return { model, emit, wrapper }
}
describe('conversation realtime synchronization', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mocks.handlers.clear()
mocks.reconnect.clear()
mocks.acknowledgements.clear()
})
afterEach(() => vi.useRealTimers())
it('refreshes a selected AI conversation after its authorized watch is acknowledged', async () => {
const { model, wrapper, emit } = setup()
mocks.getChatDetail.mockResolvedValueOnce(detail('s1')).mockResolvedValueOnce(detail('s1', [message(1, '你好'), message(2, '您好')]))
await model.loadChatDetail('s1')
expect(mocks.socket.emit).toHaveBeenCalledWith('join_room', { session_id: 'chat_watch_s1' }, expect.any(Function))
mocks.acknowledgements.get('chat_watch_s1')!()
mocks.handlers.get('chat_changed')!()
await vi.advanceTimersByTimeAsync(200)
expect(model.selectedChat.value?.messages.map(m => m.message)).toEqual(['你好', '您好'])
expect(emit).toHaveBeenCalledWith('chatUpdated', expect.objectContaining({ session_id: 's1' }))
wrapper.unmount()
})
it('coalesces thousands of updates and performs only one trailing read while a read is in flight', async () => {
const { model, wrapper, emit } = setup()
let finish!: (value: ChatDetail) => void
const delayed = new Promise<ChatDetail>(resolve => { finish = resolve })
mocks.getChatDetail.mockResolvedValueOnce(detail('s1')).mockReturnValueOnce(delayed).mockResolvedValueOnce(detail('s1', [message(1, 'latest')]))
await model.loadChatDetail('s1')
for (let i = 0; i < 5000; i++) {
mocks.handlers.get('chat_changed')!()
mocks.handlers.get('inbox_changed')!()
}
await vi.advanceTimersByTimeAsync(200)
expect(mocks.getChatDetail).toHaveBeenCalledTimes(2)
for (let i = 0; i < 5000; i++) mocks.handlers.get('chat_changed')!()
await vi.advanceTimersByTimeAsync(800)
expect(mocks.getChatDetail).toHaveBeenCalledTimes(2)
expect(emit.mock.calls.filter(call => call[0] === 'liveRefresh')).toHaveLength(1)
finish(detail('s1'))
await Promise.resolve()
await vi.advanceTimersByTimeAsync(200)
expect(mocks.getChatDetail).toHaveBeenCalledTimes(3)
expect(model.selectedChat.value?.messages[0].message).toBe('latest')
wrapper.unmount()
})
it('does not replace a newer selection with a slow older response', async () => {
const { model, wrapper } = setup()
let finish!: (value: ChatDetail) => void
mocks.getChatDetail.mockReturnValueOnce(new Promise<ChatDetail>(resolve => { finish = resolve })).mockResolvedValueOnce(detail('s2'))
const first = model.loadChatDetail('s1')
await model.loadChatDetail('s2')
finish(detail('s1'))
await first
expect(model.selectedChat.value?.session_id).toBe('s2')
expect(mocks.socket.emit).toHaveBeenCalledWith('leave_room', { session_id: 'chat_watch_s1' })
wrapper.unmount()
})
it('rejoins and resynchronizes after a connection drops, then removes its listeners', async () => {
const { model, wrapper } = setup()
mocks.getChatDetail.mockResolvedValue(detail('s1'))
await model.loadChatDetail('s1')
for (const callback of mocks.reconnect) callback()
mocks.acknowledgements.get('chat_watch_s1')!()
await vi.advanceTimersByTimeAsync(200)
expect(mocks.getChatDetail).toHaveBeenCalledTimes(2)
wrapper.unmount()
expect(mocks.socket.emit).toHaveBeenCalledWith('leave_room', { session_id: 'chat_watch_s1' })
expect(mocks.handlers.has('chat_changed')).toBe(false)
expect(mocks.reconnect.size).toBe(0)
})
it('eventually reconciles if a broker notification is missed without a disconnect', async () => {
const random = vi.spyOn(Math, 'random').mockReturnValue(0)
const { model, wrapper, emit } = setup()
mocks.getChatDetail.mockResolvedValueOnce(detail('s1')).mockResolvedValueOnce(detail('s1', [message(1, 'recovered')]))
await model.loadChatDetail('s1')
await vi.advanceTimersByTimeAsync(46000)
expect(model.selectedChat.value?.messages[0].message).toBe('recovered')
expect(emit).toHaveBeenCalledWith('liveRefresh')
wrapper.unmount()
random.mockRestore()
})
it('drops a conversation when the server revokes access', async () => {
const { model, wrapper, emit } = setup()
mocks.getChatDetail.mockResolvedValueOnce(detail('s1')).mockRejectedValueOnce({ response: { status: 404 } })
await model.loadChatDetail('s1')
mocks.handlers.get('chat_changed')!()
await vi.advanceTimersByTimeAsync(200)
expect(model.selectedChat.value).toBeNull()
expect(emit).toHaveBeenCalledWith('chatAccessLost')
wrapper.unmount()
})
it('keeps two same-tick messages and ignores a duplicate row', async () => {
const { model, wrapper } = setup()
mocks.getChatDetail.mockResolvedValue(detail('s1'))
await model.loadChatDetail('s1')
const reply = mocks.handlers.get('chat_reply')!
reply({ session_id: 's1', message_id: 1, message: 'a', type: 'user', timestamp: '2026-10-29T00:00:00Z' })
reply({ session_id: 's1', message_id: 2, message: 'b', type: 'user', timestamp: '2026-10-29T00:00:00Z' })
reply({ session_id: 's1', message_id: 1, message: 'a', type: 'user', timestamp: '2026-10-29T00:00:00Z' })
await Promise.resolve()
expect(model.selectedChat.value?.messages.map(m => m.message)).toEqual(['a', 'b'])
wrapper.unmount()
})
})
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { io, socket, emitter } = vi.hoisted(() => {
const socket = {
connected: false,
active: true,
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
}
return {
io: vi.fn(() => socket),
socket,
emitter: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
},
}
})
vi.mock('socket.io-client', () => ({ io }))
vi.mock('@/utils/emitter', () => ({ default: emitter }))
vi.mock('@/config/api', () => ({ getWsUrl: () => 'wss://support.example.test' }))
import { socketService } from '@/services/socket'
describe('SocketService connection lifecycle', () => {
beforeEach(() => {
socketService.disconnect()
vi.clearAllMocks()
socket.connected = false
socket.active = true
io.mockReturnValue(socket)
})
it('reuses a pending or reconnecting socket instead of replacing its listeners', () => {
socketService.connect()
socketService.on('chat_reply', vi.fn())
socketService.connect()
expect(io).toHaveBeenCalledTimes(1)
expect(socket.on).toHaveBeenCalledTimes(5)
expect(socket.disconnect).not.toHaveBeenCalled()
expect(socket.connect).not.toHaveBeenCalled()
})
it('reconnects an existing inactive socket without creating a second instance', () => {
socketService.connect()
socket.active = false
socketService.connect()
expect(io).toHaveBeenCalledTimes(1)
expect(socket.connect).toHaveBeenCalledTimes(1)
})
it('allows a fresh socket after an explicit disconnect', () => {
const nextSocket = {
...socket,
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
}
io.mockReturnValueOnce(socket).mockReturnValueOnce(nextSocket)
socketService.connect('/agent')
socketService.disconnect()
socketService.connect('/agent')
expect(socket.disconnect).toHaveBeenCalledTimes(1)
expect(io).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { validateWidgetFormField } from '@/utils/widgetFormValidation'
describe('validateWidgetFormField', () => {
it('uses the server-trimmed value for required and text length rules', () => {
const field = { name: 'name', label: 'Name', type: 'text', minLength: 2 }
expect(validateWidgetFormField(field, ' A ')).toBe('Name 至少需要 2 个字符')
expect(validateWidgetFormField(field, ' AB ')).toBeNull()
expect(validateWidgetFormField({ ...field, required: true }, ' ')).toBe('Name 为必填项')
})
it('counts text by Unicode code point like Python len()', () => {
const field = { name: 'name', label: 'Name', type: 'text' }
expect(validateWidgetFormField({ ...field, minLength: 2 }, '🙂'))
.toBe('Name 至少需要 2 个字符')
expect(validateWidgetFormField({ ...field, maxLength: 1 }, '🙂')).toBeNull()
})
it('treats a zero text maxLength as disabled, matching the server contract', () => {
expect(validateWidgetFormField(
{ name: 'message', label: 'Message', type: 'text', maxLength: 0 },
'hello',
)).toBeNull()
})
it('keeps numeric zero and zero bounds meaningful', () => {
const field = { name: 'amount', label: 'Amount', type: 'number', maxLength: 0, required: true }
expect(validateWidgetFormField(field, 0)).toBeNull()
expect(validateWidgetFormField(field, '1')).toBe('Amount 不能大于 0')
expect(validateWidgetFormField({ name: 'age', label: 'Age', type: 'number' }, '-1'))
.toBe('Age 不能小于 0')
})
it('rejects numeric text that Python float() rejects', () => {
expect(validateWidgetFormField(
{ name: 'age', label: 'Age', type: 'number' },
'12abc',
)).toBe('Age 必须是有效数字')
expect(validateWidgetFormField(
{ name: 'age', label: 'Age', type: 'number', minLength: 18 },
'2.5e1',
)).toBeNull()
})
it('accepts Python float() decimal forms and preserves explicit null bounds', () => {
const field = { name: 'amount', label: 'Amount', type: 'number' }
expect(validateWidgetFormField(field, '3.')).toBeNull()
expect(validateWidgetFormField(field, '1.e3')).toBeNull()
expect(validateWidgetFormField({ ...field, minLength: null }, '-1')).toBeNull()
expect(validateWidgetFormField({ ...field, maxLength: null }, '1')).toBeNull()
})
it('trims email values before applying the existing email rule', () => {
expect(validateWidgetFormField(
{ name: 'email', label: 'Email', type: 'email' },
' person@example.test ',
)).toBeNull()
})
})
+8 -14
View File
@@ -41,7 +41,8 @@ describe('403 View', () => {
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{ path: '/403', name: 'forbidden', component: ForbiddenView }
{ path: '/403', name: 'forbidden', component: ForbiddenView },
{ path: '/settings/user', name: 'user-settings', component: { template: '<div>Settings</div>' } }
]
})
@@ -54,10 +55,6 @@ describe('403 View', () => {
global: {
plugins: [router],
stubs: {
RouterLink: {
template: '<a :href="to" class="router-link"><slot /></a>',
props: ['to']
},
DashboardLayout: {
template: '<div class="dashboard-layout"><slot /></div>'
}
@@ -68,8 +65,8 @@ describe('403 View', () => {
it('renders properly', () => {
expect(wrapper.find('.unauthorized').exists()).toBe(true)
expect(wrapper.find('h1').text()).toBe('Access Denied')
expect(wrapper.find('p').text()).toBe('You don\'t have permission to access this page.')
expect(wrapper.find('h1').text()).toBe('访问被拒绝')
expect(wrapper.find('p').text()).toBe('你没有权限访问此页面。')
})
it('is wrapped in DashboardLayout', () => {
@@ -95,14 +92,11 @@ describe('403 View', () => {
expect(backLink.classes()).toContain('back-link')
})
it('navigates to home when clicking the back link', async () => {
it('navigates to the accessible page when clicking the back link', async () => {
const backLink = wrapper.find('.back-link')
await backLink.trigger('click')
// Simulate router navigation
await router.push('/')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/')
expect(router.currentRoute.value.path).toBe('/settings/user')
})
})
})
@@ -84,12 +84,14 @@ vi.mock('@/services/api', () => ({
// Mock agent service
vi.mock('@/services/agent', () => ({
agentService: {
getAgentRoster: vi.fn().mockResolvedValue([]),
getOrganizationAgents: vi.fn().mockResolvedValue([])
}
}))
// Import the mocked modules
import { chatService } from '@/services/chat'
import { agentService } from '@/services/agent'
describe('ConversationsView', () => {
let wrapper: VueWrapper
@@ -138,6 +140,20 @@ describe('ConversationsView', () => {
expect(conversationsList.props('loading')).toBe(false)
})
it('loads the lightweight agent roster used by the inbox filter', async () => {
const roster = [{ id: 'agent-1', name: 'Ada', display_name: 'Ada Lovelace' }]
vi.mocked(agentService.getAgentRoster).mockResolvedValue(roster)
wrapper = mount(ConversationsView)
await vi.waitFor(() => {
const filters = wrapper.findComponent({ name: 'ConversationFilters' })
expect(filters.props('agents')).toEqual(roster)
})
expect(agentService.getAgentRoster).toHaveBeenCalledOnce()
})
it('handles loading error', async () => {
const errorMessage = '加载会话失败'
;(chatService.getRecentChats as any).mockRejectedValue(new Error('API Error'))
@@ -186,4 +202,4 @@ describe('ConversationsView', () => {
expect(chatService.getRecentChats).toHaveBeenCalled()
})
})
})
@@ -15,10 +15,30 @@ limitations under the License.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import { flushPromises, mount, VueWrapper } from '@vue/test-utils'
import HumanAgentView from '../../views/HumanAgentView.vue'
import { createPinia, setActivePinia } from 'pinia'
const serviceMocks = vi.hoisted(() => ({
listUsers: vi.fn(),
getTeamOverview: vi.fn(),
listGroups: vi.fn(),
listRoles: vi.fn()
}))
vi.mock('@/services/users', () => ({
listUsers: serviceMocks.listUsers,
getTeamOverview: serviceMocks.getTeamOverview
}))
vi.mock('@/services/groups', () => ({
listGroups: serviceMocks.listGroups
}))
vi.mock('@/services/roles', () => ({
listRoles: serviceMocks.listRoles
}))
// Mock the components
vi.mock('@/layouts/DashboardLayout.vue', () => ({
default: {
@@ -51,9 +71,26 @@ vi.mock('@/components/human-agent/RoleList.vue', () => ({
describe('HumanAgentView', () => {
let wrapper: VueWrapper
beforeEach(() => {
beforeEach(async () => {
setActivePinia(createPinia())
vi.clearAllMocks()
serviceMocks.listUsers.mockResolvedValue([{
id: 'agent-1',
email: 'agent@example.test',
full_name: 'Test Agent',
organization_id: 'org-test',
is_online: true,
role: { id: 'role-agent', name: 'Agent' }
}])
serviceMocks.getTeamOverview.mockResolvedValue(null)
serviceMocks.listGroups.mockResolvedValue([{
id: 'group-1',
name: 'Support',
organization_id: 'org-test'
}])
serviceMocks.listRoles.mockResolvedValue([{ id: 'role-agent', name: 'Agent' }])
wrapper = mount(HumanAgentView)
await flushPromises()
})
it('renders properly', () => {
@@ -77,6 +114,15 @@ describe('HumanAgentView', () => {
expect(wrapper.findComponent({ name: 'RoleList' }).exists()).toBe(false)
})
it('loads the roster and tab counts through the local service boundary', () => {
expect(serviceMocks.listUsers).toHaveBeenCalledTimes(1)
expect(serviceMocks.getTeamOverview).toHaveBeenCalledTimes(1)
expect(serviceMocks.listGroups).toHaveBeenCalledTimes(1)
expect(serviceMocks.listRoles).toHaveBeenCalledTimes(1)
expect(wrapper.findAll('.tab-pill .count').map((badge) => badge.text())).toEqual(['1', '1', '1'])
expect(wrapper.find('.stat-value').text()).toBe('1')
})
it('switches to GroupList when Groups tab is clicked', async () => {
const groupsTab = wrapper.findAll('.tab-pill')[1]
await groupsTab.trigger('click')
@@ -121,4 +167,4 @@ describe('HumanAgentView', () => {
const dashboardLayout = wrapper.findComponent({ name: 'DashboardLayout' })
expect(dashboardLayout.exists()).toBe(true)
})
})
})
+10 -4
View File
@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia, setActivePinia } from 'pinia'
import SetupView from '../../views/SetupView.vue'
import { nextTick } from 'vue'
@@ -58,13 +58,17 @@ describe('SetupView', () => {
let wrapper: VueWrapper
let router: any
afterEach(() => {
wrapper?.unmount()
})
beforeEach(async () => {
// Create a fresh pinia instance
setActivePinia(createPinia())
// Create router instance
router = createRouter({
history: createWebHistory(),
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{ path: '/ai-agents', name: 'ai-agents', component: { template: '<div>AI Agents</div>' } }
@@ -85,6 +89,7 @@ describe('SetupView', () => {
})
// Wait for initial organization check
await flushPromises()
await nextTick()
})
@@ -102,6 +107,7 @@ describe('SetupView', () => {
vi.mocked(getSetupStatus).mockResolvedValueOnce(true)
// Remount component
wrapper.unmount()
wrapper = mount(SetupView, {
global: {
plugins: [router]
@@ -288,4 +294,4 @@ describe('SetupView', () => {
expect(wrapper.find('button[type="submit"]').classes()).toContain('loading')
})
})
})
@@ -0,0 +1,85 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import TicketsView from '@/views/TicketsView.vue'
import { ticketService } from '@/services/tickets'
const { router } = vi.hoisted(() => ({
router: { push: vi.fn(), replace: vi.fn() },
}))
vi.mock('vue-router', async (importOriginal) => ({
...await importOriginal<typeof import('vue-router')>(),
useRoute: () => ({ query: {} }),
useRouter: () => router,
}))
vi.mock('@/layouts/DashboardLayout.vue', () => ({
default: { name: 'DashboardLayout', template: '<div><slot /></div>' },
}))
vi.mock('@/composables/useTicketSocket', () => ({ useTicketSocket: vi.fn() }))
vi.mock('@/composables/useUsers', async () => {
const { ref } = await import('vue')
return { useUsers: () => ({ users: ref([]), fetchUsers: vi.fn().mockResolvedValue([]) }) }
})
vi.mock('@/services/tickets', () => ({
ticketService: {
listTickets: vi.fn(),
getStats: vi.fn(),
},
}))
describe('ticket filter updates through the workspace', () => {
let wrapper: VueWrapper
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
vi.mocked(ticketService.listTickets).mockResolvedValue({
tickets: [],
pagination: { page: 1, per_page: 20, total: 0, total_pages: 0 },
})
vi.mocked(ticketService.getStats).mockResolvedValue({
open: 0,
awaiting_approval: 0,
sla_breaching: 0,
ai_resolved_pct_7d: null,
csat_avg: null,
csat_ai_avg: null,
csat_human_avg: null,
csat_responses: 0,
csat_window_days: 7,
})
})
afterEach(() => {
wrapper?.unmount()
vi.useRealTimers()
})
it('updates parent filters, URL state, and the debounced search request', async () => {
wrapper = mount(TicketsView)
await flushPromises()
vi.mocked(ticketService.listTickets).mockClear()
const statusButton = wrapper.findAll('button.pill').find((button) => button.text() === '待处理')
expect(statusButton).toBeDefined()
await statusButton!.trigger('click')
await flushPromises()
expect(router.replace).toHaveBeenCalledWith({ query: { status: 'open' } })
expect(ticketService.listTickets).toHaveBeenCalledWith(expect.objectContaining({
status: 'open,triaging,investigating,awaiting_approval,in_progress,reopened,resolved_pending_confirmation',
}))
vi.mocked(ticketService.listTickets).mockClear()
const search = wrapper.get('input.search-input')
await search.setValue('refund')
await vi.advanceTimersByTimeAsync(349)
expect(ticketService.listTickets).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await flushPromises()
expect(router.replace).toHaveBeenLastCalledWith({ query: { status: 'open', search: 'refund' } })
expect(ticketService.listTickets).toHaveBeenCalledWith(expect.objectContaining({ search: 'refund' }))
})
})
@@ -95,6 +95,7 @@ describe('the composer while a reply is in flight', () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
const composer = (wrapper: any) =>
@@ -185,4 +186,51 @@ describe('the composer while a reply is in flight', () => {
wrapper.unmount()
})
it('observes dynamic inputs without a runtime Node global', async () => {
vi.stubGlobal('Node', undefined)
const errors: string[] = []
const onWindowError = (event: ErrorEvent) => errors.push(event.message)
window.addEventListener('error', onWindowError)
const wrapper = mount(WidgetBuilder, { props: { widgetId: 'w-1', token: 'tok-1' } })
const hostInput = document.createElement('input')
hostInput.type = 'text'
document.body.appendChild(hostInput)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(errors).toEqual([])
hostInput.remove()
window.removeEventListener('error', onWindowError)
wrapper.unmount()
})
it('removes the same host-message listener before remounting', () => {
const addListener = vi.spyOn(window, 'addEventListener')
const removeListener = vi.spyOn(window, 'removeEventListener')
const firstMount = mount(WidgetBuilder, { props: { widgetId: 'w-1', token: 'tok-1' } })
const messageListeners = () => addListener.mock.calls
.filter(([type]) => type === 'message')
.map(([, listener]) => listener)
const firstHostListener = messageListeners()[0]
expect(firstHostListener).toBeDefined()
firstMount.unmount()
expect(removeListener.mock.calls.some(
([type, listener]) => type === 'message' && listener === firstHostListener
)).toBe(true)
const secondMount = mount(WidgetBuilder, { props: { widgetId: 'w-1', token: 'tok-1' } })
const listenersAfterRemount = messageListeners()
const secondHostListener = listenersAfterRemount[2]
expect(listenersAfterRemount).toHaveLength(4)
expect(secondHostListener).not.toBe(firstHostListener)
expect(removeListener.mock.calls.some(
([type, listener]) => type === 'message' && listener === secondHostListener
)).toBe(false)
secondMount.unmount()
})
})