You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
118 lines
5.7 KiB
118 lines
5.7 KiB
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<string, string> = {};
|
|
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<typeof initChat>[1]);
|
|
clearButton.dispatchEvent(new Event('click'));
|
|
vi.clearAllMocks();
|
|
}
|
|
function stream(body: string) {
|
|
return new Response(new ReadableStream<Uint8Array>({ 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<Response>(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<Uint8Array>({
|
|
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<Uint8Array>({
|
|
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(), []);
|
|
});
|
|
});
|