Files
replypier/frontend/src/__tests__/components/StepTeach.recovery.spec.ts
T

407 lines
16 KiB
TypeScript
Raw Normal View History

// @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()
})
})