import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { initChat, sendQuickReply } from '../src/chat'; import { setApiConfig, clearApiConfig } from '../src/api'; import { parseConfig } from '../src/config'; import { renderAIBubble, createEmptyAIBubble, renderSources } from '../src/dom'; import { saveMessages } from '../src/storage'; vi.mock('../src/dom', () => ({ renderUserBubble: vi.fn(), renderAIBubble: vi.fn(() => ({ id: 'sync-wrapper' })), createEmptyAIBubble: vi.fn(() => ({ wrapper: { id: 'stream-wrapper' }, bubble: {} })), renderSources: vi.fn(), finalizeAIBubble: vi.fn(), scrollToBottom: vi.fn(), hideOfflineBanner: vi.fn(), showOfflineBanner: vi.fn(), renderErrorBubble: vi.fn(), })); vi.mock('../src/storage', () => ({ saveMessages: vi.fn(), clearMessages: vi.fn() })); class ElementStub extends EventTarget { style: Record = {}; classList = { add: vi.fn(), remove: vi.fn() }; setAttribute() {} removeAttribute() {} querySelector() { return null; } querySelectorAll() { return []; } } const encoder = new TextEncoder(); const source = { documentId: '1234567890123456789', title: 'same answer', sourceName: null, chunkIndex: null, score: null, snippet: null }; const event = (value: unknown) => `data: ${JSON.stringify(value)}\n\n`; const answer = event({ choices: [{ delta: { content: 'answer' } }] }); const sources = event({ choices: [], sources: [source] }); let clearButton: ElementStub; let sender: ElementStub; function setup(streaming: boolean) { const config = parseConfig({ integrateId: '42', requestDomain: 'https://example.test', streaming, suggestions: false })!; config.chatId = 'conversation'; setApiConfig(config); clearButton = new ElementStub(); sender = new ElementStub(); initChat(config, { messagesContainer: new ElementStub(), inputEl: sender, clearBtn: clearButton, categorySelect: null, roleSelect: null, historyPanel: new ElementStub(), welcomeEl: new ElementStub(), newMsgBtn: new ElementStub(), searchInput: null, ariaLiveEl: new ElementStub(), showLoading: vi.fn(), hideLoading: vi.fn(), } as unknown as Parameters[1]); clearButton.dispatchEvent(new Event('click')); vi.clearAllMocks(); } function stream(body: string) { return new Response(new ReadableStream({ start(c) { c.enqueue(encoder.encode(body)); c.close(); } })); } beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { clearApiConfig(); vi.unstubAllGlobals(); }); describe('SDK message references belong to the producing request', () => { it.each([true, false])('makes one answer request and persists/renders its sources (streaming=%s)', async streaming => { setup(streaming); vi.stubGlobal('fetch', vi.fn().mockResolvedValue(streaming ? stream(answer + sources + 'data: [DONE]\n\n') : Response.json({ text: 'answer', mcpEvents: [], suggestions: [], sources: [source] }))); await sendQuickReply('question'); expect(fetch).toHaveBeenCalledTimes(1); expect(new URL(vi.mocked(fetch).mock.calls[0]![0] as string).pathname).toBe(streaming ? '/ai/chat/stream' : '/ai/chat/result'); const saved = vi.mocked(saveMessages).mock.calls.at(-1)![1]; expect(saved.at(-1)).toMatchObject({ role: 'ai', content: 'answer', sources: [source] }); const wrapper = streaming ? vi.mocked(createEmptyAIBubble).mock.results[0]!.value.wrapper : vi.mocked(renderAIBubble).mock.results[0]!.value; expect(renderSources).toHaveBeenLastCalledWith(wrapper, [source]); }); it('does not turn an empty completed stream into a second answer request', async () => { setup(true); vi.stubGlobal('fetch', vi.fn().mockResolvedValue(stream('data: [DONE]\n\n'))); await sendQuickReply('question'); expect(fetch).toHaveBeenCalledTimes(1); }); it('ignores a delayed synchronous answer after a new conversation begins', async () => { setup(false); let reply!: (response: Response) => void; vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => { reply = resolve; }))); const pending = sendQuickReply('old question'); clearButton.dispatchEvent(new Event('click')); reply(Response.json({ text: 'old answer', mcpEvents: [], suggestions: [], sources: [source] })); await pending; expect(saveMessages).not.toHaveBeenCalled(); expect(renderSources).not.toHaveBeenCalled(); expect(renderAIBubble).not.toHaveBeenCalled(); }); it('cancels in-flight old streams without adding their sources to a new conversation', async () => { setup(true); const cancel = vi.fn(); vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(new ReadableStream({ start(controller) { controller.enqueue(encoder.encode(answer)); }, cancel, })))); const pending = sendQuickReply('old question'); await vi.waitFor(() => expect(createEmptyAIBubble).toHaveBeenCalledTimes(1)); clearButton.dispatchEvent(new Event('click')); await pending; expect(cancel).toHaveBeenCalledTimes(1); expect(saveMessages).not.toHaveBeenCalled(); expect(renderSources).not.toHaveBeenCalled(); }); it('retains partial text but removes references on user cancellation', async () => { setup(true); vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(new ReadableStream({ start(controller) { controller.enqueue(encoder.encode(answer + sources)); }, })))); const pending = sendQuickReply('question'); await vi.waitFor(() => expect(renderSources).toHaveBeenCalled()); sender.dispatchEvent(new Event('stop')); await pending; expect(vi.mocked(saveMessages).mock.calls.at(-1)![1].at(-1)).toMatchObject({ content: 'answer', sources: [] }); expect(renderSources).toHaveBeenLastCalledWith(expect.anything(), []); }); });