feat(agent): 检索工具、模型接入与结构化回复

Agent 按组织与知识源范围调用检索工具,密钥加密保存,回复含来源与转人工意图。
This commit is contained in:
WangShen007 committed 2026-10-02 21:25:00 +08:00
1 parent c7011040c3
commit 2dd062787d
65 files changed
+33409

No files matched your search

@@ -0,0 +1,119 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
// The live/blocked status is an enterprise component (tested there): stub
// the slot so this spec covers the open-source step only.
vi.mock('@/composables/useEnterpriseFeatures', async () => {
const { defineComponent, h } = await import('vue')
const Slot = defineComponent({
props: { widgetId: { type: String, default: null } },
setup: (props) => () => h('p', { class: 'install-slot' }, props.widgetId),
})
return {
useEnterpriseFeatures: () => ({
moduleImports: { widgetInstallStatus: 'install-status' },
enterpriseComponent: () => Slot,
}),
}
})
vi.mock('vue-sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
import { toast } from 'vue-sonner'
import StepLaunch from '@/components/aiagent/onboarding/steps/StepLaunch.vue'
import { WIDGET_INSTALL_GUIDE_URL } from '@/config/docs'
const mountStep = async (widgetId: string | null = 'w-1') => {
const wrapper = mount(StepLaunch, {
props: { widgetId, agentType: 'CUSTOMER_SUPPORT' },
global: { stubs: { 'font-awesome-icon': true } },
})
await flushPromises()
return wrapper
}
describe('StepLaunch', () => {
it('offers a mailto with the real snippet and the install guide', async () => {
const wrapper = await mountStep()
const mailto = wrapper.find('a[href^="mailto:"]').attributes('href')!
const body = new URLSearchParams(mailto.slice('mailto:?'.length)).get('body')!
expect(body).toContain("window.replypierId = 'w-1'")
expect(body).toContain(WIDGET_INSTALL_GUIDE_URL)
expect(mailto).not.toContain('+') // spaces encoded as %20 for mail clients
expect(wrapper.find(`a[href="${WIDGET_INSTALL_GUIDE_URL}"]`).exists()).toBe(true)
})
it('hands the widget to the install status slot', async () => {
const wrapper = await mountStep()
expect(wrapper.find('.install-slot').text()).toBe('w-1')
})
it('offers no mailto without a widget', async () => {
const wrapper = await mountStep(null)
expect(wrapper.find('a[href^="mailto:"]').exists()).toBe(false)
})
})
describe('StepLaunch - copying and help', () => {
const writeText = vi.fn()
beforeEach(() => {
vi.useFakeTimers()
writeText.mockReset().mockResolvedValue(undefined)
Object.assign(navigator, { clipboard: { writeText } })
vi.mocked(toast.error).mockReset()
})
afterEach(() => vi.useRealTimers())
const copyButton = (w: any) => w.find('.copy-btn')
it('copies the snippet and says so on the button, then goes back', async () => {
const wrapper = await mountStep()
await copyButton(wrapper).trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith(expect.stringContaining("window.replypierId = 'w-1'"))
expect(copyButton(wrapper).text()).toBe('已复制')
await vi.advanceTimersByTimeAsync(2000)
expect(copyButton(wrapper).text()).toBe('复制')
})
it('a second copy keeps "Copied" showing for the full time again', async () => {
const wrapper = await mountStep()
await copyButton(wrapper).trigger('click')
await vi.advanceTimersByTimeAsync(1500)
await copyButton(wrapper).trigger('click')
await vi.advanceTimersByTimeAsync(1500)
expect(copyButton(wrapper).text()).toBe('已复制')
})
it('a copy the browser refuses is reported, and the button stays "Copy"', async () => {
writeText.mockRejectedValue(new Error('denied'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const wrapper = await mountStep()
await copyButton(wrapper).trigger('click')
await flushPromises()
expect(toast.error).toHaveBeenCalled()
expect(copyButton(wrapper).text()).toBe('复制')
})
it('without a widget there is nothing to copy or email', async () => {
const wrapper = await mountStep(null)
expect(copyButton(wrapper).attributes('disabled')).toBeDefined()
expect(wrapper.find('a[href^="mailto:"]').exists()).toBe(false)
})
it('the install guide opens in a new tab and says so to screen readers', async () => {
const wrapper = await mountStep()
const guide = wrapper.find(`a[href="${WIDGET_INSTALL_GUIDE_URL}"]`)
expect(guide.attributes('target')).toBe('_blank')
expect(guide.attributes('rel')).toContain('noopener')
expect(guide.text()).toContain('(在新标签页打开)')
})
it('leaving the step mid-"Copied" leaves no timer behind', async () => {
const wrapper = await mountStep()
await copyButton(wrapper).trigger('click')
wrapper.unmount()
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -0,0 +1,269 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
const getOrganization = vi.fn()
const getKnowledgeByAgent = vi.fn()
const getKnowledgeByOrganization = vi.fn()
const getAgentQueueItems = vi.fn()
const addUrls = vi.fn()
const addText = vi.fn()
const planLimits = vi.fn()
vi.mock('@/services/organization', () => ({
organizationService: { getOrganization: (id: string) => getOrganization(id) }
}))
vi.mock('@/services/knowledge', () => ({
knowledgeService: {
getKnowledgeByAgent: (...a: unknown[]) => getKnowledgeByAgent(...a),
getKnowledgeByOrganization: (...a: unknown[]) => getKnowledgeByOrganization(...a),
addUrls: (...a: unknown[]) => addUrls(...a),
addText: (...a: unknown[]) => addText(...a),
getAgentQueueItems: (...a: unknown[]) => getAgentQueueItems(...a)
}
}))
vi.mock('@/utils/storage', () => ({
subscriptionStorage: { getPlanLimits: () => planLimits() }
}))
import StepTeach from '@/components/aiagent/onboarding/steps/StepTeach.vue'
const empty = { knowledge: [], pagination: { total_pages: 0 } }
const mountStep = async () => {
const wrapper = mount(StepTeach, {
props: { agentId: 'agent-1', organizationId: 'org-1' },
global: { plugins: [createPinia()], stubs: { 'font-awesome-icon': true } }
})
await flushPromises()
return wrapper
}
const clickContinue = async (wrapper: any) => {
const cont = wrapper.findAll('button').find((b: any) => b.text().includes('继续'))!
await cont.trigger('click')
await flushPromises()
}
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')
}
const staged = (wrapper: any) =>
wrapper.findAll('.source-list:not(.indexed) .source-name').map((n: any) => n.text())
describe('StepTeach - only advances when the sources were accepted', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
getKnowledgeByAgent.mockResolvedValue(empty)
getKnowledgeByOrganization.mockResolvedValue(empty)
getAgentQueueItems.mockResolvedValue({ queue_items: [] })
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'teeckbox.com' })
planLimits.mockReturnValue({ maxKnowledgeSources: 0 })
})
it('stays on the step and shows the server reason when the add is refused', async () => {
addUrls.mockRejectedValue(new Error('Cannot add URLs: Maximum number of knowledge sources (1) would be exceeded'))
const wrapper = await mountStep()
await clickContinue(wrapper)
expect(wrapper.emitted('next')).toBeFalsy()
expect(wrapper.find('.step-error').text()).toContain('Maximum number of knowledge sources')
// The batch stays staged so the user can fix it.
expect(staged(wrapper)).toEqual(['https://teeckbox.com'])
})
it('stays on the step when the URL already exists', async () => {
addUrls.mockResolvedValue({ error: 'Some URLs already exist in your knowledge base', duplicate_urls: ['https://teeckbox.com'] })
const wrapper = await mountStep()
await clickContinue(wrapper)
expect(wrapper.emitted('next')).toBeFalsy()
expect(wrapper.find('.step-error').text()).toContain('already exist')
})
it('advances once the add is accepted', async () => {
addUrls.mockResolvedValue({ message: 'Queued' })
const wrapper = await mountStep()
await clickContinue(wrapper)
expect(wrapper.emitted('next')).toBeTruthy()
})
it('advances with nothing staged', async () => {
getOrganization.mockResolvedValue({ id: 'org-1', domain: '' })
const wrapper = await mountStep()
await clickContinue(wrapper)
expect(addUrls).not.toHaveBeenCalled()
expect(wrapper.emitted('next')).toBeTruthy()
})
})
describe('StepTeach - plan source cap', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
getKnowledgeByAgent.mockResolvedValue(empty)
getKnowledgeByOrganization.mockResolvedValue(empty)
getAgentQueueItems.mockResolvedValue({ queue_items: [] })
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'teeckbox.com' })
addUrls.mockResolvedValue({ message: 'Queued' })
})
it('on a one-source plan, a typed URL replaces the staged signup domain', async () => {
planLimits.mockReturnValue({ maxKnowledgeSources: 1 })
const wrapper = await mountStep()
await typeAndAdd(wrapper, 'www.teeckbox.com')
expect(staged(wrapper)).toEqual(['https://www.teeckbox.com'])
expect(wrapper.find('.step-error').exists()).toBe(false)
await clickContinue(wrapper)
expect(addUrls.mock.calls[0][1]).toEqual(['https://www.teeckbox.com'])
expect(wrapper.emitted('next')).toBeTruthy()
})
it('says so when the cap is already used by a source on any agent', async () => {
planLimits.mockReturnValue({ maxKnowledgeSources: 1 })
getKnowledgeByOrganization.mockResolvedValue({
knowledge: [{ id: 1, name: 'https://elsewhere.com' }],
pagination: { total_pages: 1, total_count: 1 }
})
const wrapper = await mountStep()
await typeAndAdd(wrapper, 'docs.teeckbox.com')
expect(staged(wrapper)).toEqual([])
expect(wrapper.find('.step-error').text()).toContain('且已在用')
})
it('keeps every staged source when the plan has room', async () => {
planLimits.mockReturnValue({ maxKnowledgeSources: 5 })
const wrapper = await mountStep()
await typeAndAdd(wrapper, 'docs.teeckbox.com')
expect(staged(wrapper)).toEqual(['https://teeckbox.com', 'https://docs.teeckbox.com'])
})
it('counts a crawl that is still running against the cap', async () => {
planLimits.mockReturnValue({ maxKnowledgeSources: 1 })
getAgentQueueItems.mockResolvedValue({
queue_items: [{ id: 9, source: 'https://docs.other.com', source_type: 'website', status: 'processing', created_at: '' }]
})
const wrapper = await mountStep()
await typeAndAdd(wrapper, 'blog.teeckbox.com')
expect(staged(wrapper)).toEqual([])
expect(wrapper.find('.step-error').text()).toContain('且已在用')
})
it('back from a later step, does not re-stage a domain that is still crawling', async () => {
planLimits.mockReturnValue({ maxKnowledgeSources: 1 })
getAgentQueueItems.mockResolvedValue({
queue_items: [{ id: 9, source: 'https://teeckbox.com', source_type: 'website', status: 'pending', created_at: '' }]
})
const wrapper = await mountStep()
expect(staged(wrapper)).toEqual([])
await clickContinue(wrapper)
expect(addUrls).not.toHaveBeenCalled()
expect(wrapper.emitted('next')).toBeTruthy()
})
})
describe('StepTeach - a website that fails fast', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
getKnowledgeByAgent.mockResolvedValue(empty)
getKnowledgeByOrganization.mockResolvedValue(empty)
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'erfanpv.com' })
planLimits.mockReturnValue({ maxKnowledgeSources: 0 })
addUrls.mockResolvedValue({ message: 'Queued' })
})
const failedItem = {
id: 7, source: 'https://erfanpv.com', source_type: 'website', status: 'failed', created_at: '',
error: 'raw', reason: 'This site could not be found. Check the address for typos.',
}
it('stays, explains, and offers to paste text instead', async () => {
getAgentQueueItems.mockResolvedValueOnce({ queue_items: [] }).mockResolvedValue({ queue_items: [failedItem] })
const wrapper = await mountStep()
await clickContinue(wrapper)
expect(wrapper.emitted('next')).toBeFalsy()
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')
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 flushPromises()
expect(addText).toHaveBeenCalledWith('org-1', expect.stringMatching(/^About us \(/), expect.stringContaining('handmade candles'), 'agent-1')
expect(wrapper.emitted('next')).toBeTruthy()
})
it('offers a PDF upload too', async () => {
getAgentQueueItems.mockResolvedValueOnce({ queue_items: [] }).mockResolvedValue({ queue_items: [failedItem] })
const wrapper = await mountStep()
await clickContinue(wrapper)
const picker = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {})
await wrapper.find('.crawl-failure').findAll('button').find((b: any) => b.text() === '上传 PDF')!.trigger('click')
expect(picker).toHaveBeenCalled()
picker.mockRestore()
})
it('lets them continue without it', async () => {
getAgentQueueItems.mockResolvedValueOnce({ queue_items: [] }).mockResolvedValue({ queue_items: [failedItem] })
const wrapper = await mountStep()
await clickContinue(wrapper)
await clickContinue(wrapper)
expect(wrapper.emitted('next')).toBeTruthy()
})
it('locks the step while the sources are being sent', async () => {
getAgentQueueItems.mockResolvedValue({ queue_items: [] })
let accept!: (v: unknown) => void
addUrls.mockReturnValue(new Promise((resolve) => (accept = resolve)))
const wrapper = await mountStep()
await clickContinue(wrapper)
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(wrapper.find('input[type="text"]').attributes('disabled')).toBeDefined()
expect(wrapper.find('.source-remove').attributes('disabled')).toBeDefined()
expect(wrapper.emitted('next')).toBeFalsy()
accept({ message: 'URLs queued for processing' })
await flushPromises()
expect(wrapper.emitted('next')).toBeTruthy()
})
it('a crawl that is still running does not hold the step: Continue moves on at once', async () => {
getAgentQueueItems.mockResolvedValueOnce({ queue_items: [] }).mockResolvedValue({
queue_items: [{ id: 1, source: 'https://erfanpv.com', source_type: 'website', status: 'pending', created_at: '' }]
})
const wrapper = await mountStep()
await clickContinue(wrapper)
expect(wrapper.emitted('next')).toBeTruthy()
expect(wrapper.find('.crawl-failure').exists()).toBe(false)
})
})
@@ -0,0 +1,133 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
const getOrganization = vi.fn()
const getKnowledgeByAgent = vi.fn()
const getKnowledgeByOrganization = vi.fn()
const addUrls = vi.fn()
vi.mock('@/services/organization', () => ({
organizationService: { getOrganization: (id: string) => getOrganization(id) }
}))
vi.mock('@/services/knowledge', () => ({
knowledgeService: {
getKnowledgeByAgent: (...a: unknown[]) => getKnowledgeByAgent(...a),
getKnowledgeByOrganization: (...a: unknown[]) => getKnowledgeByOrganization(...a),
addUrls: (...a: unknown[]) => addUrls(...a),
getAgentQueueItems: vi.fn().mockResolvedValue({ queue_items: [] })
}
}))
import StepTeach from '@/components/aiagent/onboarding/steps/StepTeach.vue'
const empty = { knowledge: [], pagination: { total_pages: 0 } }
const mountStep = async () => {
const wrapper = mount(StepTeach, {
props: { agentId: 'agent-1', organizationId: 'org-1' },
global: { plugins: [createPinia()], stubs: { 'font-awesome-icon': true } }
})
await flushPromises()
return wrapper
}
const urlInput = (wrapper: any) =>
wrapper.find('input[type="text"]').element as HTMLInputElement
describe('StepTeach - signup domain prefill', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
getKnowledgeByAgent.mockResolvedValue(empty)
getKnowledgeByOrganization.mockResolvedValue(empty)
addUrls.mockResolvedValue({ message: 'Queued' })
})
it('stages the signup domain as a source, scheme added', async () => {
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'testypo.com' })
const wrapper = await mountStep()
// Visible as an added source, not parked in the input where it reads as a
// placeholder the user must still press "Add" to accept.
expect(wrapper.find('.source-list').text()).toContain('https://testypo.com')
expect(urlInput(wrapper).value).toBe('')
})
it('does not suggest a domain the server would refuse', async () => {
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'localhost' })
const wrapper = await mountStep()
expect(wrapper.find('.source-list').exists()).toBe(false)
expect(urlInput(wrapper).value).toBe('')
})
it('lets the suggestion be removed', async () => {
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'testypo.com' })
const wrapper = await mountStep()
await wrapper.find('.source-remove').trigger('click')
expect(wrapper.find('.source-list').exists()).toBe(false)
})
it('leaves a domain that already has a scheme alone', async () => {
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'https://example.com' })
const wrapper = await mountStep()
expect(wrapper.find('.source-list').text()).toContain('https://example.com')
})
it('does not re-add a domain that is already indexed, or nag about it', async () => {
getKnowledgeByAgent.mockResolvedValue({
knowledge: [{ id: 1, name: 'https://testypo.com' }],
pagination: { total_pages: 1 }
})
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'testypo.com' })
const wrapper = await mountStep()
expect(wrapper.find('.step-error').exists()).toBe(false)
// Present once, as the already-indexed entry — not staged a second time.
expect(wrapper.findAll('.source-row')).toHaveLength(1)
})
it('renders normally when the org lookup fails', async () => {
getOrganization.mockRejectedValue(new Error('boom'))
const wrapper = await mountStep()
expect(urlInput(wrapper).value).toBe('')
expect(wrapper.find('.source-list').exists()).toBe(false)
expect(wrapper.text()).toContain('让它了解你的业务')
})
it('stages a typed URL on Continue without needing "Add"', async () => {
getOrganization.mockResolvedValue({ id: 'org-1', domain: '' })
const wrapper = await mountStep()
await wrapper.find('input[type="text"]').setValue('docs.company.com')
const cont = wrapper.findAll('button').find((b: any) => b.text().includes('继续'))!
await cont.trigger('click')
await flushPromises()
expect(wrapper.emitted('next')).toBeTruthy()
})
it('does not advance when the typed URL is invalid', async () => {
getOrganization.mockResolvedValue({ id: 'org-1', domain: '' })
const wrapper = await mountStep()
await wrapper.find('input[type="text"]').setValue('not a url')
const cont = wrapper.findAll('button').find((b: any) => b.text().includes('继续'))!
await cont.trigger('click')
await flushPromises()
expect(wrapper.emitted('next')).toBeFalsy()
expect(wrapper.find('.step-error').text()).toContain('请输入有效的')
})
})
@@ -0,0 +1,407 @@
// @vitest-environment jsdom
// What the Teach step does after a website couldn't be read: every way out of
// the failure panel, and that it never outlives the attempt it describes.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
const getOrganization = vi.fn()
const getKnowledgeByAgent = vi.fn()
const getKnowledgeByOrganization = vi.fn()
const getAgentQueueItems = vi.fn()
const addUrls = vi.fn()
const addText = vi.fn()
const uploadPdfFiles = vi.fn()
const planLimits = vi.fn()
vi.mock('@/services/organization', () => ({
organizationService: { getOrganization: (id: string) => getOrganization(id) }
}))
vi.mock('@/services/knowledge', () => ({
knowledgeService: {
getKnowledgeByAgent: (...a: unknown[]) => getKnowledgeByAgent(...a),
getKnowledgeByOrganization: (...a: unknown[]) => getKnowledgeByOrganization(...a),
addUrls: (...a: unknown[]) => addUrls(...a),
addText: (...a: unknown[]) => addText(...a),
uploadPdfFiles: (...a: unknown[]) => uploadPdfFiles(...a),
getAgentQueueItems: (...a: unknown[]) => getAgentQueueItems(...a)
}
}))
vi.mock('@/utils/storage', () => ({
subscriptionStorage: { getPlanLimits: () => planLimits() }
}))
import StepTeach from '@/components/aiagent/onboarding/steps/StepTeach.vue'
const empty = { knowledge: [], pagination: { total_pages: 0 } }
const failed = (id: number, source: string, reason = 'This site could not be found.') => ({
id, source, source_type: 'website', status: 'failed', created_at: '', error: 'raw', reason,
})
const firstSite = failed(7, 'https://erfanpv.com')
let wrapper: VueWrapper<any>
const mountStep = async () => {
wrapper = mount(StepTeach, {
props: { agentId: 'agent-1', organizationId: 'org-1' },
global: { plugins: [createPinia()], stubs: { 'font-awesome-icon': true } },
attachTo: document.body,
})
await flushPromises()
return wrapper
}
const button = (label: string) => wrapper.findAll('button').find((b) => b.text() === label)
const primary = () => wrapper.find('.btn-accent')
const panel = () => wrapper.find('.crawl-failure')
const staged = () => wrapper.findAll('.source-list:not(.indexed) .source-name').map((n) => n.text())
const clickContinue = async () => {
await primary().trigger('click')
await flushPromises()
}
const typeAndAdd = async (url: string) => {
await wrapper.find('input[type="text"]').setValue(url)
await button('添加')!.trigger('click')
await flushPromises()
}
const pickPdfs = async (...names: string[]) => {
const input = wrapper.find('input[type="file"]')
const picked = names.map((n) => new File(['%PDF'], n, { type: 'application/pdf' }))
Object.defineProperty(input.element, 'files', { value: picked, configurable: true })
await input.trigger('change')
await flushPromises()
}
// Mount with the signup domain staged, Continue, and have it fail fast.
const failFirst = async () => {
getAgentQueueItems.mockResolvedValueOnce({ queue_items: [] }).mockResolvedValue({ queue_items: [firstSite] })
await mountStep()
await clickContinue()
expect(panel().exists()).toBe(true)
}
const openPaste = async () => {
await button('改为粘贴文本')!.trigger('click')
await flushPromises()
}
describe('StepTeach - after a website could not be read', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
getKnowledgeByAgent.mockResolvedValue(empty)
getKnowledgeByOrganization.mockResolvedValue(empty)
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'erfanpv.com' })
planLimits.mockReturnValue({ maxKnowledgeSources: 0 })
addUrls.mockResolvedValue({ message: 'Queued' })
uploadPdfFiles.mockResolvedValue({ message: 'Uploaded' })
})
afterEach(() => wrapper?.unmount())
it('moves focus to the address box so another site can be tried', async () => {
await failFirst()
expect(document.activeElement).toBe(wrapper.find('input[type="text"]').element)
})
it('staging a new URL clears the panel and brings back Continue, which checks again', async () => {
await failFirst()
expect(primary().text()).toContain('跳过并继续')
await typeAndAdd('replypier.site')
expect(panel().exists()).toBe(false)
expect(primary().text()).toMatch(/^继续\s*→$/)
expect(staged()).toEqual(['https://replypier.site'])
await clickContinue()
expect(addUrls).toHaveBeenLastCalledWith('org-1', ['https://replypier.site'], 'agent-1', expect.any(Function))
expect(wrapper.emitted('next')).toBeTruthy()
})
it('a URL typed but not added yet already reads as Continue', async () => {
await failFirst()
await wrapper.find('input[type="text"]').setValue('replypier.site')
expect(primary().text()).toMatch(/^继续\s*→$/)
await clickContinue()
expect(addUrls).toHaveBeenLastCalledWith('org-1', ['https://replypier.site'], 'agent-1', expect.any(Function))
expect(wrapper.emitted('next')).toBeTruthy()
})
it('removing the new source leaves nothing to send, so it moves on without the site', async () => {
await failFirst()
await typeAndAdd('replypier.site')
await wrapper.find('.source-remove').trigger('click')
expect(staged()).toEqual([])
expect(document.activeElement).toBe(wrapper.find('input[type="text"]').element)
expect(primary().text()).toMatch(/^继续\s*→$/)
await clickContinue()
expect(addUrls).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('next')).toBeTruthy()
})
it('a second failure replaces the first and starts from the choice again', async () => {
await failFirst()
await openPaste()
expect(wrapper.find('textarea').exists()).toBe(true)
// Leave the paste box open, then retry with another site that also fails.
await typeAndAdd('erfanpv.in')
getAgentQueueItems.mockResolvedValue({ queue_items: [failed(8, 'https://erfanpv.in', 'Nothing answered there.'), firstSite] })
await clickContinue()
expect(wrapper.emitted('next')).toBeFalsy()
expect(panel().text()).toContain("无法读取 https://erfanpv.in。Nothing answered there.")
expect(panel().text()).not.toContain('erfanpv.com')
expect(wrapper.find('textarea').exists()).toBe(false)
expect(button('改为粘贴文本')).toBeTruthy()
expect(primary().text()).toContain('跳过并继续')
})
it('a URL already staged says so and keeps the panel', async () => {
await failFirst()
await typeAndAdd('replypier.site')
await typeAndAdd('replypier.site')
expect(wrapper.find('[role="alert"]').text()).toContain('该 URL 已添加到当前批次')
expect(staged()).toEqual(['https://replypier.site'])
})
it('a duplicate URL with nothing new staged keeps the failure on screen', async () => {
getKnowledgeByOrganization.mockResolvedValue({ knowledge: [{ id: 1, name: 'https://docs.erfanpv.com' }], pagination: { total_pages: 1, total_count: 1 } })
await failFirst()
await typeAndAdd('docs.erfanpv.com')
expect(wrapper.text()).toContain('该 URL 已存在于你的知识库中')
expect(panel().exists()).toBe(true)
expect(primary().text()).toContain('跳过并继续')
})
it('the plan cap message keeps the failure on screen when nothing could be staged', async () => {
planLimits.mockReturnValue({ maxKnowledgeSources: 1 })
getKnowledgeByOrganization.mockResolvedValue({ knowledge: [{ id: 1, name: 'https://elsewhere.com' }], pagination: { total_pages: 1, total_count: 1 } })
getOrganization.mockResolvedValue({ id: 'org-1', domain: '' })
getAgentQueueItems.mockResolvedValueOnce({ queue_items: [] }).mockResolvedValue({ queue_items: [firstSite] })
await mountStep()
await typeAndAdd('erfanpv.com')
// The cap is used up, so nothing was staged and nothing was sent.
expect(wrapper.find('[role="alert"]').text()).toContain('且已在用')
expect(panel().exists()).toBe(false)
})
it('picking a PDF from the panel clears it and Continue uploads it', async () => {
await failFirst()
await pickPdfs('menu.pdf')
expect(panel().exists()).toBe(false)
expect(staged()).toEqual(['menu.pdf'])
expect(primary().text()).toMatch(/^继续\s*→$/)
await clickContinue()
expect(uploadPdfFiles).toHaveBeenCalled()
expect(wrapper.emitted('next')).toBeTruthy()
})
it('continue without it moves on and sends nothing', async () => {
await failFirst()
await clickContinue()
expect(addUrls).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('next')).toBeTruthy()
})
it('back on the step later, shows the failure again instead of re-staging the same site', async () => {
getAgentQueueItems.mockResolvedValue({ queue_items: [firstSite] })
await mountStep()
expect(staged()).toEqual([])
expect(panel().text()).toContain("无法读取 https://erfanpv.com")
expect(button('改为粘贴文本')).toBeTruthy()
expect(primary().text()).toContain('跳过并继续')
})
it('back on the step with something already indexed, the old failure stays out of the way', async () => {
getKnowledgeByAgent.mockResolvedValue({ knowledge: [{ id: 3, name: 'About us' }], pagination: { total_pages: 1 } })
getAgentQueueItems.mockResolvedValue({ queue_items: [firstSite] })
await mountStep()
expect(panel().exists()).toBe(false)
})
})
describe('StepTeach - paste text instead', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
getKnowledgeByAgent.mockResolvedValue(empty)
getKnowledgeByOrganization.mockResolvedValue(empty)
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'erfanpv.com' })
planLimits.mockReturnValue({ maxKnowledgeSources: 0 })
addUrls.mockResolvedValue({ message: 'Queued' })
})
afterEach(() => wrapper?.unmount())
const longText = 'We sell handmade candles and ship across India within five days.'
it('opens with the box focused', async () => {
await failFirst()
await openPaste()
expect(document.activeElement).toBe(wrapper.find('textarea').element)
})
it('can be cancelled back to the choice, focus returned to it', async () => {
await failFirst()
await openPaste()
await button('取消')!.trigger('click')
await flushPromises()
expect(wrapper.find('textarea').exists()).toBe(false)
expect(panel().text()).toContain("无法读取")
expect(document.activeElement).toBe(button('改为粘贴文本')!.element)
expect(primary().text()).toContain('跳过并继续')
})
it('Esc cancels too', async () => {
await failFirst()
await openPaste()
await wrapper.find('textarea').trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(wrapper.find('textarea').exists()).toBe(false)
expect(button('改为粘贴文本')).toBeTruthy()
})
it('Esc cancels from the buttons as well, e.g. after a too-short attempt', async () => {
await failFirst()
await openPaste()
await wrapper.find('textarea').setValue('Too short.')
await button('添加文本')!.trigger('click')
await button('添加文本')!.trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(wrapper.find('textarea').exists()).toBe(false)
})
it('adds the text and moves on', async () => {
addText.mockResolvedValue({ message: 'ok' })
await failFirst()
await openPaste()
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(wrapper.emitted('next')).toBeTruthy()
})
it('says why when the text is too short, and sends nothing', async () => {
await failFirst()
await openPaste()
await wrapper.find('textarea').setValue('We sell candles.')
await button('添加文本')!.trigger('click')
await flushPromises()
expect(addText).not.toHaveBeenCalled()
expect(wrapper.find('.text-fallback [role="alert"]').text()).toContain('请多写几句话')
// Typing more clears it.
await wrapper.find('textarea').setValue(longText)
expect(wrapper.find('.text-fallback [role="alert"]').exists()).toBe(false)
})
it('keeps the text and stays put when the server refuses it', async () => {
addText.mockRejectedValue(new Error('A source with this title already exists'))
await failFirst()
await openPaste()
await wrapper.find('textarea').setValue(longText)
await button('添加文本')!.trigger('click')
await flushPromises()
expect(wrapper.emitted('next')).toBeFalsy()
expect(wrapper.find('.text-fallback [role="alert"]').text()).toContain('already exists')
expect((wrapper.find('textarea').element as HTMLTextAreaElement).value).toBe(longText)
})
it('holds the rest of the step still while the text is saving', async () => {
let finish!: (v: unknown) => void
addText.mockReturnValue(new Promise((resolve) => (finish = resolve)))
await failFirst()
await openPaste()
await wrapper.find('textarea').setValue(longText)
await button('添加文本')!.trigger('click')
await flushPromises()
expect(button('添加中…')!.attributes('disabled')).toBeDefined()
expect(button('取消')!.attributes('disabled')).toBeDefined()
expect(button('返回')!.attributes('disabled')).toBeDefined()
expect(primary().attributes('disabled')).toBeDefined()
finish({ message: 'ok' })
await flushPromises()
expect(wrapper.emitted('next')).toBeTruthy()
})
})
describe('StepTeach - staged PDFs', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
getKnowledgeByAgent.mockResolvedValue(empty)
getKnowledgeByOrganization.mockResolvedValue(empty)
getAgentQueueItems.mockResolvedValue({ queue_items: [] })
getOrganization.mockResolvedValue({ id: 'org-1', domain: '' })
planLimits.mockReturnValue({ maxKnowledgeSources: 0 })
})
afterEach(() => wrapper?.unmount())
it('a second pick adds to the list, and the same PDF twice says so', async () => {
await mountStep()
await pickPdfs('menu.pdf')
await pickPdfs('prices.pdf')
expect(staged()).toEqual(['menu.pdf', 'prices.pdf'])
await pickPdfs('menu.pdf')
expect(staged()).toEqual(['menu.pdf', 'prices.pdf'])
expect(wrapper.find('[role="alert"]').text()).toContain('该 PDF 已在列表中')
})
it('removing a source clears an error about the batch', async () => {
addUrls.mockResolvedValue({ error: 'Some URLs already exist in your knowledge base' })
await mountStep()
await typeAndAdd('acme.com')
await clickContinue()
expect(wrapper.text()).toContain('Some URLs already exist')
await wrapper.find('button[aria-label="移除 https://acme.com"]').trigger('click')
expect(wrapper.text()).not.toContain('Some URLs already exist')
})
it('a site that failed is still reported when a PDF in the same batch fails', async () => {
getAgentQueueItems.mockResolvedValueOnce({ queue_items: [] }).mockResolvedValue({ queue_items: [firstSite] })
uploadPdfFiles.mockRejectedValue(new Error('That PDF is too large.'))
addUrls.mockResolvedValue({ message: 'Queued' })
await mountStep()
await typeAndAdd('https://erfanpv.com')
await pickPdfs('menu.pdf')
await clickContinue()
expect(panel().exists()).toBe(true)
expect(panel().text()).toContain("无法读取 https://erfanpv.com。")
expect(wrapper.text()).toContain('That PDF is too large.')
expect(wrapper.emitted('next')).toBeFalsy()
})
it('a PDF that fails keeps the step even when the site is fine', async () => {
const reading = { ...firstSite, status: 'pending', reason: null }
getAgentQueueItems.mockResolvedValueOnce({ queue_items: [] }).mockResolvedValue({ queue_items: [reading] })
uploadPdfFiles.mockRejectedValue(new Error('That PDF is too large.'))
addUrls.mockResolvedValue({ message: 'Queued' })
await mountStep()
await typeAndAdd('https://erfanpv.com')
await pickPdfs('menu.pdf')
await clickContinue()
expect(addUrls).toHaveBeenCalled() // the site went in
expect(panel().exists()).toBe(false)
expect(wrapper.text()).toContain('That PDF is too large.')
expect(wrapper.emitted('next')).toBeFalsy()
})
})
@@ -0,0 +1,77 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { ref } from 'vue'
const getAgentQueueItems = vi.fn()
const getKnowledgeByAgent = vi.fn()
vi.mock('@/services/knowledge', () => ({
knowledgeService: {
getAgentQueueItems: (...a: unknown[]) => getAgentQueueItems(...a),
getKnowledgeByAgent: (...a: unknown[]) => getKnowledgeByAgent(...a)
}
}))
vi.mock('@/services/widget', () => ({ widgetService: { createAgentWidget: vi.fn() } }))
vi.mock('@/composables/useOnboardingTestChat', () => ({
useOnboardingTestChat: () => ({
messages: ref([]), loading: ref(false), connecting: ref(false), connectionError: ref(''),
start: vi.fn(), send: vi.fn(), cleanup: vi.fn()
})
}))
import StepTest from '@/components/aiagent/onboarding/steps/StepTest.vue'
const SITE = 'https://teeckbox.com'
const REASON = 'This site could not be found — the domain does not resolve.'
const queue = (status: string) => ({
queue_items: [{ id: 1, source: SITE, source_type: 'website', status, created_at: '',
reason: status === 'failed' ? REASON : null }]
})
const knowledgePage = (count: number) => ({ knowledge: [], pagination: { total_count: count } })
const mountStep = async () => {
const wrapper = mount(StepTest, { props: { agentId: 'agent-1', agentName: 'Rex', widgetId: 'w-1' } })
await flushPromises()
return wrapper
}
describe('StepTest - what the agent knows', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => vi.useRealTimers())
it('a crawl that fails after Teach says why, not "Knowledge indexed"', async () => {
getAgentQueueItems.mockResolvedValueOnce(queue('pending')).mockResolvedValue(queue('failed'))
getKnowledgeByAgent.mockResolvedValue(knowledgePage(0))
const wrapper = await mountStep()
expect(wrapper.text()).toContain('知识库仍在后台建立索引')
await vi.advanceTimersByTimeAsync(5000)
await flushPromises()
expect(wrapper.text()).not.toContain('知识库已索引')
expect(wrapper.text()).toContain(`无法读取 ${SITE}。${REASON}`)
expect(wrapper.text()).toContain('它还完全不了解你的业务')
})
it('a crawl that finishes says it is indexed', async () => {
getAgentQueueItems.mockResolvedValueOnce(queue('pending')).mockResolvedValue(queue('completed'))
getKnowledgeByAgent.mockResolvedValue(knowledgePage(1))
const wrapper = await mountStep()
await vi.advanceTimersByTimeAsync(5000)
await flushPromises()
expect(wrapper.text()).toContain('知识库已索引')
})
it('arriving after the failure shows it too', async () => {
getAgentQueueItems.mockResolvedValue(queue('failed'))
getKnowledgeByAgent.mockResolvedValue(knowledgePage(0))
const wrapper = await mountStep()
expect(wrapper.text()).toContain(`无法读取 ${SITE}。`)
})
})
@@ -0,0 +1,62 @@
/*
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 { describe, it, expect, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
vi.mock('@/services/agent', () => ({
agentService: { getGuardrailDefault: vi.fn().mockResolvedValue('') },
}))
import AgentInstructionsTab from '@/components/agent/AgentInstructionsTab.vue'
const mountTab = async (props: Record<string, unknown> = {}) => {
const wrapper = mount(AgentInstructionsTab, {
props: { instructions: 'Be helpful', transferToHuman: false, askForRating: false, isEditing: true,
agent: { id: 'agent-1', name: 'Ava', display_name: 'Ava' }, userGroups: [], selectedGroupIds: [],
...props },
global: { directives: { tooltip: {} }, stubs: { 'font-awesome-icon': true } },
})
await flushPromises()
return wrapper
}
const buttonsSwitch = (wrapper: any) => wrapper.find('input[aria-label="消息渠道上的回复按钮"]')
describe('AgentInstructionsTab reply buttons', () => {
it('is off by default and named for the channels it covers', async () => {
const wrapper = await mountTab()
expect((buttonsSwitch(wrapper).element as HTMLInputElement).checked).toBe(false)
expect(wrapper.text()).toContain('在 WhatsApp、Messenger、Instagram 和 Telegram 上把选项显示为可点击按钮,网页组件不支持。')
})
it('is hidden when the AI does not answer', async () => {
const wrapper = await mountTab({ aiRepliesEnabled: false })
expect(buttonsSwitch(wrapper).exists()).toBe(false)
})
it('saves the choice', async () => {
const wrapper = await mountTab()
await buttonsSwitch(wrapper).setValue(true)
await wrapper.find('.save-button').trigger('click')
expect(wrapper.emitted('save-agent')![0][0]).toMatchObject({ channelQuickReplies: true })
})
it('reflects an agent that has it on', async () => {
const wrapper = await mountTab({ channelQuickReplies: true })
expect((buttonsSwitch(wrapper).element as HTMLInputElement).checked).toBe(true)
})
})
@@ -0,0 +1,108 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
const getProgress = vi.fn()
const getAgentById = vi.fn()
// The enterprise module's setup service; null when the module is absent
let enterprise = true
vi.mock('@/composables/useEnterpriseFeatures', () => ({
useEnterpriseFeatures: () => ({
moduleImports: { setupService: 'setup-service' },
loadModule: async () => (enterprise ? { setupService: { getProgress: () => getProgress() } } : null),
})
}))
vi.mock('@/services/agent', () => ({
agentService: { getAgentById: (id: string) => getAgentById(id) }
}))
const createAgentWidget = vi.fn()
vi.mock('@/services/widget', () => ({
widgetService: { createAgentWidget: (...a: unknown[]) => createAgentWidget(...a) }
}))
vi.mock('@/services/user', () => ({
userService: { getCurrentUser: () => ({ organization_id: 'org-1' }) }
}))
vi.mock('@/utils/permissions', () => ({ permissionChecks: { canManageAgents: () => true } }))
const STORAGE_KEY = 'replypier:onboarding:v1'
const stubs = {
StepCreate: {
emits: ['created'],
template: `<button class="step-create" @click="$emit('created', { id: 'agent-1', name: 'Tom', agent_type: 'CUSTOMER_SUPPORT' })" />`,
},
StepTeach: { props: ['agentId'], template: '<div class="step-teach">{{ agentId }}</div>' },
StepTest: { props: ['widgetId'], template: '<div class="step-test">{{ widgetId }}</div>' },
StepLaunch: { props: ['widgetId'], template: '<div class="step-launch">{{ widgetId }}</div>' },
}
const mountWizard = async () => {
vi.resetModules()
const { default: AIAgentOnboarding } = await import('@/components/aiagent/onboarding/AIAgentOnboarding.vue')
const wrapper = mount(AIAgentOnboarding, { global: { stubs } })
await flushPromises()
return wrapper
}
describe('AIAgentOnboarding - where it resumes', () => {
beforeEach(() => {
localStorage.clear()
enterprise = true
getProgress.mockReset()
createAgentWidget.mockReset().mockResolvedValue({ id: 'w-new' })
getAgentById.mockReset().mockResolvedValue({ id: 'agent-9', name: 'Tom', agent_type: 'CUSTOMER_SUPPORT' })
})
it('on a fresh device, opens at the step the server reports', async () => {
getProgress.mockResolvedValue({ step: 'test', agent_id: 'agent-9', widget_id: 'w-9', hidden: false })
const wrapper = await mountWizard()
expect(wrapper.find('.step-test').text()).toBe('w-9')
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)['org-1'].agentId).toBe('agent-9')
})
it('opens Launch for an org that has tested but is not live', async () => {
getProgress.mockResolvedValue({ step: 'launch', agent_id: 'agent-9', widget_id: 'w-9', hidden: false })
const wrapper = await mountWizard()
expect(wrapper.find('.step-launch').exists()).toBe(true)
})
it('starts at Create when the org has no agent', async () => {
getProgress.mockResolvedValue({ step: 'create', agent_id: null, widget_id: null, hidden: false })
const wrapper = await mountWizard()
expect(wrapper.find('.step-create').exists()).toBe(true)
})
it("prefers this browser's own run over the server", async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
'org-1': { skipped: false, currentStep: 1, completedSteps: [0], agentId: 'agent-9', agentName: 'Tom', widgetId: null, updatedAt: 1 },
}))
const wrapper = await mountWizard()
expect(getProgress).not.toHaveBeenCalled()
expect(wrapper.find('.step-teach').text()).toBe('agent-9')
})
it('creates the widget with the agent, so it exists if they stop at Teach', async () => {
getProgress.mockResolvedValue({ step: 'create', agent_id: null, widget_id: null, hidden: false })
const wrapper = await mountWizard()
await wrapper.find('.step-create').trigger('click')
await flushPromises()
expect(createAgentWidget).toHaveBeenCalledWith('agent-1', 'Tom')
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)['org-1'].widgetId).toBe('w-new')
expect(wrapper.find('.step-teach').exists()).toBe(true)
})
it('still moves on to Teach if the widget could not be created', async () => {
getProgress.mockResolvedValue({ step: 'create', agent_id: null, widget_id: null, hidden: false })
createAgentWidget.mockRejectedValue(new Error('500'))
const wrapper = await mountWizard()
await wrapper.find('.step-create').trigger('click')
await flushPromises()
expect(wrapper.find('.step-teach').exists()).toBe(true)
})
it('in open source, resumes only from this browser', async () => {
enterprise = false
const wrapper = await mountWizard()
expect(getProgress).not.toHaveBeenCalled()
expect(wrapper.find('.step-create').exists()).toBe(true)
})
})
@@ -0,0 +1,146 @@
/*
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 { describe, it, expect, beforeEach } 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
const createWrapper = (props = {}) => {
return mount(ChatMessage, {
props: {
role: 'user',
content: 'Test message',
...props
}
})
}
it('renders user message properly', () => {
wrapper = createWrapper({
role: 'user',
content: 'Hello, world!'
})
expect(wrapper.find('.message').exists()).toBe(true)
expect(wrapper.find('.message').classes()).toContain('user')
expect(wrapper.find('.bubble').text()).toBe('Hello, world!')
expect(wrapper.find('.user-icon').exists()).toBe(true)
})
it('renders bot message properly', () => {
wrapper = createWrapper({
role: 'bot',
content: 'I am a bot',
agent: 'Test Bot'
})
expect(wrapper.find('.message').exists()).toBe(true)
expect(wrapper.find('.robot-icon').exists()).toBe(true)
expect(wrapper.find('.bubble').text()).toBe('I am a bot')
expect(wrapper.find('.robot-icon').attributes('alt')).toBe('Test Bot')
})
it('renders error message properly', () => {
wrapper = createWrapper({
role: 'error',
content: 'An error occurred'
})
expect(wrapper.find('.message').exists()).toBe(true)
expect(wrapper.find('.message').classes()).toContain('error')
expect(wrapper.find('.bubble').text()).toBe('An error occurred')
expect(wrapper.find('.avatar').text()).toBe('⚠️')
})
it('shows loading state for bot messages', () => {
wrapper = createWrapper({
role: 'bot',
content: '',
isLoading: true
})
expect(wrapper.find('.typing-indicator').exists()).toBe(true)
expect(wrapper.findAll('.typing-indicator span')).toHaveLength(3)
})
it('uses custom avatar for bot when provided', () => {
const customAvatar = 'custom-bot.png'
wrapper = createWrapper({
role: 'bot',
content: 'Custom avatar message',
avatar: customAvatar
})
const avatarImg = wrapper.find('.robot-icon')
expect(avatarImg.exists()).toBe(true)
expect(avatarImg.attributes('src')).toBe(customAvatar)
})
it('uses default robot icon when no avatar is provided for bot', () => {
wrapper = createWrapper({
role: 'bot',
content: 'Default avatar message'
})
const avatarImg = wrapper.find('.robot-icon')
expect(avatarImg.exists()).toBe(true)
expect(avatarImg.attributes('src')).toBe(robotIcon)
})
it('applies correct styling classes', () => {
wrapper = createWrapper({
role: 'user',
content: 'Style test message'
})
expect(wrapper.find('.message').classes()).toContain('user')
expect(wrapper.find('.avatar').exists()).toBe(true)
expect(wrapper.find('.bubble').exists()).toBe(true)
})
it('handles empty content properly', () => {
wrapper = createWrapper({
role: 'user',
content: ''
})
expect(wrapper.find('.bubble').text()).toBe('')
})
it('uses correct alt text for bot avatar', () => {
wrapper = createWrapper({
role: 'bot',
content: 'Bot message',
agent: 'Custom Agent'
})
expect(wrapper.find('.robot-icon').attributes('alt')).toBe('Custom Agent')
})
it('uses default alt text for bot avatar when no agent name is provided', () => {
wrapper = createWrapper({
role: 'bot',
content: 'Bot message'
})
expect(wrapper.find('.robot-icon').attributes('alt')).toBe('AI 助手')
})
})