Files
markdown-converter/tests/ai-assist-stream.test.js
T
Amit Haridas 1a25669967 feat(ai-assist): SSE streaming + IPC handler
AiProviders.completeStream() — async iterable over provider chunks.
  - OpenAI-style (openai, ollama, lmstudio, *-compatible): parses
    SSE data: {choices:[{delta:{content}}]} payloads.
  - Anthropic: parses event: content_block_delta with delta.text.
  - Honours caller AbortSignal + internal timeout via AbortController.
  - shared parseSseStream() helper handles [DONE] sentinel, partial
    lines across chunk boundaries, reader.releaseLock() in finally.

main.js IPC:
  - 'ai-assist-stream:start' (send) registers a per-requestId entry
    in a Map with an AbortController, iterates completeStream, and
    forwards each chunk via 'ai-assist-stream:chunk' (send).
  - 'ai-assist-stream:cancel' (send) aborts the in-flight request.
  - Terminal 'done' or 'error' (with code+message) is sent after
    each stream; renderer can correlate by requestId.

preload.js exposes aiAssist.{start, cancel, onChunk, onDone, onError}.

5 new tests covering OpenAI delta parsing, Anthropic content_block_delta
parsing, and three synchronous-error paths (no fetch, no messages,
missing key).

Amit Haridas
2026-09-30 20:04:32 +05:30

131 lines
3.7 KiB
JavaScript

/**
* @jest-environment node
*
* AiProviders.completeStream — minimal coverage of the SSE parser.
* Provider-specific shapes (OpenAI delta.content, Anthropic content_block_delta)
* are exercised via the parseData callback the helpers accept.
*/
/* global ReadableStream, Response */
const { completeStream, AiProviderError } = require('../src/main/AiProviders');
function makeSseResponse(lines) {
const body = lines.join('\n') + '\n';
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(body));
controller.close();
},
});
return new Response(stream, { status: 200 });
}
async function collect(iterable) {
const out = [];
for await (const chunk of iterable) out.push(chunk);
return out;
}
describe('AiProviders.completeStream', () => {
test('openai-style: yields delta.content per chunk, stops at [DONE]', async () => {
const fetchImpl = jest
.fn()
.mockResolvedValue(
makeSseResponse([
'data: {"choices":[{"delta":{"content":"Hello"}}]}',
'data: {"choices":[{"delta":{"content":", world"}}]}',
'data: {"choices":[{"delta":{"content":"!"}}]}',
'data: [DONE]',
])
);
const chunks = await collect(
completeStream(
{
provider: 'openai',
apiKey: 'sk-test',
messages: [{ role: 'user', content: 'hi' }],
},
{ fetchImpl }
)
);
expect(chunks.join('')).toBe('Hello, world!');
});
test('anthropic-style: yields delta.text only from content_block_delta events', async () => {
const fetchImpl = jest
.fn()
.mockResolvedValue(
makeSseResponse([
'event: message_start',
'data: {"type":"message_start","message":{}}',
'',
'event: content_block_start',
'data: {"type":"content_block_start"}',
'',
'event: content_block_delta',
'data: {"type":"content_block_delta","delta":{"text":"Hi"}}',
'',
'event: content_block_delta',
'data: {"type":"content_block_delta","delta":{"text":" there"}}',
'',
'data: [DONE]',
])
);
const chunks = await collect(
completeStream(
{
provider: 'anthropic',
apiKey: 'sk-test',
messages: [{ role: 'user', content: 'hi' }],
},
{ fetchImpl }
)
);
expect(chunks.join('')).toBe('Hi there');
});
test('throws AiProviderError when no fetch implementation is available', async () => {
const drain = async () => {
// eslint-disable-next-line no-unused-vars
for await (const _chunk of completeStream(
{ provider: 'openai', apiKey: 'x', messages: [{ role: 'user', content: 'h' }] },
{ fetchImpl: null }
)) {
// drain
}
};
await expect(drain()).rejects.toBeInstanceOf(AiProviderError);
});
test('throws on empty messages', async () => {
const drain = async () => {
// eslint-disable-next-line no-unused-vars
for await (const _chunk of completeStream(
{ provider: 'openai', apiKey: 'x', messages: [] },
{ fetchImpl: jest.fn() }
)) {
// drain
}
};
await expect(drain()).rejects.toThrow(/No messages/);
});
test('throws on missing API key for branded providers', async () => {
const drain = async () => {
// eslint-disable-next-line no-unused-vars
for await (const _chunk of completeStream(
{ provider: 'openai', apiKey: '', messages: [{ role: 'user', content: 'h' }] },
{ fetchImpl: jest.fn() }
)) {
// drain
}
};
await expect(drain()).rejects.toThrow(/API key/);
});
});