import { effectScope } from 'vue' import { afterEach, describe, expect, it, vi } from 'vitest' import { useEventStream } from '../../composables/useEventStream' function pendingStreamResponse(): { response: Response close: () => void } { let close!: () => void const body = new ReadableStream({ start(controller) { close = () => { try { controller.close() } catch { // stop() may already have cancelled the reader. } } }, }) return { response: new Response(body), close } } describe('useEventStream', () => { afterEach(() => { vi.useRealTimers() }) it('should decode chunked UTF-8 and dispatch an unterminated multiline tail frame', async () => { const encoded = new TextEncoder().encode( ': keepalive\nevent: result\nid: event-7\ndata: first\ndata: 2 µmol', ) const split = encoded.indexOf(0xc2) + 1 const body = new ReadableStream({ start(controller) { controller.enqueue(encoded.slice(0, split)) controller.enqueue(encoded.slice(split)) controller.close() }, }) const onMessage = vi.fn() const scope = effectScope() const stream = scope.run(() => useEventStream('/events', { transport: vi.fn(async () => new Response(body)), reconnect: false, onMessage, }))! await vi.waitFor(() => expect(onMessage).toHaveBeenCalledOnce()) expect(stream.lastMessage.value).toEqual({ event: 'result', data: 'first\n2 µmol', id: 'event-7', retry: undefined, raw: ': keepalive\nevent: result\nid: event-7\ndata: first\ndata: 2 µmol', }) scope.stop() }) it('should keep server retry delay local and send Last-Event-ID on reconnect', async () => { vi.useFakeTimers() const first = new Response('id: event-1\nretry: 7\ndata: first\n\n') const second = pendingStreamResponse() const transport = vi.fn() .mockResolvedValueOnce(first) .mockResolvedValueOnce(second.response) const options = { transport, reconnectDelayMs: 1_000, } const scope = effectScope() const stream = scope.run(() => useEventStream('/events', options))! await vi.waitFor(() => expect(stream.lastEventId.value).toBe('event-1')) await vi.advanceTimersByTimeAsync(7) const secondHeaders = new Headers(transport.mock.calls[1]?.[1]?.headers) expect({ attempts: transport.mock.calls.length, lastEventId: secondHeaders.get('Last-Event-ID'), configuredDelay: options.reconnectDelayMs, }).toEqual({ attempts: 2, lastEventId: 'event-1', configuredDelay: 1_000, }) stream.stop() second.close() scope.stop() }) it('should apply control-only frames without dispatching a data message', async () => { vi.useFakeTimers() const second = pendingStreamResponse() const transport = vi.fn() .mockResolvedValueOnce(new Response('id: event-2\nretry: 7\n\n')) .mockResolvedValueOnce(second.response) const onMessage = vi.fn() const scope = effectScope() const stream = scope.run(() => useEventStream('/events', { transport, reconnectDelayMs: 1_000, onMessage, }))! await vi.waitFor(() => expect(stream.lastEventId.value).toBe('event-2')) await vi.advanceTimersByTimeAsync(7) expect({ messages: onMessage.mock.calls.length, reconnectCursor: new Headers(transport.mock.calls[1]?.[1]?.headers).get('Last-Event-ID'), }).toEqual({ messages: 0, reconnectCursor: 'event-2' }) stream.stop() second.close() scope.stop() }) it('should reconnect after an ordinary stream close', async () => { vi.useFakeTimers() const second = pendingStreamResponse() const transport = vi.fn() .mockResolvedValueOnce(new Response('data: first\n\n')) .mockResolvedValueOnce(second.response) const scope = effectScope() const stream = scope.run(() => useEventStream('/events', { transport, reconnectDelayMs: 5, }))! await vi.waitFor(() => expect(stream.lastMessage.value?.data).toBe('first')) await vi.advanceTimersByTimeAsync(5) expect(transport).toHaveBeenCalledTimes(2) stream.stop() second.close() scope.stop() }) })