Skip to content

Commit 5f83544

Browse files
Merge pull request #110 from hell0gunawan/test/parse-sse-coverage
test: add parseSSE coverage (src/client/stream.ts)
2 parents f0bf565 + d9a1e6b commit 5f83544

1 file changed

Lines changed: 255 additions & 0 deletions

File tree

test/client/stream.test.ts

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { describe, it, expect, afterEach } from 'bun:test';
2+
import { parseSSE, type ServerSentEvent } from '../../src/client/stream';
3+
import { createMockServer, type MockServer } from '../helpers/mock-server';
4+
5+
/**
6+
* Build a raw SSE body string from an array of event objects.
7+
* Each event becomes: (optional comment) + (optional id) + (optional event) +
8+
* data lines + blank line. The terminating [DONE] event is added at the end.
9+
*/
10+
function buildSSE(events: Array<{
11+
data?: string;
12+
event?: string;
13+
id?: string;
14+
comment?: string;
15+
}>): string {
16+
const lines: string[] = [];
17+
for (const ev of events) {
18+
if (ev.comment) lines.push(`: ${ev.comment}`);
19+
if (ev.id) lines.push(`id: ${ev.id}`);
20+
if (ev.event) lines.push(`event: ${ev.event}`);
21+
if (ev.data !== undefined) {
22+
for (const dl of ev.data.split('\n')) lines.push(`data: ${dl}`);
23+
}
24+
lines.push(''); // blank line terminates this event
25+
}
26+
// [DONE] terminator — sent as a normal data event that callers check for
27+
lines.push('data: [DONE]');
28+
return lines.join('\n');
29+
}
30+
31+
function sseResponse(events: Parameters<typeof buildSSE>[0]): Response {
32+
return new Response(buildSSE(events), {
33+
headers: { 'Content-Type': 'text/event-stream' },
34+
});
35+
}
36+
37+
describe('parseSSE', () => {
38+
let server: MockServer;
39+
40+
afterEach(() => {
41+
server?.close();
42+
});
43+
44+
// -------------------------------------------------------------------------
45+
// Basic parsing
46+
// -------------------------------------------------------------------------
47+
48+
it('parses a single event with data', async () => {
49+
server = createMockServer({
50+
routes: { '/stream': () => sseResponse([{ data: 'hello' }]) },
51+
});
52+
53+
const response = await fetch(`${server.url}/stream`);
54+
const events = await collectEvents(response);
55+
56+
expect(events.length).toBe(2);
57+
expect(events[0].data).toBe('hello');
58+
expect(events[1].data).toBe('[DONE]');
59+
});
60+
61+
it('parses multiple sequential events', async () => {
62+
server = createMockServer({
63+
routes: {
64+
'/stream': () => sseResponse([
65+
{ data: 'first' },
66+
{ data: 'second' },
67+
{ data: 'third' },
68+
]),
69+
},
70+
});
71+
72+
const response = await fetch(`${server.url}/stream`);
73+
const events = await collectEvents(response);
74+
75+
expect(events.length).toBe(4);
76+
expect(events[0].data).toBe('first');
77+
expect(events[1].data).toBe('second');
78+
expect(events[2].data).toBe('third');
79+
expect(events[3].data).toBe('[DONE]');
80+
});
81+
82+
it('parses event with event type field', async () => {
83+
server = createMockServer({
84+
routes: {
85+
'/stream': () => sseResponse([
86+
{ event: 'message', data: 'payload' },
87+
]),
88+
},
89+
});
90+
91+
const response = await fetch(`${server.url}/stream`);
92+
const events = await collectEvents(response);
93+
94+
expect(events[0].data).toBe('payload');
95+
expect(events[0].event).toBe('message');
96+
expect(events[1].data).toBe('[DONE]');
97+
});
98+
99+
it('parses event with id field', async () => {
100+
server = createMockServer({
101+
routes: {
102+
'/stream': () => sseResponse([{ id: '42', data: 'item 42' }]),
103+
},
104+
});
105+
106+
const response = await fetch(`${server.url}/stream`);
107+
const events = await collectEvents(response);
108+
109+
expect(events[0].data).toBe('item 42');
110+
expect(events[0].id).toBe('42');
111+
expect(events[1].data).toBe('[DONE]');
112+
});
113+
114+
it('parses event with all fields set', async () => {
115+
server = createMockServer({
116+
routes: {
117+
'/stream': () => sseResponse([
118+
{ id: '1', event: 'update', data: 'all fields' },
119+
]),
120+
},
121+
});
122+
123+
const response = await fetch(`${server.url}/stream`);
124+
const events = await collectEvents(response);
125+
126+
expect(events[0]).toEqual({ data: 'all fields', event: 'update', id: '1' });
127+
expect(events[1].data).toBe('[DONE]');
128+
});
129+
130+
// -------------------------------------------------------------------------
131+
// Field value handling
132+
// -------------------------------------------------------------------------
133+
134+
it('skips comment lines (starting with colon)', async () => {
135+
server = createMockServer({
136+
routes: {
137+
'/stream': () => sseResponse([
138+
{ comment: 'this is a comment' },
139+
{ data: 'after comment' },
140+
]),
141+
},
142+
});
143+
144+
const response = await fetch(`${server.url}/stream`);
145+
const events = await collectEvents(response);
146+
147+
// Comment event produces no yield; only the data event is yielded
148+
expect(events[0].data).toBe('after comment');
149+
expect(events[1].data).toBe('[DONE]');
150+
});
151+
152+
it('concatenates multi-line data with newline separator', async () => {
153+
server = createMockServer({
154+
routes: {
155+
'/stream': () => sseResponse([{ data: 'line1\nline2\nline3' }]),
156+
},
157+
});
158+
159+
const response = await fetch(`${server.url}/stream`);
160+
const events = await collectEvents(response);
161+
162+
expect(events[0].data).toBe('line1\nline2\nline3');
163+
expect(events[1].data).toBe('[DONE]');
164+
});
165+
166+
it('skips lines without a colon (malformed field)', async () => {
167+
// A field with no colon is skipped per the spec.
168+
// "data: valid" starts an event; "no_colon_here" is skipped;
169+
// "data: second" appends to the same event (SSE concatenates with \n);
170+
// "\n" terminates the event.
171+
const body = 'data: valid\nno_colon_here\ndata: second\n\ndata: [DONE]\n\n';
172+
server = createMockServer({
173+
routes: {
174+
'/stream': () => new Response(body, {
175+
headers: { 'Content-Type': 'text/event-stream' },
176+
}),
177+
},
178+
});
179+
180+
const response = await fetch(`${server.url}/stream`);
181+
const events = await collectEvents(response);
182+
183+
// "valid" + "\n" + "second" = "valid\nsecond" (SSE multi-line data concatenation)
184+
expect(events[0].data).toBe('valid\nsecond');
185+
expect(events[1].data).toBe('[DONE]');
186+
});
187+
188+
it('handles empty data field (data: with no value)', async () => {
189+
// Per SSE spec, "data:" with no value after it is valid
190+
const body = 'data:\n\ndata: [DONE]\n\n';
191+
server = createMockServer({
192+
routes: {
193+
'/stream': () => new Response(body, {
194+
headers: { 'Content-Type': 'text/event-stream' },
195+
}),
196+
},
197+
});
198+
199+
const response = await fetch(`${server.url}/stream`);
200+
const events = await collectEvents(response);
201+
202+
// Empty data field is yielded as ''
203+
expect(events[0].data).toBe('');
204+
expect(events[1].data).toBe('[DONE]');
205+
});
206+
207+
// -------------------------------------------------------------------------
208+
// Streaming / buffering edge cases
209+
// -------------------------------------------------------------------------
210+
211+
it('handles response with no body (null body)', async () => {
212+
server = createMockServer({
213+
routes: {
214+
'/stream': () => new Response(null, { status: 204 }),
215+
},
216+
});
217+
218+
const response = await fetch(`${server.url}/stream`);
219+
const events = await collectEvents(response);
220+
221+
// Should yield nothing — no error
222+
expect(events).toEqual([]);
223+
});
224+
225+
it('releases reader lock after iteration completes', async () => {
226+
server = createMockServer({
227+
routes: {
228+
'/stream': () => sseResponse([{ data: 'test' }]),
229+
},
230+
});
231+
232+
// First request — consume all events
233+
const response1 = await fetch(`${server.url}/stream`);
234+
for await (const _ of parseSSE(response1)) { /* consume */ }
235+
236+
// Second request — should work since lock released
237+
const response2 = await fetch(`${server.url}/stream`);
238+
const events2 = await collectEvents(response2);
239+
240+
expect(events2[0].data).toBe('test');
241+
expect(events2[1].data).toBe('[DONE]');
242+
});
243+
244+
// -------------------------------------------------------------------------
245+
// Helpers
246+
// -------------------------------------------------------------------------
247+
248+
async function collectEvents(response: Response): Promise<ServerSentEvent[]> {
249+
const events: ServerSentEvent[] = [];
250+
for await (const event of parseSSE(response)) {
251+
events.push(event);
252+
}
253+
return events;
254+
}
255+
});

0 commit comments

Comments
 (0)