Files
xo-cash-utils/test/sse-session/sse-session.test.ts
T
2026-09-17 07:51:48 +00:00

1186 lines
48 KiB
TypeScript

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<Response>): Mock<FetchFn> => {
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<SSESessionOptions> = {
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<SSESessionOptions & SSESessionEventCallbacks> = {}): Promise<SSESession> => {
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 <T>(
fetch: FetchFn,
options: Partial<SSESessionOptions & SSESessionEventCallbacks>,
run: (session: SSESession) => Promise<T>,
): Promise<T> => {
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<SSEvent[]> => {
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<Uint8Array> => {
const encoder = new TextEncoder();
return new ReadableStream({
async start(controller): Promise<void> {
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<void> => {
// 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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
// 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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<Error>((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<void> => {
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<void> => {
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
const session = await createSession(fetchMock, {});
const closed = new Promise<void>((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<void> => {
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<void> => {
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
const onConnected = vi.fn();
const abortController = new AbortController();
const onRequest = async (requestInit: RequestInit): Promise<RequestInit> => {
// 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<void> => {
// 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<void> => {
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<void> => {
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<void> => {
// 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<void> => {
// 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<void> => {
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<void> => {
// 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<void> => {
// 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<void> => {
// 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<void> => {
// 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<void> => {
// 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<void> => {
// 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<void> => {
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();