import { afterEach, expect, test, vi, type Mock } from 'vitest'; import { SSESession } from '../../source/sse-session/sse-session.ts'; import { ExponentialBackoff, ExponentialBackoffMaxRetriesHitError } from '../../source/exponential-backoff/index.ts'; import type { SSESessionEventCallbacks, SSESessionOptions, SSEvent } from '../../source/sse-session/types.ts'; import { HTTPError, ResponseBodyNullError, SSESessionUnsubscribePluginNotAFunctionError } from '../../source/sse-session/errors.ts'; import * as utils from '../../source/misc.ts'; import { SseTestStream, type SseTestStreamOptions } from './helpers/sse-stream.ts'; import { priceOracleEvents, storageEvents } from './fixtures/events.fixtures.ts'; class DocumentStub extends EventTarget { public visibilityState: DocumentVisibilityState = 'visible'; } const documentStub = new DocumentStub(); vi.stubGlobal('document', documentStub); /** URL passed to every session under test. */ const EVENTS_URL = '/events'; /** Headers required for a valid SSE response in these tests. */ const SSE_HEADERS = { 'Content-Type': 'text/event-stream' }; type FetchFn = SSESessionOptions['fetch']; /** * Builds a minimal ExponentialBackoff so reconnect and retry paths finish quickly in tests. * Real production delays would make vi.waitFor-based assertions time out. * * @param maxAttempts - Maximum retry attempts; defaults to 1. */ const testRetry = (maxAttempts = 1): ExponentialBackoff => { return new ExponentialBackoff({ baseDelay: 1, maxDelay: 1, maxAttempts, growthRate: 1, jitter: 0, }); }; /** * Wraps fixture chunks in a Response backed by {@link SseTestStream}. * SseTestStream simulates a real HTTP body: chunks arrive over time and the stream * can optionally close itself when all chunks are sent. * * @param chunks - Raw SSE payload strings to stream. * @param options - Optional stream timing and close behavior. */ const sseFetchResponse = (chunks: string[], options: SseTestStreamOptions = {}): Response => { return new Response(new SseTestStream(chunks, options).stream, { status: 200, headers: SSE_HEADERS, }); }; /** * Creates a vitest mock fetch that delegates to the given responder. * SSESession requires fetch injection so tests never hit the network. * * @param responder - Function that returns the Response for each fetch call. */ const createFetchMock = (responder: (url: string, init: RequestInit) => Response | Promise): Mock => { return vi.fn(async (url: string, init: RequestInit) => responder(url, init)); }; /** * Session defaults that disable reconnect noise unless a test opts in. * attemptReconnect: false — transport errors should not auto-retry by default. * persistent: false — server closing the stream should close the message iterator. */ const defaultSessionOptions: Partial = { attemptReconnect: false, persistent: false, retry: testRetry(), }; /** * Creates an SSESession wired to the injected fetch mock. * SSESession.create immediately calls connect(), so fetch is invoked during creation. * * @param fetch - Mock fetch implementation. * @param options - Per-test session overrides merged on top of defaults. */ const createSession = async (fetch: FetchFn, options: Partial = {}): Promise => { return SSESession.create(EVENTS_URL, { ...defaultSessionOptions, onError: vi.fn(), ...options, fetch, }); }; /** * Runs a callback against a session and always closes the session afterward. * Most tests use this so session lifecycle (connect on create, disconnect on exit) * is consistent and resources are not leaked between cases. * * @param fetch - Mock fetch for the session. * @param options - Session options. * @param run - Test body receiving the connected session. */ const withSession = async ( fetch: FetchFn, options: Partial, run: (session: SSESession) => Promise, ): Promise => { const session = await createSession(fetch, options); try { return await run(session); } finally { session.close(); } }; /** * Reads up to `count` messages from the session's async iterator. * Breaking out of the for-await loop early leaves the underlying stream open, * which is intentional for tests that inspect post-read session state. * * @param session - Connected SSE session. * @param count - Number of events to collect before stopping. */ const readMessages = async (session: SSESession, count: number): Promise => { const events: SSEvent[] = []; for await (const event of session.messages) { events.push(event); if (events.length >= count) { break; } } return events; }; /** * Builds a ReadableStream that emits one chunk then errors after a delay. * Simulates a mid-stream network failure: the client receives partial data, * then the connection drops before the server finishes sending. * * @param raw - SSE payload to enqueue before the error. * @param delayMs - Milliseconds to wait before erroring (gives the parser time to process the chunk). */ const failingStreamAfter = (raw: string, delayMs = 50): ReadableStream => { const encoder = new TextEncoder(); return new ReadableStream({ async start(controller): Promise { controller.enqueue(encoder.encode(raw)); await new Promise((resolve) => setTimeout(resolve, delayMs)); controller.error(new Error('network failure')); }, }); }; /** * Scenario: a client opens a standard GET SSE connection. * Verifies the session passes the correct fetch options for a spec-compliant SSE request. */ const testSseSessionConnectCallsFetchWithExpectedOptions = async (): Promise => { // Return one valid storage fixture event so connect succeeds and the stream stays open briefly. const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ])); try { // withSession → createSession → SSESession.create → connect → fetchMock is called once. await withSession(fetchMock, {}, async () => { expect(fetchMock).toHaveBeenCalledWith( EVENTS_URL, expect.objectContaining({ method: 'GET', cache: 'no-store', signal: expect.any(AbortSignal), headers: expect.objectContaining({ Accept: 'text/event-stream', 'Cache-Control': 'no-cache', }), }), ); // While the stream is active the abort signal must not yet be triggered. // Grab the first call to fetchMock const firstCall = fetchMock.mock.calls[0]; // Grab the second parameter of the first call to fetchMock (the request init) // - `fetch(url, options)` - we are grabbing the options object. const firstCallParams = firstCall?.[1]; // Assert the abort signal is not yet triggered. If it is, the stream is closed. expect(firstCallParams?.signal?.aborted).toBe(false); }); } finally { vi.restoreAllMocks(); } }; const testSseSessionUsesDefaultFetchImplementation = async (): Promise => { const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ])); vi.stubGlobal('fetch', fetchMock); const session = new SSESession('http://test.com'); await session.connect(); expect(fetchMock).toHaveBeenCalledWith( 'http://test.com', expect.objectContaining({ method: 'GET', cache: 'no-store', signal: expect.any(AbortSignal), headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache', }, }), ); }; /** * Scenario: a client connects and the application wants a lifecycle callback when the stream is ready. * Verifies onConnected fires after the transport is established. */ const testSseSessionConnectInvokesOnConnected = async (): Promise => { const onConnected = vi.fn(); const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ])); try { await withSession(fetchMock, { onConnected }, async () => { expect(onConnected).toHaveBeenCalledOnce(); }); } finally { vi.restoreAllMocks(); } }; /** * Scenario: a client is already connected and something calls connect() again (e.g. a duplicate init). * Verifies the session does not open a second HTTP transport. */ const testSseSessionConnectDoesNotOpenSecondTransport = async (): Promise => { const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ])); try { await withSession(fetchMock, {}, async (session) => { // Pull one event so the first transport is fully established and reading. await readMessages(session, 1); // The session has already been connected, so we expect the fetchMock to have been called once. expect(fetchMock).toHaveBeenCalledOnce(); // Idempotent connect — should be a no-op at the fetch layer. // If this were to create a second transport, it would increment the fetchMock call count by one. await session.connect(); // Since we are testing that a second transport is not opened, we expect the fetchMock to still have only been called once. expect(fetchMock).toHaveBeenCalledOnce(); }); } finally { vi.restoreAllMocks(); } }; /** * Scenario: the application attaches auth or other headers via onRequest before each fetch. * Verifies mutations from onRequest reach the actual fetch call. */ const testSseSessionConnectPassesOnRequestMutations = async (): Promise => { const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ])); try { await withSession( fetchMock, { // onRequest runs during connect and can rewrite headers/body before fetch sees them. onRequest: async (request) => ({ ...request, headers: { ...request.headers, Authorization: 'Bearer test-token' }, }), }, async () => { expect(fetchMock).toHaveBeenCalledWith( EVENTS_URL, expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer test-token', }), }), ); }, ); } finally { vi.restoreAllMocks(); } }; /** * Scenario: some SSE endpoints require POST with a form body instead of a plain GET. * Verifies method and body are forwarded to fetch. */ const testSseSessionConnectSendsPostBody = async (): Promise => { const body = new FormData(); body.set('topic', 'prices'); const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ])); try { await withSession( fetchMock, { method: 'POST', body, }, async () => { expect(fetchMock).toHaveBeenCalledWith( EVENTS_URL, expect.objectContaining({ method: 'POST', body, }), ); }, ); } finally { vi.restoreAllMocks(); } }; /** * Scenario: a single long-lived connection delivers many events from different domains (LLM, oracle, storage). * Verifies the parser and session deliver every fixture event in order through one stream. */ const testSseSessionDeliversMultipleFixtureEvents = async (): Promise => { const fixtures = [ ...priceOracleEvents, ...storageEvents ]; const expected = fixtures.flatMap(({ parsed }) => parsed ?? []); // All raw payloads are concatenated into one SseTestStream response. const fetchMock = createFetchMock(() => sseFetchResponse(fixtures.map(({ raw }) => raw))); try { await withSession(fetchMock, {}, async (session) => { const received = await readMessages(session, expected.length); expect(received).toEqual(expected); }); } finally { vi.restoreAllMocks(); } }; /** * Scenario: application code listens via session.on('message') instead of the async iterator. * Verifies the EventEmitter path receives the same parsed events as the iterator. */ const testSseSessionEmitsMessageEvents = async (): Promise => { const { raw, parsed } = storageEvents[0]!; const emitted: SSEvent[] = []; // ':\n\n' is an SSE comment/heartbeat; chunkDelayMs forces it to arrive as a separate chunk // so incremental parsing is exercised before the real data frame. const fetchMock = createFetchMock(() => sseFetchResponse([ ':\n\n', raw ], { chunkDelayMs: 50 })); // Use createSession directly (not withSession) so we control disconnect timing in finally. const session = await createSession(fetchMock, {}); try { session.on('message', (event) => emitted.push(event)); await readMessages(session, 1); expect(emitted).toEqual([ parsed![0] ]); } finally { session.close(); vi.restoreAllMocks(); } }; /** * Scenario: network chunks split an SSE frame at an arbitrary byte boundary. * Verifies the internal parser buffers partial data and still emits a complete event. */ const testSseSessionParsesEventSplitAcrossChunks = async (): Promise => { const { raw, parsed } = storageEvents[0]!; const mid = Math.floor(raw.length / 2); // SseTestStream sends each array element as a separate chunk — the event is cut in half. const fetchMock = createFetchMock(() => sseFetchResponse([ raw.slice(0, mid), raw.slice(mid) ])); try { await withSession(fetchMock, {}, async (session) => { const [ event ] = await readMessages(session, 1); expect(event).toEqual(parsed![0]); }); } finally { vi.restoreAllMocks(); } }; /** * Scenario: server closes the stream after one event and the client is not in persistent mode. * Verifies the message iterator closes and onDisconnected fires. */ const testSseSessionClosesOnServerCloseWhenNotPersistent = async (): Promise => { // closeWhenDone: true makes SseTestStream end the body after sending the chunk. const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ], { closeWhenDone: true })); const disconnected = vi.fn(); try { // persistent defaults to false — terminal server close should shut down messages. await withSession(fetchMock, { onDisconnected: disconnected }, async (session) => { await readMessages(session, 1); await vi.waitFor(() => expect(session.messages.closed).toBe(true)); expect(disconnected).toHaveBeenCalled(); }); } finally { vi.restoreAllMocks(); } }; /** * Scenario: server closes the stream, the message iterator closes, then the app manually reconnects. * Verifies a second fetch opens and events flow again (non-persistent manual reconnect path). */ const testSseSessionReconnectsAfterTerminalClose = async (): Promise => { const fetchMock = vi.fn(); // First fetch closes after one event, second fetch stays open. fetchMock .mockResolvedValueOnce(sseFetchResponse([ priceOracleEvents[0]!.raw ], { closeWhenDone: true })) .mockResolvedValueOnce(sseFetchResponse([ priceOracleEvents[1]!.raw ])); try { await withSession(fetchMock, {}, async (session) => { // Read the first streams message const [ event0 ] = await readMessages(session, 1); expect(event0).toEqual(priceOracleEvents[0]!.parsed![0]); // Wait for the stream to close await vi.waitFor(() => expect(session.messages.closed).toBe(true)); // App-initiated reconnect after the first stream ended. await session.connect(); // Read the next message from the stream // It should be different because we should be using a different fetch call instance) const [ event1 ] = await readMessages(session, 1); expect(event1).toEqual(priceOracleEvents[1]!.parsed![0]); // Assert that we have made two fetch calls expect(fetchMock).toHaveBeenCalledTimes(2); }); } finally { vi.restoreAllMocks(); } }; /** * Scenario: persistent client — server closes the stream but the session should auto-reconnect. * Verifies two fetch calls and events from both streams arrive on the same message iterator. */ const testSseSessionPersistentReconnectsOnServerClose = async (): Promise => { const fetchMock = vi.fn(); // First fetch closes after one event, second fetch stays open. fetchMock .mockResolvedValueOnce(sseFetchResponse([ priceOracleEvents[0]!.raw ], { closeWhenDone: true })) .mockResolvedValueOnce(sseFetchResponse([ priceOracleEvents[1]!.raw ])); try { await withSession( fetchMock, { persistent: true, retry: testRetry(2), }, async (session) => { const [ first, second ] = await readMessages(session, 2); // Assert that we have received the first event expect(first).toEqual(priceOracleEvents[0]!.parsed![0]); // Assert that we have received the second event expect(second).toEqual(priceOracleEvents[1]!.parsed![0]); // Assert that we have made two fetch calls await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); }, ); } finally { vi.restoreAllMocks(); } }; /** * Scenario: mid-stream network error with attemptReconnect enabled. * Verifies the session fetches again and the iterator continues delivering events. */ const testSseSessionReconnectsOnTransportError = async (): Promise => { const fetchMock = vi.fn(); fetchMock // First connection: delivers one event then the stream errors (failingStreamAfter). .mockResolvedValueOnce(new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS })) // Second connection: clean stream with the next fixture event. .mockResolvedValueOnce(sseFetchResponse([ priceOracleEvents[1]!.raw ])); try { await withSession( fetchMock, { attemptReconnect: true, retry: testRetry(2), }, async (session) => { const [ beforeError, recovered ] = await readMessages(session, 2); expect(beforeError).toEqual(priceOracleEvents[0]!.parsed![0]); expect(recovered).toEqual(priceOracleEvents[1]!.parsed![0]); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); }, ); } finally { vi.restoreAllMocks(); } }; /** * Scenario: mid-stream network error with attemptReconnect disabled (default). * Verifies the session terminates: messages close, onError runs, and an error event is emitted. */ const testSseSessionClosesOnTransportErrorWhenNotReconnecting = async (): Promise => { const onError = vi.fn(); const response = new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS }); const fetchMock = createFetchMock(() => response); try { await withSession(fetchMock, { onError }, async (session) => { const errorEvent = new Promise((resolve) => session.once('error', resolve)); await vi.waitFor(() => expect(session.messages.closed).toBe(true)); expect(onError).toHaveBeenCalled(); await expect(errorEvent).resolves.toBeInstanceOf(Error); }); } finally { vi.restoreAllMocks(); } }; /** * Scenario: the user aborts an active connection (e.g. navigation away) but may reconnect later. * Verifies onDisconnected fires, fetch is aborted, but the message iterator stays open. */ const testSseSessionDisconnectEmitsDisconnectedAndKeepsMessagesOpen = async (): Promise => { const onDisconnected = vi.fn(); const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ])); try { await withSession(fetchMock, { onDisconnected }, async (session) => { await readMessages(session, 1); session.disconnect(); expect(onDisconnected).toHaveBeenCalled(); expect(session.messages.closed).toBe(false); }); } finally { vi.restoreAllMocks(); } }; /** * Scenario: the application fully tears down the session (logout, component unmount, etc.). * Verifies close closes the message iterator and emits the closed event. */ const testSseSessionCloseClosesMessagesAndEmitsClosed = async (): Promise => { const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ])); const session = await createSession(fetchMock, {}); const closed = new Promise((resolve) => session.once('closed', () => resolve())); try { session.close(); expect(session.messages.closed).toBe(true); await expect(closed).resolves.toBeUndefined(); } finally { vi.restoreAllMocks(); } }; /** * Scenario: the server returns a non-2xx HTTP status (500). * Verifies create rejects, onError is invoked, and the error is a real Error instance. */ const testSseSessionHttpErrorCallsOnErrorAndThrows = async (): Promise => { const onError = vi.fn(); const fetchMock = createFetchMock(() => new Response('nope', { status: 500, statusText: 'Internal Server Error', })); const expectedError = new ExponentialBackoffMaxRetriesHitError([ new HTTPError(500, 'nope') ]); try { await expect(createSession(fetchMock, { onError })).rejects.toThrow(expectedError); expect(onError).toHaveBeenCalled(); // Grab the parameters of the first call to onError const firstCall = onError.mock.calls[0]; // Grab the First Parameter of the first call to onError // - `onError(error)` - we are grabbing the error object. const firstCallParams = firstCall?.[0]; // Assert the error is an instance of Error that matches the expected error. expect(firstCallParams).toBeInstanceOf(Error); } finally { vi.restoreAllMocks(); } }; /** * Scenario: The connectionController changes between the reader being created and #readStream being called. * Verifies the session closes the reader and doesn't continue reading the stream. */ const testSseSessionClosesReaderIfConnectionChanges = async (): Promise => { const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ])); const onConnected = vi.fn(); const abortController = new AbortController(); const onRequest = async (requestInit: RequestInit): Promise => { // Swap out the signal (so it doesn't close the connection) requestInit.signal = abortController.signal; return requestInit; }; const session = new SSESession('http://test.com', { fetch: fetchMock, onRequest, onConnected, persistent: true, retry: testRetry(2) }); // Provide a mock retry which will swap out the connection controller after the session connects const retry = { run: vi.fn().mockImplementation(async (fn) => { const result = await fn(); // "Disconnect" the session // This won't disconnect the fetch since we replaced the signal with a different one session.disconnect(); return result; }), }; // Replace the retry with our mock retry session.options.retry = retry; // Connect the session await session.connect(); // Expect the session to not have connected (it should have hit the exceptional behavior block) expect(onConnected).not.toHaveBeenCalled(); }; /** * Scenario: The #readStream throws an unhandled error * Verifies the error event is emitted and the session is ready for a new connection. */ const testSseSessionEmitsErrorEventOnReadStreamError = async (): Promise => { // The mock fetch will return a "reader" that throws from .read() so that we can hit the catch block in #readStream. // We will also mock the `normalizeError` method to throw, this should cascade up to the .catch handler in `connect` const fetchMock = createFetchMock(() => { return { body: { getReader: vi.fn().mockReturnValue({ read: vi.fn().mockRejectedValue(new Error('Network failure')), }), }, status: 200, statusText: 'OK', headers: new Headers(), ok: true, } as unknown as Response; }); // Mock the normalizeError method to throw, this should cascade up to the .catch handler in `connect` const normalizeErrorSpy = vi.spyOn(utils, 'normalizeError').mockImplementation(() => { throw new Error('Normalize Error failed'); }); // Create listeners for the error and disconnected events const onError = vi.fn(); const onDisconnected = vi.fn(); // Create a session const session = new SSESession('http://test.com', { fetch: fetchMock, onError, onDisconnected, persistent: true, retry: testRetry(2) }); // Connect the session await session.connect(); // Wait for the normalizeError method to be called (since #readStream is run as "fire and forget", connect resolved before our error is thrown) await vi.waitFor(() => expect(normalizeErrorSpy).toHaveBeenCalled()); // Expect the error event to be emitted with the error from the normalizeError method expect(onError).toHaveBeenCalledWith(new Error('Normalize Error failed')); // OnDisconnected should not have been called because the error should have thrown earlier than this emitter expect(onDisconnected).not.toHaveBeenCalled(); }; /** * Scenario: fetch returns 200 but with a null body (misconfigured proxy or server bug). * Verifies create rejects because SSE requires a readable stream body. */ const testSseSessionRejectsWhenResponseBodyIsNull = async (): Promise => { const onError = vi.fn(); const fetchMock = createFetchMock(() => new Response(null, { status: 200, headers: SSE_HEADERS })); const expectedError = new ExponentialBackoffMaxRetriesHitError([ new ResponseBodyNullError() ]); try { await expect(createSession(fetchMock, { onError, retry: testRetry() })).rejects.toThrow(expectedError); expect(onError).toHaveBeenCalled(); } finally { vi.restoreAllMocks(); } }; /** * Scenario: SSE spec resume — after receiving an event with an id, reconnect should send Last-Event-ID. * Verifies the second fetch includes the id from the first event (1234 in storageEvents[0]). */ const testSseSessionSendsLastEventIdOnReconnect = async (): Promise => { const { raw } = storageEvents[0]!; const fetchMock = vi.fn(); fetchMock // First connection: heartbeat chunk, then the event (with id: 1234), then server closes. .mockResolvedValueOnce(sseFetchResponse([ ':\n\n', raw ], { closeWhenDone: true })) // Second connection: just connects, we don't really care .mockResolvedValueOnce(sseFetchResponse([])); const session = new SSESession('http://test.com', { fetch: fetchMock, persistent: true, retry: testRetry(2) }); try { // Registers an onRequest hook that copies the last seen event id into reconnect headers. SSESession.addLastEventIdReconnect(session); // Connect the session await session.connect(); // Wait for the first message to be read, then we will disable the listener await readMessages(session, 1); // Wait for us to fetch 2 times await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); // Expect that the second fetch (so after our first message) includes the Last-Event-ID header with the id of the first message expect(fetchMock).toHaveBeenNthCalledWith( 2, expect.anything(), expect.objectContaining({ headers: expect.objectContaining({ 'Last-Event-ID': '1234', }), }), ); } finally { session.close(); vi.restoreAllMocks(); } }; /** * Scenario: Attempt to add 2 `lastEventIdReconnect` plugins to the same session. * Verifies the second plugin is not added, but no error is thrown */ const testSseSessionIdempotentlyHandlesAddingTheSamePluginTwice = async (): Promise => { // Create a new session const session = new SSESession('http://test.com', { persistent: true, retry: testRetry(2) }); // Add the listener the first time SSESession.addLastEventIdReconnect(session); // Expect it to be added and that there is only a single plugin installed expect(session.plugins.has(SSESession.addLastEventIdReconnect)).toBe(true); expect(session.plugins.size).toBe(1); // Add the listener the second time SSESession.addLastEventIdReconnect(session); // Expect it to be added and that there is only a single plugin installed expect(session.plugins.has(SSESession.addLastEventIdReconnect)).toBe(true); expect(session.plugins.size).toBe(1); }; /** * Scenario: A non-function was provided as the unsubscribe function for a plugin. * Verifies an error is thrown and the plugin is not added. */ const testSseSessionThrowsWhenProvidingANonFunctionAsTheUnsubscribeFunction = async (): Promise => { // Create a new session const session = new SSESession('http://test.com', { persistent: true, retry: testRetry(2) }); // Set the plugin to a non-function session.plugins.set(SSESession.addLastEventIdReconnect, 'not a function'); // Add the listener with a non-function expect(() => SSESession.addLastEventIdReconnect(session)).toThrow(SSESessionUnsubscribePluginNotAFunctionError); }; /** * Scenario: The lastEventIdReconnect plugin can be removed when the unsubscribe method is called. * Verifies the plugin is removed and the session no longer sends the Last-Event-ID header on reconnect. */ const testSseSessionLastEventIdReconnectPluginCanBeRemoved = async (): Promise => { const events = [{ raw: 'id: 1\ndata: 1\n\n' }, { raw: 'id: 2\ndata: 2\n\n' }, { raw: 'data: 3\n\n' }]; const fetchMock = vi.fn(); // Create a mock fetch that will be used to create the session fetchMock // First connection: Send event with id: 1 .mockResolvedValueOnce(sseFetchResponse([ events[0]!.raw ], { closeWhenDone: true })) // Second connection: Send event with id: 2 .mockResolvedValueOnce(sseFetchResponse([ events[1]!.raw ], { closeWhenDone: true })) // Third connection: Send event with id: 3, we don't expect to see the header with id 2 here .mockResolvedValueOnce(sseFetchResponse([ events[2]!.raw ], { closeWhenDone: false })); // Create a new session const session = new SSESession('http://test.com', { fetch: fetchMock, persistent: true, retry: testRetry(2) }); // Add the plugin to the session const { removeListener } = SSESession.addLastEventIdReconnect(session); // Expect the plugin to be added expect(session.plugins.has(SSESession.addLastEventIdReconnect)).toBe(true); // Connect the session await session.connect(); // Wait for the first message to be read, then we will disable the listener await readMessages(session, 1); // Call the unsubscribe method removeListener?.(); // Expect the plugin to be removed expect(session.plugins.has(SSESession.addLastEventIdReconnect)).toBe(false); // Wait for the rest of the messages to be read await readMessages(session, 2); // Wait for us to fetch 3 times await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); // Expect that the second fetch (so after our first message) includes the Last-Event-ID header with the id of the first message expect(fetchMock).toHaveBeenNthCalledWith( 2, expect.anything(), expect.objectContaining({ headers: expect.objectContaining({ 'Last-Event-ID': '1', }), }), ); // Our second reconnect, the listener was disabled, so we don't expect to see the Last-Event-ID header with the id of the second message expect(fetchMock).toHaveBeenNthCalledWith( 3, expect.anything(), expect.objectContaining({ headers: expect.not.objectContaining({ 'Last-Event-ID': expect.any(String), }), }), ); }; /** * Scenario: The browser visibility change handler is supported * Verifies the session is connected and disconnected when the document visibility state changes. */ const testSseSessionBrowserVisibilityChangeIsSupported = async (): Promise => { // Create a fetch mock const response = new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS }); const fetchMock = createFetchMock(() => response); // Create mock callbacks for connection to test connection state changes const onConnected = vi.fn(); const onDisconnected = vi.fn(); // Create, and connect, a session const session = await createSession(fetchMock, { persistent: true, retry: testRetry(2), onConnected, onDisconnected }); // Expect the session to be connected await vi.waitFor(() => expect(onConnected).toHaveBeenCalled()); expect(session.active).toBe(true); // Add browser visibility change listener SSESession.addBrowserVisibilityHandler(session); // Expect the session to be connected expect(session.active).toBe(true); // Set the document visibility state to hidden and dispatch a visibilitychange event documentStub.visibilityState = 'hidden'; documentStub.dispatchEvent(new Event('visibilitychange')); // Expect the session to be disconnected expect(session.active).toBe(false); await vi.waitFor(() => expect(onDisconnected).toHaveBeenCalledWith({ reason: 'disconnect', source: SSESession.addBrowserVisibilityHandler })); // Wait for the session to be disconnected documentStub.visibilityState = 'visible'; documentStub.dispatchEvent(new Event('visibilitychange')); // Expect the session to be connected again expect(session.active).toBe(true); }; /** * Scenario: The browser visibility change handler is only added once * Verifies the handler is only added once and that the session is connected and disconnected when the document visibility state changes. */ const testSseSessionBrowserVisibilityOnlyAddsSingleHandler = async (): Promise => { // Create a fetch mock const response = new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS }); const fetchMock = createFetchMock(() => response); // Create a spy on the document.addEventListener method const addEventListenerSpy = vi.spyOn(documentStub, 'addEventListener'); // Create, and connect, a session const session = await createSession(fetchMock, { persistent: true, retry: testRetry(2) }); // Expect the session to be connected expect(session.active).toBe(true); // Add browser visibility change listener SSESession.addBrowserVisibilityHandler(session); // Expect the plugin to be added and that there is only a single plugin installed expect(session.plugins.size).toBe(1); expect(addEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); expect(addEventListenerSpy).toHaveBeenCalledTimes(1); // Add the listener the second time SSESession.addBrowserVisibilityHandler(session); // Expect the plugin to be added and that there is only a single plugin installed expect(session.plugins.size).toBe(1); expect(addEventListenerSpy).toHaveBeenCalledTimes(1); }; /** * Scenario: The browser visibility change handler throws an error if the existing plugin is not a function * Verifies the handler is not added and an error is thrown. */ const testSseSessionBrowserVisibilityThrowsErrorIfExistingPluginIsNotAFunction = async (): Promise => { // Create a fetch mock const response = new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS }); const fetchMock = createFetchMock(() => response); // Spy on the document.addEventListener method const addEventListenerSpy = vi.spyOn(documentStub, 'addEventListener'); // Create a new session const session = await createSession(fetchMock, { persistent: true, retry: testRetry(2) }); // Set the plugin to a non-function session.plugins.set(SSESession.addBrowserVisibilityHandler, 'not a function'); // Expect an error to be thrown expect(() => SSESession.addBrowserVisibilityHandler(session)).toThrow(SSESessionUnsubscribePluginNotAFunctionError); // Expect the document.addEventListener method to not have been called expect(addEventListenerSpy).toHaveBeenCalledTimes(0); }; /** * Scenario: The browser visibility change handler is removed if the session is disconnected by `disconnect`. * Verifies the handler is removed and the session is no longer connected when the document visibility state changes. */ const testSseSessionBrowserVisibilityRemovesVisibleHandlerIfDisconnectIsCalled = async (): Promise => { // Create a fetch mock const response = new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS }); const fetchMock = createFetchMock(() => response); // Create a mock callbacks for connection to test connection state changes const onConnected = vi.fn(); const onDisconnected = vi.fn(); const onBeforeDisconnect = vi.fn(); // Create a spy on the document.removeEventListener method const removeEventListenerSpy = vi.spyOn(documentStub, 'removeEventListener'); // Create, and connect, a session const session = await createSession(fetchMock, { persistent: true, retry: testRetry(2), onConnected, onDisconnected }); session.on('beforeDisconnect', onBeforeDisconnect); // Expect the session to be connected await vi.waitFor(() => expect(onConnected).toHaveBeenCalled()); expect(session.active).toBe(true); // Add browser visibility change listener SSESession.addBrowserVisibilityHandler(session); // Expect the session to be connected expect(session.active).toBe(true); // Set the document visibility state to hidden and dispatch a visibilitychange event documentStub.visibilityState = 'hidden'; documentStub.dispatchEvent(new Event('visibilitychange')); expect(session.active).toBe(false); await vi.waitFor(() => expect(onBeforeDisconnect).toHaveBeenCalledWith({ source: SSESession.addBrowserVisibilityHandler })); await vi.waitFor(() => expect(onDisconnected).toHaveBeenCalledWith({ reason: 'disconnect', source: SSESession.addBrowserVisibilityHandler })); expect(removeEventListenerSpy).toHaveBeenCalledTimes(0); // Disconnect the session session.disconnect({ source: 'force-disconnect' }); // Should still be disconnected expect(session.active).toBe(false); await vi.waitFor(() => expect(onBeforeDisconnect).toHaveBeenLastCalledWith({ source: 'force-disconnect' })); expect(removeEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); expect(removeEventListenerSpy).toHaveBeenCalledTimes(1); // Set the document visibility state to visible and dispatch a visibilitychange event documentStub.visibilityState = 'visible'; documentStub.dispatchEvent(new Event('visibilitychange')); // Should still be disconnected expect(session.active).toBe(false); }; /** * Scenario: The browser visibility change handler is removed if the `removeListener` method is called. * Verifies the handler is removed and the session is no longer connected when the document visibility state changes. */ const testSseSessionBrowserVisibilityRemovesVisibleHandlerWhenUnsubscribed = async (): Promise => { // Create a fetch mock const response = new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS }); const fetchMock = createFetchMock(() => response); const onConnected = vi.fn(); const onDisconnected = vi.fn(); // Create a new session const session = await createSession(fetchMock, { persistent: true, retry: testRetry(2), onConnected, onDisconnected }); expect(onConnected).toHaveBeenCalled(); expect(session.active).toBe(true); // Add browser visibility change listener const { removeListener } = SSESession.addBrowserVisibilityHandler(session); // Expect the plugin to be added and that there is only a single plugin installed expect(session.plugins.size).toBe(1); // Call the unsubscribe method removeListener?.(); // Expect the plugin to be removed expect(session.plugins.size).toBe(0); // Test the disconnect by hiding the document and dispatching a visibilitychange event documentStub.visibilityState = 'hidden'; documentStub.dispatchEvent(new Event('visibilitychange')); // Expect the session to be disconnected expect(session.active).toBe(true); expect(onDisconnected).not.toHaveBeenCalled(); }; /** * Scenario: The browser visibility change handler is not removed if the method to remove the handler is not the same * Verifies the handler is not removed and the session is still connected when the document visibility state changes. */ const testSseSessionBrowserVisibilityDoesNotRemoveHandlerIfPluginIsNotTheSame = async (): Promise => { // Create a fetch mock const response = new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS }); const fetchMock = createFetchMock(() => response); const onConnected = vi.fn(); const onDisconnected = vi.fn(); // Create a new session const session = await createSession(fetchMock, { persistent: true, retry: testRetry(2), onConnected, onDisconnected }); // Assert that the session connected expect(onConnected).toHaveBeenCalled(); expect(session.active).toBe(true); // Add browser visibility change listener const { removeListener } = SSESession.addBrowserVisibilityHandler(session); // Overwrite the plugin with a different function session.plugins.set(SSESession.addBrowserVisibilityHandler, () => 1); // Call the unsubscribe method removeListener?.(); // Expect the plugin to be removed expect(session.plugins.size).toBe(1); // Test the disconnect by hiding the document and dispatching a visibilitychange event documentStub.visibilityState = 'hidden'; documentStub.dispatchEvent(new Event('visibilitychange')); // Expect the session to be disconnected expect(session.active).toBe(true); await vi.waitFor(() => expect(onDisconnected).not.toHaveBeenCalled()); }; const runTests = async (): Promise => { afterEach(() => { vi.restoreAllMocks(); vi.resetAllMocks(); documentStub.visibilityState = 'visible'; }); test('SSESession.connect: calls injected fetch with method, headers, and abort signal', testSseSessionConnectCallsFetchWithExpectedOptions); test('SSESession.connect: uses default fetch implementation if no fetch is provided', testSseSessionUsesDefaultFetchImplementation); test('SSESession.connect: invokes onConnected when the stream is established', testSseSessionConnectInvokesOnConnected); test('SSESession.connect: does not open a second transport when connect is called again', testSseSessionConnectDoesNotOpenSecondTransport); test('SSESession.connect: passes request mutations from onRequest to fetch', testSseSessionConnectPassesOnRequestMutations); test('SSESession.connect: sends POST bodies for POST-based SSE endpoints', testSseSessionConnectSendsPostBody); test('SSESession: delivers multiple fixture events through a single session', testSseSessionDeliversMultipleFixtureEvents); test('SSESession: emits message events for incoming SSE frames', testSseSessionEmitsMessageEvents); test('SSESession: parses a fixture event split across chunk boundaries', testSseSessionParsesEventSplitAcrossChunks); test('SSESession: closes messages and emits disconnected when persistent is false', testSseSessionClosesOnServerCloseWhenNotPersistent); test('SSESession: opens a new message stream after reconnecting following a terminal close', testSseSessionReconnectsAfterTerminalClose); test('SSESession: reconnects when the server closes the stream and persistent is true', testSseSessionPersistentReconnectsOnServerClose); test('SSESession: reconnects when attemptReconnect is true', testSseSessionReconnectsOnTransportError); test('SSESession: closes messages and emits error when attemptReconnect is false', testSseSessionClosesOnTransportErrorWhenNotReconnecting); test( 'SSESession.disconnect: disconnects fetch, emits disconnected, and keeps messages open', testSseSessionDisconnectEmitsDisconnectedAndKeepsMessagesOpen, ); test('SSESession.close: closes messages and emits closed', testSseSessionCloseClosesMessagesAndEmitsClosed); test('SSESession: calls onError, emits error, and throws from create on HTTP error', testSseSessionHttpErrorCallsOnErrorAndThrows); test('SSESession: closes reader if connection changes', testSseSessionClosesReaderIfConnectionChanges); test('SSESession: emits error event on read stream error', testSseSessionEmitsErrorEventOnReadStreamError); test('SSESession: rejects when the response body is null', testSseSessionRejectsWhenResponseBodyIsNull); test( 'SSESession: lastEventIdReconnect sends Last-Event-ID on reconnect after receiving an event with an id', testSseSessionSendsLastEventIdOnReconnect, ); test( 'SSESession: lastEventIdReconnect idempotently handles adding the same plugin twice', testSseSessionIdempotentlyHandlesAddingTheSamePluginTwice, ); test( 'SSESession: lastEventIdReconnect throws when providing a non-function as the unsubscribe function', testSseSessionThrowsWhenProvidingANonFunctionAsTheUnsubscribeFunction, ); test('SSESession: lastEventIdReconnect plugin can be removed', testSseSessionLastEventIdReconnectPluginCanBeRemoved); test('SSESession: browser visibility change is supported', testSseSessionBrowserVisibilityChangeIsSupported); test('SSESession: browser visibility only adds a single handler', testSseSessionBrowserVisibilityOnlyAddsSingleHandler); test( 'SSESession: browser visibility throws error if existing plugin is not a function', testSseSessionBrowserVisibilityThrowsErrorIfExistingPluginIsNotAFunction, ); test( 'SSESession: browser visibility removes visible handler if disconnect is called', testSseSessionBrowserVisibilityRemovesVisibleHandlerIfDisconnectIsCalled, ); test( 'SSESession: browser visibility removes visible handler when unsubscribed', testSseSessionBrowserVisibilityRemovesVisibleHandlerWhenUnsubscribed, ); test( 'SSESession: browser visibility does not remove handler if plugin is not the same', testSseSessionBrowserVisibilityDoesNotRemoveHandlerIfPluginIsNotTheSame, ); }; await runTests();