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.
164 lines
7.4 KiB
164 lines
7.4 KiB
import assert from 'node:assert/strict'
|
|
import { afterEach, beforeEach, test } from 'node:test'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { build } from 'esbuild'
|
|
|
|
// Exercise the production TypeScript with Vite's existing esbuild dependency, no test framework.
|
|
const root = fileURLToPath(new URL('../', import.meta.url))
|
|
async function loadModule(entry) {
|
|
const result = await build({
|
|
entryPoints: [root + entry], bundle: true, write: false, format: 'esm', platform: 'node',
|
|
alias: { '@': root + 'src' },
|
|
})
|
|
return import('data:text/javascript;base64,' + Buffer.from(result.outputFiles[0].text).toString('base64'))
|
|
}
|
|
const { readSSEStream, readSSEStreamWithEvents } = await loadModule('src/utils/sse.ts')
|
|
const { chatSync, fetchChatResult } = await loadModule('src/api/chat.ts')
|
|
const originalFetch = globalThis.fetch
|
|
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
|
|
|
|
beforeEach(() => {
|
|
Object.defineProperty(globalThis, 'localStorage', {
|
|
configurable: true, value: { getItem: () => 'admin-token' },
|
|
})
|
|
})
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch
|
|
if (originalStorage) Object.defineProperty(globalThis, 'localStorage', originalStorage)
|
|
else delete globalThis.localStorage
|
|
})
|
|
|
|
const sources = [{
|
|
documentId: '9223372036854775806', title: '报销制度', sourceName: 'policy.pdf',
|
|
chunkIndex: 2, score: 0.125, snippet: '申请应在三十天内提交。',
|
|
}]
|
|
const metadata = JSON.stringify({ object: 'chat.completion.chunk', choices: [], sources })
|
|
const answer = JSON.stringify({ choices: [{ delta: { content: '答复。' } }] })
|
|
|
|
function responseFor(text, { open = false, fragment = false, cancel } = {}) {
|
|
const bytes = new TextEncoder().encode(text)
|
|
const body = new ReadableStream({
|
|
start(controller) {
|
|
if (fragment) for (const byte of bytes) controller.enqueue(Uint8Array.of(byte))
|
|
else controller.enqueue(bytes)
|
|
if (!open) controller.close()
|
|
},
|
|
cancel,
|
|
})
|
|
return new Response(body)
|
|
}
|
|
|
|
test('fragmented CRLF metadata preserves ID/snippet and DONE completes without EOF or cancellation settlement', { timeout: 1000 }, async () => {
|
|
let cancelled = 0
|
|
const response = responseFor(
|
|
`data: ${answer}\r\n\r\ndata: ${metadata}\r\n\r\ndata: [DONE]\r\n\r\ndata: ignored\r\n\r\n`,
|
|
{ open: true, fragment: true, cancel() { cancelled++; return new Promise(() => {}) } },
|
|
)
|
|
const requests = []
|
|
globalThis.fetch = async (...args) => { requests.push(args); return response }
|
|
const events = []
|
|
await readSSEStreamWithEvents('/ai/chat/stream', {
|
|
onMessage: text => events.push(['text', text]),
|
|
onSources: value => events.push(['sources', value]),
|
|
onDone: () => events.push(['done']),
|
|
}, { Authorization: 'Bearer sdk-token' })
|
|
assert.deepEqual(events, [['text', '答复。'], ['sources', sources], ['done']])
|
|
assert.equal(requests.length, 1)
|
|
assert.equal(requests[0][1].headers.Authorization, 'Bearer sdk-token')
|
|
assert.equal(cancelled, 1)
|
|
assert.equal(response.body.locked, false)
|
|
})
|
|
|
|
test('text-only facade uses identical DONE handling and never leaks empty choices or sources', { timeout: 1000 }, async () => {
|
|
globalThis.fetch = async () => responseFor(
|
|
`data: ${metadata}\n\ndata: ${answer}\n\ndata: [DONE]\n\ndata: [DONE]\n\ndata: trailing\n\n`,
|
|
{ open: true },
|
|
)
|
|
const text = []
|
|
let completed = 0
|
|
await readSSEStream('/ai/chat/stream', value => text.push(value), () => completed++)
|
|
assert.deepEqual(text, ['答复。'])
|
|
assert.equal(completed, 1)
|
|
})
|
|
|
|
test('EOF residuals, multiline data, tool events, status and legacy text retain their semantics', async () => {
|
|
globalThis.fetch = async () => responseFor(
|
|
': heartbeat\r\nid: event-1\r\nretry: 1000\r\nevent: status\r\ndata: generating\r\n\r\n'
|
|
+ 'event: tool_call_start\r\ndata: {"tool":"lookup"}\r\n\r\n'
|
|
+ 'event: tool_call_result\r\ndata: {"tool":"lookup","result":"ok"}\r\n\r\n'
|
|
+ 'event: error\r\ndata: {"message":"tool unavailable"}\r\n\r\n'
|
|
+ 'data: first\r\ndata: indented\r\n\r\ndata:\r\n\r\nlegacy\r\ndata: 尾部',
|
|
{ fragment: true },
|
|
)
|
|
const events = []
|
|
await readSSEStreamWithEvents('/stream', {
|
|
onMessage: text => events.push(text),
|
|
onToolCallStart: value => events.push(value),
|
|
onToolCallResult: value => events.push(value),
|
|
onError: value => events.push(value),
|
|
onDone: () => events.push('done'),
|
|
})
|
|
assert.deepEqual(events, [
|
|
{ tool: 'lookup' }, { tool: 'lookup', result: 'ok' }, { message: 'tool unavailable' },
|
|
'first\n indented', '\n', 'legacy', '尾部', 'done',
|
|
])
|
|
})
|
|
|
|
test('EOF flushes an unterminated plain-text line and an unterminated DONE event', async () => {
|
|
for (const body of ['legacy tail', 'data: [DONE]']) {
|
|
globalThis.fetch = async () => responseFor(body)
|
|
const text = []
|
|
let done = 0
|
|
await readSSEStream('/stream', value => text.push(value), () => done++)
|
|
assert.deepEqual(text, body.startsWith('data:') ? [] : ['legacy tail'])
|
|
assert.equal(done, 1)
|
|
}
|
|
})
|
|
|
|
test('cleanup failure cannot replace callback errors or call onDone on an error', async () => {
|
|
const original = new Error('consumer failed')
|
|
const response = responseFor(`data: ${answer}\n\n`, {
|
|
open: true, cancel() { throw new Error('cleanup failed') },
|
|
})
|
|
globalThis.fetch = async () => response
|
|
let done = 0
|
|
await assert.rejects(readSSEStream('/stream', () => { throw original }, () => done++), error => error === original)
|
|
assert.equal(done, 0)
|
|
assert.equal(response.body.locked, false)
|
|
})
|
|
|
|
test('empty sources for ordinary answers are delivered separately from content', async () => {
|
|
globalThis.fetch = async () => responseFor('data: {"choices":[],"sources":[]}\n\ndata: [DONE]\n\n')
|
|
const values = []
|
|
await readSSEStreamWithEvents('/stream', {
|
|
onMessage: () => assert.fail('metadata is not text'),
|
|
onSources: value => values.push(value),
|
|
})
|
|
assert.deepEqual(values, [[]])
|
|
})
|
|
|
|
test('synchronous chat makes one result request and retains direct answer, sources, explicit strategy and signal', async () => {
|
|
const result = { text: '答复。', mcpEvents: [], suggestions: [], sources }
|
|
const calls = []
|
|
globalThis.fetch = async (...args) => { calls.push(args); return Response.json(result) }
|
|
const controller = new AbortController()
|
|
assert.deepEqual(await chatSync('费用?', 'conversation-1', {
|
|
enableRag: true, roleId: '9223372036854775806', rewriteStrategy: 'MULTI_QUERY', categoryIds: ['123'],
|
|
}, controller.signal), result)
|
|
assert.equal(calls.length, 1)
|
|
const url = new URL(calls[0][0], 'https://test.invalid')
|
|
assert.equal(url.pathname, '/ai/chat/result')
|
|
assert.equal(url.searchParams.get('rewriteStrategy'), 'MULTI_QUERY')
|
|
assert.equal(url.searchParams.get('roleId'), '9223372036854775806')
|
|
assert.equal(calls[0][1].signal, controller.signal)
|
|
assert.equal(calls[0][1].headers.Authorization, 'Bearer admin-token')
|
|
})
|
|
|
|
test('synchronous SDK response uses the same direct contract and surfaces server errors', async () => {
|
|
const result = { text: '普通回答', mcpEvents: [], suggestions: [], sources: [] }
|
|
globalThis.fetch = async () => Response.json(result)
|
|
assert.deepEqual(await fetchChatResult('https://sdk.invalid/ai/chat/result', {}), result)
|
|
globalThis.fetch = async () => new Response('角色无访问权限', { status: 403 })
|
|
await assert.rejects(fetchChatResult('/ai/chat/result', {}), /角色无访问权限/)
|
|
await assert.rejects(readSSEStream('/ai/chat/stream', () => {}), /角色无访问权限/)
|
|
})
|