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.
123 lines
5.8 KiB
123 lines
5.8 KiB
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { chatRequest, chatSSERequest, clearApiConfig, setApiConfig } from '../src/api';
|
|
import { parseConfig } from '../src/config';
|
|
import type { RagSource } from '../src/types';
|
|
|
|
const source: RagSource = {
|
|
documentId: '1234567890123456789', title: 'manual', sourceName: null,
|
|
chunkIndex: 0, score: null, snippet: '实际命中的文档',
|
|
};
|
|
const encoder = new TextEncoder();
|
|
const chunk = (content: string) => `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}\r\n\r\n`;
|
|
const metadata = `data: ${JSON.stringify({ object: 'chat.completion.chunk', choices: [], sources: [source] })}\r\n\r\n`;
|
|
|
|
function response(parts: Uint8Array[], close = true) {
|
|
const cancel = vi.fn();
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
parts.forEach(part => controller.enqueue(part));
|
|
if (close) controller.close();
|
|
},
|
|
cancel,
|
|
});
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(stream)));
|
|
return { stream, cancel };
|
|
}
|
|
function receive(signal?: AbortSignal) {
|
|
const onChunk = vi.fn();
|
|
const onDone = vi.fn();
|
|
const onError = vi.fn();
|
|
const onSources = vi.fn();
|
|
const pending = chatSSERequest('问题', onChunk, onDone, onError, 7, true, undefined, signal, onSources);
|
|
return { pending, onChunk, onDone, onError, onSources };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
const config = parseConfig({ integrateId: '42', requestDomain: 'https://example.test', userId: 'user' })!;
|
|
config.chatId = 'conversation';
|
|
setApiConfig(config);
|
|
});
|
|
afterEach(() => { clearApiConfig(); vi.unstubAllGlobals(); });
|
|
|
|
describe('same-request answer transport', () => {
|
|
it('stops on DONE without EOF, preserving split UTF-8, CRLF and sources outside text', async () => {
|
|
const bytes = encoder.encode(chunk('中文 ') + metadata + 'data: [DONE]\r\n\r\n' + chunk('不得出现'));
|
|
const { stream, cancel } = response(Array.from(bytes, byte => Uint8Array.of(byte)), false);
|
|
const result = receive();
|
|
await result.pending;
|
|
expect(result.onChunk.mock.calls.flat().join('')).toBe('中文 ');
|
|
expect(result.onSources).toHaveBeenCalledExactlyOnceWith([source]);
|
|
expect(result.onDone).toHaveBeenCalledTimes(1);
|
|
expect(result.onError).not.toHaveBeenCalled();
|
|
expect(cancel).toHaveBeenCalledTimes(1);
|
|
expect(stream.locked).toBe(false);
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
const url = new URL(vi.mocked(fetch).mock.calls[0]![0] as string);
|
|
expect(url.pathname).toBe('/ai/chat/stream');
|
|
expect(url.searchParams.get('rewriteStrategy')).toBe('NONE');
|
|
});
|
|
|
|
it('keeps multiline data whitespace and flushes the last unterminated event at EOF', async () => {
|
|
response([encoder.encode('event: status\ndata: 不显示\n\ndata: first \r\ndata: second\r\n\r\ndata: last ')]);
|
|
const result = receive();
|
|
await result.pending;
|
|
expect(result.onChunk.mock.calls.flat()).toEqual([' first \nsecond', 'last ']);
|
|
expect(result.onDone).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('recognizes EOF remainder DONE rather than rendering it', async () => {
|
|
response([encoder.encode(chunk('answer') + 'data: [DONE]')]);
|
|
const result = receive();
|
|
await result.pending;
|
|
expect(result.onChunk).toHaveBeenCalledExactlyOnceWith('answer');
|
|
expect(result.onDone).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('delivers original server error once even when reader cleanup rejects', async () => {
|
|
const { cancel, stream } = response([encoder.encode('data: {"error":{"message":"权限不足"}}\n\n')], false);
|
|
cancel.mockRejectedValue(new Error('cleanup failed'));
|
|
const result = receive();
|
|
await result.pending;
|
|
expect(result.onError).toHaveBeenCalledTimes(1);
|
|
expect(result.onError.mock.calls[0]![0].message).toBe('权限不足');
|
|
expect(result.onDone).not.toHaveBeenCalled();
|
|
expect(stream.locked).toBe(false);
|
|
});
|
|
|
|
it('cancels a pending read once without consuming later metadata', async () => {
|
|
const { stream } = response([], false);
|
|
const controller = new AbortController();
|
|
const result = receive(controller.signal);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
controller.abort();
|
|
await result.pending;
|
|
expect(result.onDone).toHaveBeenCalledTimes(1);
|
|
expect(result.onError).not.toHaveBeenCalled();
|
|
expect(result.onSources).not.toHaveBeenCalled();
|
|
expect(stream.locked).toBe(false);
|
|
});
|
|
|
|
it('uses one synchronous JSON request with the same RAG, role and category parameters', async () => {
|
|
const answer = { text: 'answer', mcpEvents: [], suggestions: [], sources: [source] };
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(answer)));
|
|
expect(await chatRequest('问题', ['https://example.test/photo.png'], 7, true)).toEqual(answer);
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
const url = new URL(vi.mocked(fetch).mock.calls[0]![0] as string);
|
|
expect(url.pathname).toBe('/ai/chat/result');
|
|
expect(Object.fromEntries(url.searchParams)).toMatchObject({
|
|
roleId: '42', accountId: 'user', chatId: 'conversation', categoryId: '7', enableRag: 'true', rewriteStrategy: 'NONE',
|
|
});
|
|
});
|
|
|
|
it('retains explicit rewrite configuration and disabled RAG', async () => {
|
|
const config = parseConfig({ integrateId: '42', requestDomain: 'https://example.test', rewriteStrategy: 'MULTI_QUERY' })!;
|
|
setApiConfig(config);
|
|
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(Response.json({ text: '', mcpEvents: [], suggestions: [], sources: [] }))));
|
|
await chatRequest('问题', undefined, undefined, true);
|
|
await chatRequest('问题', undefined, undefined, false);
|
|
const urls = vi.mocked(fetch).mock.calls.map(([url]) => new URL(url as string));
|
|
expect(urls[0]!.searchParams.get('rewriteStrategy')).toBe('MULTI_QUERY');
|
|
expect(urls[1]!.searchParams.get('enableRag')).toBe('false');
|
|
});
|
|
});
|