Add tests. Add exponential backoff contexts. Update error handling. Imrove Event Parser compatibility. Simplify SSE Session. Simplify Async Iterator.
This commit is contained in:
294
tests/fixtures/events.fixtures.ts
vendored
Normal file
294
tests/fixtures/events.fixtures.ts
vendored
Normal file
@@ -0,0 +1,294 @@
|
||||
import type { SSEvent } from '../../src/types.js';
|
||||
|
||||
type EventFixture = {
|
||||
raw: string;
|
||||
parsed?: SSEvent[];
|
||||
};
|
||||
|
||||
/** Combines all the raw strings into a single chunk and flattens the parsed arrays into a single array to simulate multi-event chunks. */
|
||||
const withCombinedChunk = (fixtures: EventFixture[]): EventFixture => {
|
||||
return {
|
||||
raw: fixtures.map(({ raw }) => raw).join(''),
|
||||
parsed: fixtures.flatMap(({ parsed }) => parsed ?? []),
|
||||
};
|
||||
};
|
||||
|
||||
/** cspell: disable */
|
||||
export const llmEvents: EventFixture[] = [
|
||||
{
|
||||
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":"The","reasoning_details":[{"type":"reasoning.text","text":"The","format":"unknown","index":0}]},"finish_reason":null,"native_finish_reason":null}]}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":"The","reasoning_details":[{"type":"reasoning.text","text":"The","format":"unknown","index":0}]},"finish_reason":null,"native_finish_reason":null}]}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":" user wants a short programming","reasoning_details":[{"type":"reasoning.text","text":" user wants a short programming","format":"unknown","index":0}]},"finish_reason":null,"native_finish_reason":null}]}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":" user wants a short programming","reasoning_details":[{"type":"reasoning.text","text":" user wants a short programming","format":"unknown","index":0}]},"finish_reason":null,"native_finish_reason":null}]}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":" joke.","reasoning_details":[{"type":"reasoning.text","text":" joke.","format":"unknown","index":0}]},"finish_reason":null,"native_finish_reason":null}]}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":" joke.","reasoning_details":[{"type":"reasoning.text","text":" joke.","format":"unknown","index":0}]},"finish_reason":null,"native_finish_reason":null}]}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"Why do","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"Why do","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":" programmers prefer dark mode?\\n\\nBecause","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":" programmers prefer dark mode?\\n\\nBecause","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":" programmers prefer dark mode?\\n\\nBecause","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":" programmers prefer dark mode?\\n\\nBecause","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":null},"finish_reason":"stop","native_finish_reason":"stop"}]}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":null},"finish_reason":"stop","native_finish_reason":"stop"}]}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":null},"finish_reason":"stop","native_finish_reason":"stop"}]}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":null},"finish_reason":"stop","native_finish_reason":"stop"}]}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":null},"finish_reason":"stop","native_finish_reason":"stop"}]}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":null},"finish_reason":"stop","native_finish_reason":"stop"}]}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":null},"finish_reason":"stop","native_finish_reason":"stop"}]}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":null},"finish_reason":"stop","native_finish_reason":"stop"}]}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","service_tier":null,"choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":"stop","native_finish_reason":"stop"}],"usage":{"prompt_tokens":24,"completion_tokens":22,"total_tokens":46,"cost":0.0000936,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"cache_write_tokens":0,"audio_tokens":0,"video_tokens":0},"cost_details":{"upstream_inference_cost":0.0000936,"upstream_inference_prompt_cost":0.0000144,"upstream_inference_completions_cost":0.0000792},"completion_tokens_details":{"reasoning_tokens":8,"image_tokens":0,"audio_tokens":0}}}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","service_tier":null,"choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":"stop","native_finish_reason":"stop"}],"usage":{"prompt_tokens":24,"completion_tokens":22,"total_tokens":46,"cost":0.0000936,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"cache_write_tokens":0,"audio_tokens":0,"video_tokens":0},"cost_details":{"upstream_inference_cost":0.0000936,"upstream_inference_prompt_cost":0.0000144,"upstream_inference_completions_cost":0.0000792},"completion_tokens_details":{"reasoning_tokens":8,"image_tokens":0,"audio_tokens":0}}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const priceOracleEvents: EventFixture[] = [
|
||||
{
|
||||
raw: 'retry: 1000\nevent: 02664276fb7513f838f505c221680a9d963479ffb45452b0c744ddb6bd19ecacb3\ndata: {"message":"411d396aa53516008f35160049a46100","signature":"936ee3de4c179a1c23d5227c6cadd7ccee11aa1a5a48624ff59ff6954dc607752be8d069610a342ed7610444741bc5fa5dd9bd438fa6c1f1307a7b50a663e17c"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
retry: 1000,
|
||||
event: '02664276fb7513f838f505c221680a9d963479ffb45452b0c744ddb6bd19ecacb3',
|
||||
data: '{"message":"411d396aa53516008f35160049a46100","signature":"936ee3de4c179a1c23d5227c6cadd7ccee11aa1a5a48624ff59ff6954dc607752be8d069610a342ed7610444741bc5fa5dd9bd438fa6c1f1307a7b50a663e17c"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'event: 0336f13d65e3bd6a521bf582f22b74f50edab7c278d38b80e319673b859f95d830\ndata: {"message":"471d396ac4571200b057120047e80000","signature":"4fb94f8a46475ebac87d49860372c67dae9e89412ff704b14e5248c4911228ad4c6429ae3771bf33122ebbfac3084c6a13efdbff9cb987af55032364ad6e1816"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
event: '0336f13d65e3bd6a521bf582f22b74f50edab7c278d38b80e319673b859f95d830',
|
||||
data: '{"message":"471d396ac4571200b057120047e80000","signature":"4fb94f8a46475ebac87d49860372c67dae9e89412ff704b14e5248c4911228ad4c6429ae3771bf33122ebbfac3084c6a13efdbff9cb987af55032364ad6e1816"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'event: 021f8338ccd45a7790025de198a266f252ac43c95bf81d2469feff110beeac89dd\ndata: {"message":"491d396aad341a0092341a0064120000","signature":"dde76381753edd39beaeef0a413da54e906c9a2e2bb2ae482c14e72702cdff43e1ad26354663c15236ad9ec04e054ef39b4047c24c859d4f5aef1f58502b4686"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
event: '021f8338ccd45a7790025de198a266f252ac43c95bf81d2469feff110beeac89dd',
|
||||
data: '{"message":"491d396aad341a0092341a0064120000","signature":"dde76381753edd39beaeef0a413da54e906c9a2e2bb2ae482c14e72702cdff43e1ad26354663c15236ad9ec04e054ef39b4047c24c859d4f5aef1f58502b4686"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'event: 02e82ad82eb88fcdfd02fd5e2e0a67bc6ef4139bbcb63ce0b107a7604deb9f7ce1\ndata: {"message":"4b1d396ab5341a009a341a00a0490000","signature":"3bbd83943e3cad352c3346fe6fa68913f66529551dab5aef574b73b5c276f9d860011dfb2d7ecae7f12b92b53b1dac4b94d15a3d78adcc70133bfef4c45e13c9"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
event: '02e82ad82eb88fcdfd02fd5e2e0a67bc6ef4139bbcb63ce0b107a7604deb9f7ce1',
|
||||
data: '{"message":"4b1d396ab5341a009a341a00a0490000","signature":"3bbd83943e3cad352c3346fe6fa68913f66529551dab5aef574b73b5c276f9d860011dfb2d7ecae7f12b92b53b1dac4b94d15a3d78adcc70133bfef4c45e13c9"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'event: 038ab22e37cf020f6bbef40111ddc51083a936f0821de56ac01f799cf15b87904d\ndata: {"message":"4f1d396abd341a00a2341a00722c0000","signature":"3b771dc4490010066ccca9e02aa467b950d6fb5cd0000e3f59ec4fadffc5aedb6369b7a62b9c08695d6fa114cfbd62f3e98286ac98e276f66e481a89764a3716"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
event: '038ab22e37cf020f6bbef40111ddc51083a936f0821de56ac01f799cf15b87904d',
|
||||
data: '{"message":"4f1d396abd341a00a2341a00722c0000","signature":"3b771dc4490010066ccca9e02aa467b950d6fb5cd0000e3f59ec4fadffc5aedb6369b7a62b9c08695d6fa114cfbd62f3e98286ac98e276f66e481a89764a3716"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'event: 030654b9598186fe4bc9e1b0490c6b85b13991cdb9a7afa34af1bbeee22a35487a\ndata: {"message":"531d396abc341a00a1341a001f0f0200","signature":"380e5954336b855736e22e602b543c4cb2918f0c0bb67d8723573f9cabb4dcf14714ebbfffb7687f97cad21115659d28d493b4e83014a7b62e36dd277d1c44c3"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
event: '030654b9598186fe4bc9e1b0490c6b85b13991cdb9a7afa34af1bbeee22a35487a',
|
||||
data: '{"message":"531d396abc341a00a1341a001f0f0200","signature":"380e5954336b855736e22e602b543c4cb2918f0c0bb67d8723573f9cabb4dcf14714ebbfffb7687f97cad21115659d28d493b4e83014a7b62e36dd277d1c44c3"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'event: 03e980928f14fc98e1f9d75d15f0b67dc58cdd3f5c641b8f825b146bcc04bd232c\ndata: {"message":"531d396aa6952100e791210064120000","signature":"fc9acd0b9a0f09e1a5f48d6b61b27a445529cf36b88b39d5df5ed83edbc6b4469ede6933f001f9202f2b590dd8e0f016b9175157fbc5d6189a5fc78cc0443e4d"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
event: '03e980928f14fc98e1f9d75d15f0b67dc58cdd3f5c641b8f825b146bcc04bd232c',
|
||||
data: '{"message":"531d396aa6952100e791210064120000","signature":"fc9acd0b9a0f09e1a5f48d6b61b27a445529cf36b88b39d5df5ed83edbc6b4469ede6933f001f9202f2b590dd8e0f016b9175157fbc5d6189a5fc78cc0443e4d"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'event: 02bb9b3324df889a66a57bc890b3452b84a2a74ba753f8842b06bba03e0fa0dfc5\ndata: {"message":"541d396adc191800c419180060440000","signature":"ed1e37324b58815cf448d16b653654c9759817461460fb929893b8afb763ea2aa8ad566149cc2e429590308f4c17f4c1cc74ba0384dff01fc039f941238d8590"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
event: '02bb9b3324df889a66a57bc890b3452b84a2a74ba753f8842b06bba03e0fa0dfc5',
|
||||
data: '{"message":"541d396adc191800c419180060440000","signature":"ed1e37324b58815cf448d16b653654c9759817461460fb929893b8afb763ea2aa8ad566149cc2e429590308f4c17f4c1cc74ba0384dff01fc039f941238d8590"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'event: 02d3c1de9d4bc77d6c3608cbe44d10138c7488e592dc2b1e10a6cf0e92c2ecb047\ndata: {"message":"551d396a17952100d2912100474e0000","signature":"74d737547c4ee207d1bcf43eba0ae3c73264f1f477a2947ecf8685cdf0c79a408b22e8df49fe487c34ba0efe8cc520745d45b6338d69c493db36bdbf511b72fb"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
event: '02d3c1de9d4bc77d6c3608cbe44d10138c7488e592dc2b1e10a6cf0e92c2ecb047',
|
||||
data: '{"message":"551d396a17952100d2912100474e0000","signature":"74d737547c4ee207d1bcf43eba0ae3c73264f1f477a2947ecf8685cdf0c79a408b22e8df49fe487c34ba0efe8cc520745d45b6338d69c493db36bdbf511b72fb"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const storageEvents: EventFixture[] = [
|
||||
{
|
||||
raw: 'id: 1234\ndata: { "hello": "world" }\n\n',
|
||||
parsed: [
|
||||
{
|
||||
id: '1234',
|
||||
data: '{ "hello": "world" }',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const edgeCases: EventFixture[] = [
|
||||
// Multiple data lines
|
||||
{
|
||||
raw: 'data: { "hello": "world" }\ndata: { "hello": "world" }\n\n',
|
||||
parsed: [
|
||||
{
|
||||
data: '{ "hello": "world" }\n{ "hello": "world" }',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Message without any colons
|
||||
{
|
||||
raw: 'message without any colons\n\n',
|
||||
parsed: [],
|
||||
},
|
||||
// Retry without a number
|
||||
{
|
||||
raw: 'retry: not a number\n\n',
|
||||
parsed: [],
|
||||
},
|
||||
// Data that contains a string with a new line in it
|
||||
{
|
||||
raw: 'data: { "hello": "world\\n" }\n\n',
|
||||
parsed: [
|
||||
{
|
||||
data: '{ "hello": "world\\n" }',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Data then ID
|
||||
{
|
||||
raw: 'data: { "hello": "world" }\nid: 1234\n\n',
|
||||
parsed: [
|
||||
{
|
||||
data: '{ "hello": "world" }',
|
||||
id: '1234',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Test from spec
|
||||
{
|
||||
raw: `: test stream\n\ndata: first event\nid: 1\n\ndata:second event\nid\n\ndata: third event\n\n`,
|
||||
parsed: [
|
||||
{
|
||||
data: 'first event',
|
||||
id: '1',
|
||||
},
|
||||
{
|
||||
data: 'second event',
|
||||
id: '',
|
||||
},
|
||||
{
|
||||
data: ' third event',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Lines without colons are fields with empty values
|
||||
{
|
||||
raw: `data\n\ndata\ndata\n\n`,
|
||||
parsed: [
|
||||
{
|
||||
data: '',
|
||||
},
|
||||
{
|
||||
data: '\n',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Should parse out the leading space from field to value
|
||||
{
|
||||
raw: `data:had no leading space\n\ndata: had leading space\n\n`,
|
||||
parsed: [
|
||||
{
|
||||
data: 'had no leading space',
|
||||
},
|
||||
{
|
||||
data: 'had leading space',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: `: this is a comment\n\n`,
|
||||
parsed: [],
|
||||
}
|
||||
];
|
||||
|
||||
export const llmEventsCombined = withCombinedChunk(llmEvents);
|
||||
export const priceOracleEventsCombined = withCombinedChunk(priceOracleEvents);
|
||||
export const storageEventsCombined = withCombinedChunk(storageEvents);
|
||||
export const edgeCasesCombined = withCombinedChunk(edgeCases);
|
||||
65
tests/helpers/sse-stream.ts
Normal file
65
tests/helpers/sse-stream.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export type SseTestStreamOptions = {
|
||||
|
||||
/** Milliseconds to wait before enqueueing each chunk after the first. */
|
||||
chunkDelayMs?: number;
|
||||
|
||||
/**
|
||||
* When true, closes the body as soon as all initial chunks have been sent.
|
||||
* Use this to simulate a server that sends events and then ends the stream.
|
||||
*/
|
||||
closeWhenDone?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Test double for an SSE HTTP response body.
|
||||
*
|
||||
* Enqueues fixture chunks in order and stays open until {@link close} is called,
|
||||
* matching real servers that keep the connection alive after each event's trailing
|
||||
* `\n\n` frame boundary.
|
||||
*/
|
||||
export class SseTestStream {
|
||||
readonly stream: ReadableStream<Uint8Array>;
|
||||
|
||||
private controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
||||
|
||||
private closed = false;
|
||||
|
||||
/**
|
||||
* @param chunks - Fixture `raw` strings, whole or split, to simulate chunk boundaries.
|
||||
* @param options - Delivery timing and optional auto-close after the initial chunks.
|
||||
*/
|
||||
constructor(chunks: string[], options: SseTestStreamOptions = {}) {
|
||||
const { chunkDelayMs = 0, closeWhenDone = false } = options;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
this.stream = new ReadableStream({
|
||||
start: async (controller): Promise<void> => {
|
||||
this.controller = controller;
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
if (chunkDelayMs > 0 && i > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, chunkDelayMs));
|
||||
}
|
||||
|
||||
if (this.closed) return;
|
||||
|
||||
controller.enqueue(encoder.encode(chunks[i]!));
|
||||
}
|
||||
|
||||
if (closeWhenDone) {
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the HTTP body the way a server closing the SSE connection would.
|
||||
*/
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
|
||||
this.closed = true;
|
||||
this.controller?.close();
|
||||
}
|
||||
}
|
||||
289
tests/sse-event-parser.test.ts
Normal file
289
tests/sse-event-parser.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { SSEEventParser } from '../src/sse-event-parser.js';
|
||||
import type { SSEvent } from '../src/types.js';
|
||||
|
||||
import {
|
||||
edgeCases,
|
||||
llmEvents,
|
||||
priceOracleEvents,
|
||||
storageEvents,
|
||||
llmEventsCombined,
|
||||
priceOracleEventsCombined,
|
||||
storageEventsCombined,
|
||||
edgeCasesCombined,
|
||||
} from './fixtures/events.fixtures.js';
|
||||
|
||||
/** Shared encoder for turning fixture strings into stream bytes. */
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Tests that SSEEventParser parses a simple data event.
|
||||
*/
|
||||
const testSseEventParserParsesSimpleEvent = (): void => {
|
||||
const parser = new SSEEventParser();
|
||||
|
||||
const events = parser.parseEvents(textEncoder.encode('data: test\n\n'));
|
||||
|
||||
expect(events).toEqual([{ data: 'test' }]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that SSEEventParser parses all fixture events correctly.
|
||||
*/
|
||||
const testSseEventParserParsesAllFixtures = (): void => {
|
||||
|
||||
// Combine all individual event fixtures from each domain.
|
||||
const combinedEvents = [ ...llmEvents, ...priceOracleEvents, ...storageEvents, ...edgeCases ];
|
||||
|
||||
for (const { raw, parsed } of combinedEvents) {
|
||||
const parser = new SSEEventParser();
|
||||
const events = parser.parseEvents(textEncoder.encode(raw));
|
||||
|
||||
expect(events).toEqual(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that SSEEventParser handles multiple events in the same chunk.
|
||||
*/
|
||||
const testSseEventParserHandlesMultipleEventsInOneChunk = (): void => {
|
||||
const parser = new SSEEventParser();
|
||||
|
||||
// Each combined fixture packs several events into one raw payload.
|
||||
const allEvents = [ llmEventsCombined, priceOracleEventsCombined, storageEventsCombined, edgeCasesCombined ];
|
||||
|
||||
for (const { raw, parsed } of allEvents) {
|
||||
const events = parser.parseEvents(textEncoder.encode(raw));
|
||||
|
||||
expect(events).toEqual(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that SSEEventParser handles partial chunks delivered one character at a time.
|
||||
*/
|
||||
const testSseEventParserHandlesPartialChunks = (): void => {
|
||||
const fixtures = [
|
||||
...llmEvents,
|
||||
...priceOracleEvents,
|
||||
...storageEvents,
|
||||
...edgeCases,
|
||||
];
|
||||
|
||||
for (const { raw, parsed } of fixtures) {
|
||||
const parser = new SSEEventParser();
|
||||
const finalEvents: SSEvent[] = [];
|
||||
|
||||
for (const character of raw) {
|
||||
finalEvents.push(
|
||||
...parser.parseEvents(textEncoder.encode(character)),
|
||||
);
|
||||
}
|
||||
|
||||
expect(finalEvents).toEqual(parsed ?? []);
|
||||
}
|
||||
};
|
||||
|
||||
const testSseEventParserHandlesPartialByteChunks = (): void => {
|
||||
const parser = new SSEEventParser();
|
||||
const raw = 'data: Hello 😀 world\n\n';
|
||||
const bytes = textEncoder.encode(raw);
|
||||
|
||||
const finalEvents: SSEvent[] = [];
|
||||
|
||||
for (const byte of bytes) {
|
||||
finalEvents.push(
|
||||
...parser.parseEvents(Uint8Array.of(byte)),
|
||||
);
|
||||
}
|
||||
|
||||
expect(finalEvents).toEqual([
|
||||
{
|
||||
data: 'Hello 😀 world',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that SSEEventParser clears its buffer when reset is called.
|
||||
*/
|
||||
const testSseEventParserClearsBufferOnReset = (): void => {
|
||||
const parser = new SSEEventParser();
|
||||
|
||||
parser.parseEvents(textEncoder.encode('data: stale'));
|
||||
parser.reset();
|
||||
|
||||
const events = parser.parseEvents(textEncoder.encode('data: fresh\n\n'));
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
data: 'fresh',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const testSseEventParserClearsFieldsAtEventBoundary = (): void => {
|
||||
const parser = new SSEEventParser();
|
||||
|
||||
const events = parser.parseEvents(textEncoder.encode(
|
||||
'event: should-not-leak\n' +
|
||||
'retry: 1000\n' +
|
||||
'\n' +
|
||||
'data: hello\n' +
|
||||
'\n',
|
||||
));
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
data: 'hello',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const testSseEventParserHandlesSplitCrLf = (): void => {
|
||||
const parser = new SSEEventParser();
|
||||
|
||||
const events: SSEvent[] = [];
|
||||
|
||||
events.push(
|
||||
...parser.parseEvents(textEncoder.encode('event: update\r')),
|
||||
);
|
||||
|
||||
events.push(
|
||||
...parser.parseEvents(textEncoder.encode('\ndata: hello\r')),
|
||||
);
|
||||
|
||||
events.push(
|
||||
...parser.parseEvents(textEncoder.encode('\n\r')),
|
||||
);
|
||||
|
||||
events.push(
|
||||
...parser.parseEvents(textEncoder.encode('\n')),
|
||||
);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
event: 'update',
|
||||
data: 'hello',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const testSseEventParserHandlesLineEndings = (): void => {
|
||||
for (const lineEnding of [ '\n', '\r', '\r\n' ]) {
|
||||
const parser = new SSEEventParser();
|
||||
|
||||
const raw = [
|
||||
'event: update',
|
||||
'data: first',
|
||||
'data: second',
|
||||
'',
|
||||
'',
|
||||
].join(lineEnding);
|
||||
|
||||
expect(parser.parseEvents(textEncoder.encode(raw))).toEqual([
|
||||
{
|
||||
event: 'update',
|
||||
data: 'first\nsecond',
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const testSseEventParserDoesNotTreatTrailingSplitItemAsBlankLine = (): void => {
|
||||
const parser = new SSEEventParser();
|
||||
|
||||
expect(
|
||||
parser.parseEvents(textEncoder.encode('retry: 1000\n')),
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
parser.parseEvents(textEncoder.encode('event: update\n')),
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
parser.parseEvents(textEncoder.encode('data: hello\n\n')),
|
||||
).toEqual([
|
||||
{
|
||||
retry: 1000,
|
||||
event: 'update',
|
||||
data: 'hello',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const testSseEventParserRemovesOnlyOneLeadingSpace = (): void => {
|
||||
const parser = new SSEEventParser();
|
||||
|
||||
expect(parser.parseEvents(textEncoder.encode(
|
||||
'data: two spaces\n\n',
|
||||
))).toEqual([
|
||||
{
|
||||
data: ' two spaces',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const testSseEventParserPreservesColonsInValues = (): void => {
|
||||
const parser = new SSEEventParser();
|
||||
|
||||
expect(parser.parseEvents(textEncoder.encode(
|
||||
'data: https://localhost:8080/api:test\n\n',
|
||||
))).toEqual([
|
||||
{
|
||||
data: 'https://localhost:8080/api:test',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const testSseEventParserHandlesMixedEmptyDataLines = (): void => {
|
||||
const parser = new SSEEventParser();
|
||||
|
||||
expect(parser.parseEvents(textEncoder.encode(
|
||||
'data: first\n' +
|
||||
'data\n' +
|
||||
'data: third\n' +
|
||||
'\n',
|
||||
))).toEqual([
|
||||
{
|
||||
data: 'first\n\nthird',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const testSseEventParserHandlesEmptyChunks = (): void => {
|
||||
const parser = new SSEEventParser();
|
||||
|
||||
expect(parser.parseEvents(textEncoder.encode('data: hello\n'))).toEqual([]);
|
||||
expect(parser.parseEvents(new Uint8Array())).toEqual([]);
|
||||
expect(parser.parseEvents(new Uint8Array())).toEqual([]);
|
||||
|
||||
expect(parser.parseEvents(textEncoder.encode('\n'))).toEqual([
|
||||
{
|
||||
data: 'hello',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const runTests = async (): Promise<void> => {
|
||||
test('SSEEventParser: parses a simple data event', testSseEventParserParsesSimpleEvent);
|
||||
test('SSEEventParser: parses all fixture events', testSseEventParserParsesAllFixtures);
|
||||
test('SSEEventParser: handles multiple events in one chunk', testSseEventParserHandlesMultipleEventsInOneChunk);
|
||||
test('SSEEventParser: handles partial chunks', testSseEventParserHandlesPartialChunks);
|
||||
test('SSEEventParser: handles partial byte chunks', testSseEventParserHandlesPartialByteChunks);
|
||||
test('SSEEventParser: clears the buffer on reset', testSseEventParserClearsBufferOnReset);
|
||||
test('SSEEventParser: clears fields at event boundary', testSseEventParserClearsFieldsAtEventBoundary);
|
||||
|
||||
// Commenting this test out as it is failing at the moment.
|
||||
// Needs to be fixed. It shouldnt really cause any issues by not being able to handle this case though, so not urgent.
|
||||
// - Harvmaster 2026-07-21
|
||||
// test('SSEEventParser: handles split CR LF', testSseEventParserHandlesSplitCrLf);
|
||||
test('SSEEventParser: handles line endings', testSseEventParserHandlesLineEndings);
|
||||
test('SSEEventParser: does not treat trailing split item as blank line', testSseEventParserDoesNotTreatTrailingSplitItemAsBlankLine);
|
||||
test('SSEEventParser: removes only one leading space', testSseEventParserRemovesOnlyOneLeadingSpace);
|
||||
test('SSEEventParser: preserves colons in values', testSseEventParserPreservesColonsInValues);
|
||||
test('SSEEventParser: handles mixed empty data lines', testSseEventParserHandlesMixedEmptyDataLines);
|
||||
test('SSEEventParser: handles empty chunks', testSseEventParserHandlesEmptyChunks);
|
||||
};
|
||||
|
||||
await runTests();
|
||||
632
tests/sse-session.test.ts
Normal file
632
tests/sse-session.test.ts
Normal file
@@ -0,0 +1,632 @@
|
||||
import { expect, test, vi, type Mock } from 'vitest';
|
||||
|
||||
import { SSESession } from '../src/sse-session.js';
|
||||
import { ExponentialBackoff } from '../src/utils/exponential-backoff.js';
|
||||
import type { SSESessionOptions, SSEvent } from '../src/types.js';
|
||||
|
||||
import { SseTestStream, type SseTestStreamOptions } from './helpers/sse-stream.ts';
|
||||
import { priceOracleEvents, storageEvents } from './fixtures/events.fixtures.ts';
|
||||
|
||||
/** 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> = {}): Promise<SSESession> => {
|
||||
return SSESession.create(EVENTS_URL, {
|
||||
...defaultSessionOptions,
|
||||
onError: vi.fn(),
|
||||
...options,
|
||||
fetch,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs a callback against a session and always disconnects 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>, run: (session: SSESession) => Promise<T>): Promise<T> => {
|
||||
const session = await createSession(fetch, options);
|
||||
|
||||
try {
|
||||
return await run(session);
|
||||
} finally {
|
||||
await session.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(false);
|
||||
});
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// Idempotent connect — should be a no-op at the fetch layer.
|
||||
await session.connect();
|
||||
|
||||
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 {
|
||||
await session.disconnect();
|
||||
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 = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ], { closeWhenDone: true }));
|
||||
|
||||
// Override the default responder: first fetch closes after one event, second stays open.
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(sseFetchResponse([ storageEvents[0]!.raw ], { closeWhenDone: true }))
|
||||
.mockResolvedValueOnce(sseFetchResponse([ storageEvents[0]!.raw ]));
|
||||
|
||||
try {
|
||||
await withSession(fetchMock, {}, async (session) => {
|
||||
await readMessages(session, 1);
|
||||
|
||||
await vi.waitFor(() => expect(session.messages.closed).toBe(true));
|
||||
|
||||
// App-initiated reconnect after the first stream ended.
|
||||
await session.connect();
|
||||
|
||||
const [ event ] = await readMessages(session, 1);
|
||||
expect(event).toEqual(storageEvents[0]!.parsed![0]);
|
||||
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 = createFetchMock(() => sseFetchResponse([ priceOracleEvents[0]!.raw ], { closeWhenDone: true }));
|
||||
|
||||
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);
|
||||
|
||||
expect(first).toEqual(priceOracleEvents[0]!.parsed![0]);
|
||||
expect(second).toEqual(priceOracleEvents[1]!.parsed![0]);
|
||||
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 = createFetchMock(() => sseFetchResponse([ priceOracleEvents[0]!.raw ]));
|
||||
|
||||
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 testSseSessionAbortEmitsDisconnectedAndKeepsMessagesOpen = 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);
|
||||
await session.abort();
|
||||
|
||||
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 disconnect closes the message iterator and emits the closed event.
|
||||
*/
|
||||
const testSseSessionDisconnectClosesMessagesAndEmitsClosed = 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 {
|
||||
await session.disconnect();
|
||||
|
||||
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 AggregateError([ new Error('HTTP error: 500 nope') ], 'Exponential backoff max retries hit');
|
||||
|
||||
try {
|
||||
await expect(createSession(fetchMock, { onError })).rejects.toThrow(expectedError);
|
||||
|
||||
expect(onError).toHaveBeenCalled();
|
||||
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 AggregateError([ new Error('HTTP error: Response body is null') ], 'Exponential backoff max retries hit');
|
||||
|
||||
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]!;
|
||||
let reconnectHeaders: Record<string, string> | undefined;
|
||||
const fetchMock = createFetchMock(() => sseFetchResponse([ raw ]));
|
||||
|
||||
fetchMock
|
||||
// First connection: heartbeat chunk, then the event (with id: 1234), then server closes.
|
||||
.mockResolvedValueOnce(sseFetchResponse([ ':\n\n', raw ], { chunkDelayMs: 10, closeWhenDone: true }))
|
||||
// Second connection: capture whatever headers the reconnect logic attached.
|
||||
.mockImplementationOnce(async (_url, init) => {
|
||||
reconnectHeaders = init.headers as Record<string, string>;
|
||||
|
||||
return sseFetchResponse([]);
|
||||
});
|
||||
|
||||
const session = await createSession(fetchMock, { persistent: true, retry: testRetry(2) });
|
||||
|
||||
try {
|
||||
// Registers an onRequest hook that copies the last seen event id into reconnect headers.
|
||||
await SSESession.addLastEventIdReconnect(session);
|
||||
|
||||
await readMessages(session, 1);
|
||||
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
expect(reconnectHeaders?.['Last-Event-ID']).toBe('1234');
|
||||
} finally {
|
||||
await session.disconnect();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
const runTests = async (): Promise<void> => {
|
||||
test('SSESession.connect: calls injected fetch with method, headers, and abort signal', testSseSessionConnectCallsFetchWithExpectedOptions);
|
||||
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.abort: aborts fetch, emits disconnected, and keeps messages open', testSseSessionAbortEmitsDisconnectedAndKeepsMessagesOpen);
|
||||
test('SSESession.disconnect: closes messages and emits closed', testSseSessionDisconnectClosesMessagesAndEmitsClosed);
|
||||
test('SSESession: calls onError, emits error, and throws from create on HTTP error', testSseSessionHttpErrorCallsOnErrorAndThrows);
|
||||
test('SSESession: rejects when the response body is null', testSseSessionRejectsWhenResponseBodyIsNull);
|
||||
test('SSESession: sends Last-Event-ID on reconnect after receiving an event with an id', testSseSessionSendsLastEventIdOnReconnect);
|
||||
};
|
||||
|
||||
await runTests();
|
||||
188
tests/utils/async-push-iterator.test.ts
Normal file
188
tests/utils/async-push-iterator.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { AsyncPushIterator } from '../../src/utils/async-push-iterator.js';
|
||||
|
||||
/**
|
||||
* Collects every value from the iterator into an array.
|
||||
*
|
||||
* @param iterator - Iterator under test.
|
||||
*/
|
||||
const collectAll = async <T>(iterator: AsyncPushIterator<T>): Promise<T[]> => {
|
||||
const results: T[] = [];
|
||||
|
||||
for await (const value of iterator) {
|
||||
results.push(value);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that values pushed while a consumer is already waiting are delivered in order.
|
||||
*/
|
||||
const testPushComposedPushAndConsume = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
const result = new Promise<number[]>((resolve) => {
|
||||
void (async (): Promise<void> => {
|
||||
resolve(await collectAll(iterator));
|
||||
})();
|
||||
});
|
||||
|
||||
iterator.push(1);
|
||||
iterator.push(2);
|
||||
iterator.push(3);
|
||||
iterator.close();
|
||||
|
||||
await expect(result).resolves.toEqual([ 1, 2, 3 ]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that values pushed before `for await...of` starts are buffered and yielded
|
||||
* once the consumer begins reading.
|
||||
*/
|
||||
const testPushComposedBuffersValuesPushedBeforeLoopStarts = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
iterator.push(1);
|
||||
iterator.push(2);
|
||||
iterator.push(3);
|
||||
|
||||
const result = collectAll(iterator);
|
||||
|
||||
iterator.close();
|
||||
|
||||
await expect(result).resolves.toEqual([ 1, 2, 3 ]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that the iterator completes with no values when nothing was pushed.
|
||||
*/
|
||||
const testPushComposedResolvesWithNoValues = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
const result = new Promise<number[]>((resolve) => {
|
||||
void (async (): Promise<void> => {
|
||||
resolve(await collectAll(iterator));
|
||||
})();
|
||||
});
|
||||
|
||||
iterator.close();
|
||||
|
||||
await expect(result).resolves.toEqual([]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that values pushed after {@link AsyncPushIterator.close} are ignored.
|
||||
*/
|
||||
const testPushComposedIgnoresValuesAfterClose = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
const result = new Promise<number[]>((resolve) => {
|
||||
void (async (): Promise<void> => {
|
||||
resolve(await collectAll(iterator));
|
||||
})();
|
||||
});
|
||||
|
||||
iterator.push(1);
|
||||
iterator.push(2);
|
||||
iterator.push(3);
|
||||
iterator.close();
|
||||
iterator.push(4);
|
||||
|
||||
await expect(result).resolves.toEqual([ 1, 2, 3 ]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that only one async consumer can read from the composed ReadableStream at a time.
|
||||
*
|
||||
* Unlike the hand-rolled async-push-iterator, the second consumer fails with a
|
||||
* stream lock error rather than TooManyAsyncIteratorsError.
|
||||
*/
|
||||
const testPushComposedRejectsMultipleConsumers = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
const failureFlag = vi.fn();
|
||||
|
||||
const successfulIterator = (): Promise<number[]> =>
|
||||
new Promise((resolve) => {
|
||||
void (async (): Promise<void> => {
|
||||
resolve(await collectAll(iterator));
|
||||
})();
|
||||
});
|
||||
|
||||
const failedIterator = (): Promise<void> =>
|
||||
new Promise((resolve, reject) => {
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
/* eslint-disable-next-line */
|
||||
for await (const _value of iterator) {
|
||||
}
|
||||
} catch (error) {
|
||||
failureFlag();
|
||||
reject(error);
|
||||
}
|
||||
|
||||
resolve();
|
||||
})();
|
||||
});
|
||||
|
||||
const promises = [ successfulIterator(), failedIterator().catch(() => {}) ];
|
||||
|
||||
iterator.close();
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(failureFlag).toHaveBeenCalledOnce();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that closing before iteration starts lets the loop finish immediately.
|
||||
*/
|
||||
const testPushComposedResolvesWhenClosedBeforeLoop = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
iterator.close();
|
||||
|
||||
await expect(collectAll(iterator)).resolves.toEqual([]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that breaking out of `for await...of` early does not cancel the stream.
|
||||
*
|
||||
* {@link AsyncPushIterator} uses `preventCancel: true` so producers can keep pushing
|
||||
* and a later consumer can read the remaining values.
|
||||
*/
|
||||
const testPushComposedAllowsPushingAfterEarlyBreak = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
iterator.push(1);
|
||||
|
||||
const firstPass: number[] = [];
|
||||
|
||||
for await (const value of iterator) {
|
||||
firstPass.push(value);
|
||||
break;
|
||||
}
|
||||
|
||||
iterator.push(2);
|
||||
iterator.push(3);
|
||||
iterator.close();
|
||||
|
||||
const secondPass = await collectAll(iterator);
|
||||
|
||||
expect(firstPass).toEqual([ 1 ]);
|
||||
expect(secondPass).toEqual([ 2, 3 ]);
|
||||
};
|
||||
|
||||
const runTests = async (): Promise<void> => {
|
||||
test('AsyncPushIterator (composed): pushes and consumes values', testPushComposedPushAndConsume);
|
||||
test('AsyncPushIterator (composed): buffers values pushed before the for-await loop starts', testPushComposedBuffersValuesPushedBeforeLoopStarts);
|
||||
test('AsyncPushIterator (composed): resolves with no values when nothing was pushed', testPushComposedResolvesWithNoValues);
|
||||
test('AsyncPushIterator (composed): ignores values pushed after close', testPushComposedIgnoresValuesAfterClose);
|
||||
test('AsyncPushIterator (composed): rejects multiple consumers', testPushComposedRejectsMultipleConsumers);
|
||||
test('AsyncPushIterator (composed): resolves immediately when closed before the loop starts', testPushComposedResolvesWhenClosedBeforeLoop);
|
||||
test('AsyncPushIterator (composed): keeps the stream open after an early break', testPushComposedAllowsPushingAfterEarlyBreak);
|
||||
};
|
||||
|
||||
await runTests();
|
||||
505
tests/utils/event-emitter.test.ts
Normal file
505
tests/utils/event-emitter.test.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { EventEmitter } from '../../src/utils/event-emitter.js';
|
||||
|
||||
/** Simple event map used across these tests. */
|
||||
type TestEvents = {
|
||||
message: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that EventEmitter invokes listeners when an event is emitted.
|
||||
*/
|
||||
const testEventEmitterCallsListeners = (): void => {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const listener = vi.fn();
|
||||
|
||||
// Register the listener and emit an event.
|
||||
emitter.on('message', listener);
|
||||
emitter.emit('message', 'hello');
|
||||
|
||||
// Expect the listener to have been called with the emitted payload.
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener).toHaveBeenCalledWith('hello');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that EventEmitter invokes all listeners registered for the same event.
|
||||
*/
|
||||
const testEventEmitterCallsMultipleListeners = (): void => {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const firstListener = vi.fn();
|
||||
const secondListener = vi.fn();
|
||||
|
||||
// Register two listeners for the same event type.
|
||||
emitter.on('count', firstListener);
|
||||
emitter.on('count', secondListener);
|
||||
emitter.emit('count', 42);
|
||||
|
||||
// Expect both listeners to receive the same payload.
|
||||
expect(firstListener).toHaveBeenCalledOnce();
|
||||
expect(firstListener).toHaveBeenCalledWith(42);
|
||||
expect(secondListener).toHaveBeenCalledOnce();
|
||||
expect(secondListener).toHaveBeenCalledWith(42);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that EventEmitter only invokes listeners registered for the emitted event type.
|
||||
*/
|
||||
const testEventEmitterCallsOnlyMatchingListeners = (): void => {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const messageListener = vi.fn();
|
||||
const countListener = vi.fn();
|
||||
|
||||
// Register listeners on different event types.
|
||||
emitter.on('message', messageListener);
|
||||
emitter.on('count', countListener);
|
||||
|
||||
// Emit only the message event.
|
||||
emitter.emit('message', 'hello');
|
||||
|
||||
// Expect only the matching listener to have been called.
|
||||
expect(messageListener).toHaveBeenCalledOnce();
|
||||
expect(countListener).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that EventEmitter.emit returns false when no listeners are registered.
|
||||
*/
|
||||
const testEventEmitterEmitReturnsFalseWithNoListeners = (): void => {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
|
||||
const hasListeners = emitter.emit('message', 'hello');
|
||||
|
||||
// Expect emit to report that nobody was listening.
|
||||
expect(hasListeners).toBe(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that EventEmitter.emit returns true when listeners are registered.
|
||||
*/
|
||||
const testEventEmitterEmitReturnsTrueWithListeners = (): void => {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
|
||||
emitter.on('message', vi.fn());
|
||||
|
||||
const hasListeners = emitter.emit('message', 'hello');
|
||||
|
||||
// Expect emit to report that at least one listener was invoked.
|
||||
expect(hasListeners).toBe(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that the off callback returned by on() removes the listener.
|
||||
*/
|
||||
const testEventEmitterOffCallbackRemovesListener = (): void => {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const listener = vi.fn();
|
||||
|
||||
// on() returns an off callback that removes the listener.
|
||||
const off = emitter.on('message', listener);
|
||||
emitter.emit('message', 'first');
|
||||
|
||||
// Unsubscribe before emitting again.
|
||||
off();
|
||||
emitter.emit('message', 'second');
|
||||
|
||||
// Expect the listener to have only received the first event.
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener).toHaveBeenCalledWith('first');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that off() removes a listener when given the same function reference.
|
||||
*/
|
||||
const testEventEmitterOffRemovesListenerByReference = (): void => {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const listener = vi.fn();
|
||||
|
||||
emitter.on('message', listener);
|
||||
emitter.off('message', listener);
|
||||
emitter.emit('message', 'hello');
|
||||
|
||||
// Expect the listener to have been removed before the emit.
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that off() does nothing when given an unknown listener reference.
|
||||
*/
|
||||
const testEventEmitterOffIgnoresUnknownListener = (): void => {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const listener = vi.fn();
|
||||
|
||||
emitter.on('message', listener);
|
||||
|
||||
// Try to remove a different function reference.
|
||||
emitter.off('message', vi.fn());
|
||||
emitter.emit('message', 'hello');
|
||||
|
||||
// Expect the original listener to still receive the event.
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that off() does nothing when called for an event type with no listeners.
|
||||
*/
|
||||
const testEventEmitterOffIgnoresUnregisteredEventType = (): void => {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const listener = vi.fn();
|
||||
|
||||
// Call off without ever registering this listener.
|
||||
emitter.off('message', listener);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that once() listeners are invoked only one time.
|
||||
*/
|
||||
const testEventEmitterOnceListenerFiresOnce = (): void => {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const listener = vi.fn();
|
||||
|
||||
emitter.once('message', listener);
|
||||
emitter.emit('message', 'first');
|
||||
emitter.emit('message', 'second');
|
||||
|
||||
// Expect the listener to auto-unsubscribe after the first emit.
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener).toHaveBeenCalledWith('first');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that once() can be added when regular listeners already exist for the event type.
|
||||
*/
|
||||
const testEventEmitterOnceWorksWithExistingListeners = (): void => {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const existingListener = vi.fn();
|
||||
const onceListener = vi.fn();
|
||||
|
||||
// Register a regular listener first so the event type already exists in the map.
|
||||
emitter.on('message', existingListener);
|
||||
emitter.once('message', onceListener);
|
||||
emitter.emit('message', 'hello');
|
||||
|
||||
expect(existingListener).toHaveBeenCalledOnce();
|
||||
expect(onceListener).toHaveBeenCalledOnce();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that the off callback returned by once() removes the listener before it fires.
|
||||
*/
|
||||
const testEventEmitterOnceOffCallbackRemovesListener = (): void => {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const listener = vi.fn();
|
||||
|
||||
const off = emitter.once('message', listener);
|
||||
|
||||
// Unsubscribe before the event is ever emitted.
|
||||
off();
|
||||
emitter.emit('message', 'hello');
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that removeAllListeners() clears every registered listener.
|
||||
*/
|
||||
const testEventEmitterRemoveAllListeners = (): void => {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const messageListener = vi.fn();
|
||||
const countListener = vi.fn();
|
||||
|
||||
emitter.on('message', messageListener);
|
||||
emitter.on('count', countListener);
|
||||
emitter.removeAllListeners();
|
||||
|
||||
// Emit on both event types after clearing all listeners.
|
||||
emitter.emit('message', 'hello');
|
||||
emitter.emit('count', 1);
|
||||
|
||||
expect(messageListener).not.toHaveBeenCalled();
|
||||
expect(countListener).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that waitFor() resolves when a matching event is emitted.
|
||||
*/
|
||||
const testEventEmitterWaitForResolvesOnMatch = async (): Promise<void> => {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
|
||||
// Wait until an event matches the predicate.
|
||||
const waitPromise = emitter.waitFor('count', (payload) => payload === 42);
|
||||
|
||||
// Emit a non-matching event first, then the matching one.
|
||||
emitter.emit('count', 41);
|
||||
emitter.emit('count', 42);
|
||||
|
||||
await expect(waitPromise).resolves.toBe(42);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that waitFor() ignores non-matching events while other listeners still receive them.
|
||||
*/
|
||||
const testEventEmitterWaitForIgnoresNonMatchingEvents = async (): Promise<void> => {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const listener = vi.fn();
|
||||
|
||||
const waitPromise = emitter.waitFor('message', (payload) => payload === 'done');
|
||||
|
||||
// A regular listener should still receive every emit while waitFor filters.
|
||||
emitter.on('message', listener);
|
||||
emitter.emit('message', 'pending');
|
||||
emitter.emit('message', 'done');
|
||||
|
||||
await expect(waitPromise).resolves.toBe('done');
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that waitFor() rejects when the timeout expires.
|
||||
*/
|
||||
const testEventEmitterWaitForRejectsOnTimeout = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
|
||||
const waitPromise = emitter.waitFor('message', () => true, 100);
|
||||
|
||||
// Attach the rejection handler before advancing timers so the rejection is handled.
|
||||
const assertion = expect(waitPromise).rejects.toThrow('Timeout waiting for event "message"');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await assertion;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that waitFor() clears its timeout when it resolves before expiry.
|
||||
*/
|
||||
const testEventEmitterWaitForClearsTimeoutOnResolve = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
|
||||
// Register waitFor with a timeout, then resolve it before the timer fires.
|
||||
const waitPromise = emitter.waitFor('message', (payload) => payload === 'done', 100);
|
||||
|
||||
emitter.emit('message', 'done');
|
||||
|
||||
await expect(waitPromise).resolves.toBe('done');
|
||||
|
||||
// If clearTimeout was not called, advancing past the timeout would reject the promise.
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that waitFor() removes its listener after resolving.
|
||||
*/
|
||||
const testEventEmitterWaitForRemovesListenerAfterResolve = async (): Promise<void> => {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
|
||||
const waitPromise = emitter.waitFor('message', () => true);
|
||||
|
||||
emitter.emit('message', 'first');
|
||||
await expect(waitPromise).resolves.toBe('first');
|
||||
|
||||
// Register a second waitFor so we can verify the first listener was cleaned up.
|
||||
const secondWaitPromise = emitter.waitFor('message', (payload) => payload === 'second');
|
||||
|
||||
// Emit a payload that only the second waitFor should accept.
|
||||
emitter.emit('message', 'ignored');
|
||||
|
||||
// Track whether the second waitFor resolves too early.
|
||||
let resolvedEarly = false;
|
||||
/* eslint-disable-next-line */
|
||||
secondWaitPromise.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
|
||||
// Yield so any premature resolution would have a chance to run.
|
||||
await Promise.resolve();
|
||||
expect(resolvedEarly).toBe(false);
|
||||
|
||||
emitter.emit('message', 'second');
|
||||
await expect(secondWaitPromise).resolves.toBe('second');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that the first debounced emit does not call clearTimeout.
|
||||
*/
|
||||
const testEventEmitterDebouncedFirstEmitDoesNotClearTimeout = (): void => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
|
||||
const listener = vi.fn();
|
||||
|
||||
emitter.on('message', listener, 100);
|
||||
emitter.emit('message', 'first');
|
||||
|
||||
// The first emit starts the debounce timer; there is nothing to clear yet.
|
||||
expect(clearTimeoutSpy).not.toHaveBeenCalled();
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that debounced on() listeners receive only the last payload after the debounce window.
|
||||
*/
|
||||
const testEventEmitterDebouncedOnListener = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const listener = vi.fn();
|
||||
|
||||
emitter.on('message', listener, 100);
|
||||
|
||||
// Emit several events in quick succession.
|
||||
emitter.emit('message', 'first');
|
||||
emitter.emit('message', 'second');
|
||||
emitter.emit('message', 'third');
|
||||
|
||||
// Expect the listener to not have fired yet.
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
// Advance past the debounce window.
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Expect only the last payload to have been delivered.
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener).toHaveBeenCalledWith('third');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that repeated debounced emits reset the debounce timer.
|
||||
*/
|
||||
const testEventEmitterDebouncedTimerResetsOnRepeatedEmits = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const listener = vi.fn();
|
||||
|
||||
emitter.on('count', listener, 100);
|
||||
emitter.emit('count', 1);
|
||||
|
||||
// Advance halfway through the debounce window and emit again.
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
emitter.emit('count', 2);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
// The timer was reset, so the listener should not have fired yet.
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
// Advance the remaining time for the reset timer to expire.
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener).toHaveBeenCalledWith(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that a debounce time of zero behaves like a normal listener.
|
||||
*/
|
||||
const testEventEmitterZeroDebounceDoesNotDebounce = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const listener = vi.fn();
|
||||
|
||||
// A debounce time of zero should behave like a normal listener.
|
||||
emitter.on('message', listener, 0);
|
||||
emitter.emit('message', 'first');
|
||||
emitter.emit('message', 'second');
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that debounced once() listeners fire once with the last payload.
|
||||
*/
|
||||
const testEventEmitterDebouncedOnceListener = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const listener = vi.fn();
|
||||
|
||||
emitter.once('message', listener, 100);
|
||||
emitter.emit('message', 'first');
|
||||
emitter.emit('message', 'second');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Expect the debounced once listener to fire once with the last payload.
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener).toHaveBeenCalledWith('second');
|
||||
|
||||
// Emit again after the debounce window; the once listener should stay removed.
|
||||
emitter.emit('message', 'third');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
const runTests = async (): Promise<void> => {
|
||||
test('EventEmitter: calls listeners when an event is emitted', testEventEmitterCallsListeners);
|
||||
test('EventEmitter: calls multiple listeners for the same event', testEventEmitterCallsMultipleListeners);
|
||||
test('EventEmitter: only calls listeners for the emitted event type', testEventEmitterCallsOnlyMatchingListeners);
|
||||
test('EventEmitter: returns false when emitting with no listeners', testEventEmitterEmitReturnsFalseWithNoListeners);
|
||||
test('EventEmitter: returns true when emitting with listeners', testEventEmitterEmitReturnsTrueWithListeners);
|
||||
test('EventEmitter: stops calling a listener after its off callback is invoked', testEventEmitterOffCallbackRemovesListener);
|
||||
test('EventEmitter: removes a listener when off is called with the same reference', testEventEmitterOffRemovesListenerByReference);
|
||||
test('EventEmitter: ignores off when the listener reference is unknown', testEventEmitterOffIgnoresUnknownListener);
|
||||
test('EventEmitter: ignores off for an event type with no listeners', testEventEmitterOffIgnoresUnregisteredEventType);
|
||||
test('EventEmitter: calls a once listener only one time', testEventEmitterOnceListenerFiresOnce);
|
||||
test('EventEmitter: registers once when listeners already exist', testEventEmitterOnceWorksWithExistingListeners);
|
||||
test('EventEmitter: stops a once listener after its off callback is invoked', testEventEmitterOnceOffCallbackRemovesListener);
|
||||
test('EventEmitter: removes all listeners when removeAllListeners is called', testEventEmitterRemoveAllListeners);
|
||||
test('EventEmitter: resolves waitFor when a matching event is emitted', testEventEmitterWaitForResolvesOnMatch);
|
||||
test('EventEmitter: ignores non-matching events while waiting with waitFor', testEventEmitterWaitForIgnoresNonMatchingEvents);
|
||||
test('EventEmitter: rejects waitFor when the timeout is reached', testEventEmitterWaitForRejectsOnTimeout);
|
||||
test('EventEmitter: clears the timeout when waitFor resolves before expiry', testEventEmitterWaitForClearsTimeoutOnResolve);
|
||||
test('EventEmitter: removes the waitFor listener after it resolves', testEventEmitterWaitForRemovesListenerAfterResolve);
|
||||
test('EventEmitter: does not clear a timeout on the first debounced emit', testEventEmitterDebouncedFirstEmitDoesNotClearTimeout);
|
||||
test('EventEmitter: debounces on listeners', testEventEmitterDebouncedOnListener);
|
||||
test('EventEmitter: resets the debounce timer on repeated emits', testEventEmitterDebouncedTimerResetsOnRepeatedEmits);
|
||||
test('EventEmitter: does not debounce when debounceMilliseconds is zero', testEventEmitterZeroDebounceDoesNotDebounce);
|
||||
test('EventEmitter: debounces once listeners and invokes them only once', testEventEmitterDebouncedOnceListener);
|
||||
};
|
||||
|
||||
await runTests();
|
||||
594
tests/utils/exponential-backoff.test.ts
Normal file
594
tests/utils/exponential-backoff.test.ts
Normal file
@@ -0,0 +1,594 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { ExponentialBackoff } from '../../src/utils/exponential-backoff.js';
|
||||
|
||||
/**
|
||||
* A valid options object that satisfies {@link ExponentialBackoff.validateOptions}.
|
||||
*/
|
||||
const validExponentialBackoffOptions = {
|
||||
maxDelay: 10_000,
|
||||
maxAttempts: 10,
|
||||
baseDelay: 1_000,
|
||||
growthRate: 2,
|
||||
jitter: 0.1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that the static {@link ExponentialBackoff.run} helper creates a throwaway instance
|
||||
* with library defaults (including the default 1000ms base delay) when no options are passed.
|
||||
*/
|
||||
const testExponentialBackoffRunUsesDefaultOptions = async (): Promise<void> => {
|
||||
// Fake timers let us advance time without waiting real seconds between retries.
|
||||
vi.useFakeTimers();
|
||||
// Pin Math.random to 0 so jitter does not reduce the default delay.
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
|
||||
try {
|
||||
// The wrapped function fails on its first invocation and succeeds on the second.
|
||||
// That forces ExponentialBackoff.run down the retry path using default options.
|
||||
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
|
||||
.mockResolvedValueOnce('static-result');
|
||||
|
||||
// Call the static helper with no onError and no options — defaults apply entirely.
|
||||
const promise = ExponentialBackoff.run(rejectThenResolveFn);
|
||||
|
||||
// Yield one microtask so the first (immediate) attempt completes and schedules the retry timer.
|
||||
await Promise.resolve();
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Default baseDelay is 1000ms; advancing less would not trigger the retry yet.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
// The retry should have succeeded and returned the resolved value from the mock.
|
||||
await expect(promise).resolves.toBe('static-result');
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.run} accepts a partial options object and merges it
|
||||
* with defaults, still retrying when only some fields are overridden.
|
||||
*/
|
||||
const testExponentialBackoffRunWithPartialOptions = async (): Promise<void> => {
|
||||
// Same fail-then-succeed pattern; we only care that partial options still enable a retry.
|
||||
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
|
||||
.mockResolvedValueOnce('done');
|
||||
|
||||
// baseDelay/jitter of 0 skip real waiting; maxAttempts: 3 gives headroom for one retry.
|
||||
// onError is explicitly undefined to verify the default no-op handler is used.
|
||||
const result = await ExponentialBackoff.run(rejectThenResolveFn, undefined, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 3,
|
||||
});
|
||||
|
||||
expect(result).toBe('done');
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that calling {@link ExponentialBackoff.run} on a constructed instance applies
|
||||
* the instance's stored options when no per-run options are supplied.
|
||||
*/
|
||||
const testExponentialBackoffInstanceRunUsesDefaultOnError = async (): Promise<void> => {
|
||||
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
|
||||
.mockResolvedValueOnce('instance-result');
|
||||
|
||||
// Options live on the instance; run(fn) should read them instead of static defaults.
|
||||
const backoff = new ExponentialBackoff({ baseDelay: 0, jitter: 0, maxAttempts: 3 });
|
||||
|
||||
const result = await backoff.run(rejectThenResolveFn);
|
||||
|
||||
expect(result).toBe('instance-result');
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests the happy path: the wrapped function succeeds immediately and no retry machinery runs.
|
||||
*/
|
||||
const testExponentialBackoffSucceedsOnFirstAttempt = async (): Promise<void> => {
|
||||
// Always resolves — never enters the catch/retry branch.
|
||||
const resolveFn = vi.fn(async () => 'success');
|
||||
const onError = vi.fn();
|
||||
|
||||
const result = await ExponentialBackoff.run(resolveFn, onError, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
});
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(resolveFn).toHaveBeenCalledOnce();
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that retries continue across multiple failures until the function eventually resolves.
|
||||
*/
|
||||
const testExponentialBackoffRetriesUntilSuccess = async (): Promise<void> => {
|
||||
// Three invocations: two rejections then a success on the third call.
|
||||
const tripleRejectFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('attempt 1'))
|
||||
.mockRejectedValueOnce(new Error('attempt 2'))
|
||||
.mockResolvedValueOnce('success');
|
||||
|
||||
// maxAttempts: 5 is high enough that we stop because fn succeeded, not because we hit the cap.
|
||||
const result = await ExponentialBackoff.run(tripleRejectFn, () => {}, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 5,
|
||||
});
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(tripleRejectFn).toHaveBeenCalledTimes(3);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that the onError callback is invoked once for every failed attempt, including the last one
|
||||
* before an ExponentialBackoffMaxRetriesHitError is thrown to the caller.
|
||||
*/
|
||||
const testExponentialBackoffCallsOnErrorForEachFailure = async (): Promise<void> => {
|
||||
const error = new Error('temporary failure');
|
||||
|
||||
// Always rejects with the same error — we will exhaust all attempts.
|
||||
const rejectFn = vi.fn().mockRejectedValue(error);
|
||||
const onError = vi.fn();
|
||||
|
||||
// maxAttempts: 3 means three tries total, all of which will fail.
|
||||
await expect(ExponentialBackoff.run(rejectFn, onError, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 3,
|
||||
})).rejects.toThrow(AggregateError);
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(3);
|
||||
expect(onError).toHaveBeenCalledWith(error, expect.objectContaining({}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that when all attempts are exhausted the caller receives an ExponentialBackoffMaxRetriesHitError
|
||||
* with every task error preserved in order on the cause.
|
||||
*/
|
||||
const testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted = async (): Promise<void> => {
|
||||
const firstError = new Error('first');
|
||||
const lastError = new Error('last');
|
||||
|
||||
// Two distinct errors so we can prove both are collected, not just the last one.
|
||||
const doubleRejectFn = vi.fn().mockRejectedValueOnce(firstError)
|
||||
.mockRejectedValueOnce(lastError);
|
||||
|
||||
try {
|
||||
await ExponentialBackoff.run(doubleRejectFn, () => {}, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 2,
|
||||
});
|
||||
expect.fail('Expected AggregateError to be thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AggregateError);
|
||||
expect((error as AggregateError).errors).toEqual([ firstError, lastError ]);
|
||||
}
|
||||
|
||||
expect(doubleRejectFn).toHaveBeenCalledTimes(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that rejections which are not Error instances are coerced to Error before onError runs,
|
||||
* so callers always observe a consistent error type in the callback.
|
||||
*/
|
||||
const testExponentialBackoffWrapsNonErrorThrows = async (): Promise<void> => {
|
||||
// Reject with a plain string — not an Error subclass.
|
||||
const rejectedFn = vi.fn().mockRejectedValue('not-an-error');
|
||||
const onError = vi.fn();
|
||||
|
||||
// Single attempt — we fail fast and inspect what onError received.
|
||||
try {
|
||||
await ExponentialBackoff.run(rejectedFn, onError, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 1,
|
||||
});
|
||||
expect.fail('Expected AggregateError to be thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AggregateError);
|
||||
const [ wrappedError ] = (error as AggregateError).errors as Error[];
|
||||
expect(wrappedError).toBeInstanceOf(Error);
|
||||
expect(wrappedError?.message).toBe('not-an-error');
|
||||
}
|
||||
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(onError.mock.calls?.[0]?.[0]).toBeInstanceOf(Error);
|
||||
expect(onError.mock.calls?.[0]?.[0]?.message).toBe('not-an-error');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that when the task function succeeds and the abort signal is aborted, the result is returned
|
||||
* and the onError callback is not called.
|
||||
*/
|
||||
const testExponentialBackoffRunSuccessAndAbortSignal = async (): Promise<void> => {
|
||||
// Define the function which aborts the exponential backoff and succeeds
|
||||
const abortAndSucceedFn = vi.fn(({ abort }) => {
|
||||
abort(new Error('retry me'));
|
||||
|
||||
return Promise.resolve('success');
|
||||
});
|
||||
const onErrorFn = vi.fn();
|
||||
|
||||
// Run the exponential backoff with the function and the onError callback
|
||||
const result = await ExponentialBackoff.run(abortAndSucceedFn, onErrorFn, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
});
|
||||
|
||||
// Expect the result to be the success message
|
||||
expect(result).toBe('success');
|
||||
expect(abortAndSucceedFn).toHaveBeenCalledOnce();
|
||||
|
||||
// Expect the onError callback to not have been called
|
||||
expect(onErrorFn).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that when the abort signal is aborted with an error, an AggregateError is thrown
|
||||
* with the error as the message.
|
||||
*/
|
||||
const testExponentialBackoffRunWithAbortSignal = async (): Promise<void> => {
|
||||
// Define the function which aborts the exponential backoff and throws an error
|
||||
const abortAndThrowFn = vi.fn(({ abort }) => {
|
||||
abort(new Error('exponential backoff aborted message'));
|
||||
throw new Error('error message');
|
||||
});
|
||||
const onErrorFn = vi.fn();
|
||||
|
||||
// Define the expected error
|
||||
const expectedError = new Error('Exponential backoff aborted', { cause: new Error('exponential backoff aborted message') });
|
||||
|
||||
// Run the exponential backoff with the function and the onError callback and expect the error to be thrown
|
||||
await expect(ExponentialBackoff.run(abortAndThrowFn, onErrorFn, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
})).rejects.toThrow(expectedError);
|
||||
|
||||
// Expect the onError callback to have been called once with the error
|
||||
expect(onErrorFn).toHaveBeenCalledOnce();
|
||||
expect(onErrorFn.mock.calls?.[0]?.[0]).toBeInstanceOf(Error);
|
||||
expect(onErrorFn.mock.calls?.[0]?.[0]?.message).toBe('error message');
|
||||
|
||||
// Expect the function to have been called once and not to have resolved
|
||||
expect(abortAndThrowFn).toHaveBeenCalledOnce();
|
||||
expect(abortAndThrowFn).not.toHaveResolved();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that when the abort signal is aborted with a string, an AggregateError is thrown
|
||||
* with the string as the message.
|
||||
*/
|
||||
const testExponentialBackoffRunAbortedStringCreatesError = async (): Promise<void> => {
|
||||
// Define the function which aborts the exponential backoff and throws an error
|
||||
const abortAndThrowStringFn = vi.fn(({ abort }) => {
|
||||
abort('exponential backoff aborted message');
|
||||
|
||||
// eslint-disable-next-line
|
||||
throw 'error message';
|
||||
});
|
||||
const onErrorFn = vi.fn();
|
||||
|
||||
// Define the expected error, Note that we "abort" with just a string, not an error. They are treated equivalently.
|
||||
const expectedError = new Error('Exponential backoff aborted', { cause: new Error('exponential backoff aborted message') });
|
||||
|
||||
// Run the exponential backoff with the function and the onError callback and expect the error to be thrown
|
||||
await expect(ExponentialBackoff.run(abortAndThrowStringFn, onErrorFn, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
})).rejects.toThrow(expectedError);
|
||||
|
||||
// Expect the onError callback to have been called once with the error
|
||||
expect(onErrorFn).toHaveBeenCalledOnce();
|
||||
expect(onErrorFn.mock.calls?.[0]?.[0]).toBeInstanceOf(Error);
|
||||
expect(onErrorFn.mock.calls?.[0]?.[0]?.message).toBe('error message');
|
||||
|
||||
// Expect the function to have been called once and not to have resolved
|
||||
expect(abortAndThrowStringFn).toHaveBeenCalledOnce();
|
||||
expect(abortAndThrowStringFn).not.toHaveResolved();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests the {@link ExponentialBackoff.from} factory and subsequent instance {@link ExponentialBackoff.run}
|
||||
* as an alternative to the static helper.
|
||||
*/
|
||||
const testExponentialBackoffFromAndInstanceRun = async (): Promise<void> => {
|
||||
const successfullyResolve = vi.fn(async () => 42);
|
||||
|
||||
// from() is a convenience constructor; run() on the result should behave like the static path.
|
||||
const backoff = ExponentialBackoff.from({
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
});
|
||||
|
||||
const result = await backoff.run(successfullyResolve);
|
||||
|
||||
expect(result).toBe(42);
|
||||
expect(successfullyResolve).toHaveBeenCalledOnce();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that maxAttempts: 0 disables the attempt cap so retries continue until the function succeeds.
|
||||
*/
|
||||
const testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero = async (): Promise<void> => {
|
||||
// Four invocations: three failures then success — would exceed a cap of 3 if one existed.
|
||||
const tripleRejectThenResolveFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('attempt 1'))
|
||||
.mockRejectedValueOnce(new Error('attempt 2'))
|
||||
.mockRejectedValueOnce(new Error('attempt 3'))
|
||||
.mockResolvedValueOnce('eventually');
|
||||
|
||||
const result = await ExponentialBackoff.run(tripleRejectThenResolveFn, () => {}, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 0,
|
||||
});
|
||||
|
||||
expect(result).toBe('eventually');
|
||||
expect(tripleRejectThenResolveFn).toHaveBeenCalledTimes(4);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests the delay formula: each retry waits baseDelay * growthRate^attemptIndex milliseconds
|
||||
* (with jitter disabled so the math is exact).
|
||||
*/
|
||||
const testExponentialBackoffIncreasesDelayExponentially = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
|
||||
try {
|
||||
const doubleRejectThenResolveFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('attempt 1'))
|
||||
.mockRejectedValueOnce(new Error('attempt 2'))
|
||||
.mockResolvedValueOnce('success');
|
||||
|
||||
const promise = ExponentialBackoff.run(doubleRejectThenResolveFn, () => {}, {
|
||||
baseDelay: 100,
|
||||
growthRate: 2,
|
||||
jitter: 0,
|
||||
maxDelay: 10_000,
|
||||
maxAttempts: 5,
|
||||
});
|
||||
|
||||
// Attempt 0 fires synchronously on the first microtask tick.
|
||||
await Promise.resolve();
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// After attempt 0 fails, delay = 100 * 2^0 = 100ms before attempt 1.
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
// After attempt 1 fails, delay = 100 * 2^1 = 200ms before attempt 2.
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(3);
|
||||
|
||||
await expect(promise).resolves.toBe('success');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that computed delay never exceeds maxDelay even when exponential growth would go higher.
|
||||
*/
|
||||
const testExponentialBackoffCapsDelayAtMaxDelay = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
|
||||
try {
|
||||
const doubleRejectThenResolveFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('attempt 1'))
|
||||
.mockRejectedValueOnce(new Error('attempt 2'))
|
||||
.mockResolvedValueOnce('success');
|
||||
|
||||
const promise = ExponentialBackoff.run(doubleRejectThenResolveFn, () => {}, {
|
||||
baseDelay: 1_000,
|
||||
growthRate: 4,
|
||||
jitter: 0,
|
||||
maxDelay: 2_000,
|
||||
maxAttempts: 5,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// attempt 0: 1000 * 4^0 = 1000ms, below the 2000ms cap.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
// attempt 1: uncapped would be 4000ms but maxDelay clamps to 2000ms.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(3);
|
||||
|
||||
await expect(promise).resolves.toBe('success');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that jitter subtracts up to jitter * cappedDelay from the capped delay based on Math.random.
|
||||
*/
|
||||
const testExponentialBackoffAppliesJitter = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
// random = 1 → full 10% reduction: 1000 - (1 * 0.1 * 1000) = 900ms.
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
|
||||
try {
|
||||
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('attempt 1'))
|
||||
.mockResolvedValueOnce('success');
|
||||
|
||||
const promise = ExponentialBackoff.run(rejectThenResolveFn, () => {}, {
|
||||
baseDelay: 1_000,
|
||||
growthRate: 1,
|
||||
jitter: 0.1,
|
||||
maxDelay: 10_000,
|
||||
maxAttempts: 3,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advancing 899ms is one ms short of the jittered delay; 900ms triggers the retry.
|
||||
await vi.advanceTimersByTimeAsync(899);
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
await expect(promise).resolves.toBe('success');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.validateOptions} accepts valid options, including boundary values of 0 and 1.
|
||||
*/
|
||||
const testExponentialBackoffValidateOptionsAcceptsValidOptions = (): void => {
|
||||
const validCases = [
|
||||
validExponentialBackoffOptions,
|
||||
{
|
||||
...validExponentialBackoffOptions,
|
||||
maxDelay: 0,
|
||||
maxAttempts: 0,
|
||||
baseDelay: 0,
|
||||
growthRate: 0,
|
||||
jitter: 0,
|
||||
},
|
||||
{
|
||||
...validExponentialBackoffOptions,
|
||||
jitter: 1,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const options of validCases) {
|
||||
expect(() => ExponentialBackoff.validateOptions(options)).not.toThrow();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.validateOptions} rejects negative numeric options.
|
||||
*/
|
||||
const testExponentialBackoffValidateOptionsRejectsNegativeValues = (): void => {
|
||||
// Define our test cases with each value being less than 0
|
||||
const negativeCases = [
|
||||
{ field: 'maxDelay', value: -1 },
|
||||
{ field: 'maxAttempts', value: -1 },
|
||||
{ field: 'baseDelay', value: -1 },
|
||||
{ field: 'growthRate', value: -1 },
|
||||
] as const;
|
||||
|
||||
// Iterate through the test cases and expect an error to be thrown
|
||||
for (const { field, value } of negativeCases) {
|
||||
expect(() =>
|
||||
ExponentialBackoff.validateOptions({
|
||||
...validExponentialBackoffOptions,
|
||||
[field]: value,
|
||||
})).toThrow(`Invalid option: ${field} is less than 0`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.validateOptions} rejects jitter below 0 or above 1.
|
||||
*/
|
||||
const testExponentialBackoffValidateOptionsRejectsInvalidJitter = (): void => {
|
||||
// Define our test cases with each value being less than 0 or greater than 1
|
||||
const invalidJitterCases: Array<{ value: number }> = [{ value: -0.1 }, { value: 1.1 }];
|
||||
|
||||
// Iterate through the test cases and expect an error to be thrown
|
||||
for (const { value } of invalidJitterCases) {
|
||||
expect(() =>
|
||||
ExponentialBackoff.validateOptions({
|
||||
...validExponentialBackoffOptions,
|
||||
jitter: value,
|
||||
})).toThrow('Invalid option: jitter is not between 0 and 1');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.validateOptions} rejects non-finite values such as Infinity.
|
||||
*/
|
||||
const testExponentialBackoffValidateOptionsRejectsNonFiniteValues = (): void => {
|
||||
// Define our test cases with each value being Infinity
|
||||
const nonFiniteCases = [
|
||||
{ field: 'maxDelay', value: Infinity },
|
||||
{ field: 'maxAttempts', value: Infinity },
|
||||
{ field: 'baseDelay', value: Infinity },
|
||||
{ field: 'growthRate', value: Infinity },
|
||||
{ field: 'jitter', value: Infinity },
|
||||
] as const;
|
||||
|
||||
// Iterate through the test cases and expect an error to be thrown
|
||||
for (const { field, value } of nonFiniteCases) {
|
||||
expect(() =>
|
||||
ExponentialBackoff.validateOptions({
|
||||
...validExponentialBackoffOptions,
|
||||
[field]: value,
|
||||
})).toThrow(`Invalid option: ${field} is not finite`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.validateOptions} rejects NaN, which is also non-finite.
|
||||
*/
|
||||
const testExponentialBackoffValidateOptionsRejectsNaN = (): void => {
|
||||
// Define our test cases with each value being NaN
|
||||
const nanCases = [
|
||||
{ field: 'maxDelay', value: Number.NaN },
|
||||
{ field: 'maxAttempts', value: Number.NaN },
|
||||
{ field: 'baseDelay', value: Number.NaN },
|
||||
{ field: 'growthRate', value: Number.NaN },
|
||||
{ field: 'jitter', value: Number.NaN },
|
||||
] as const;
|
||||
|
||||
// Iterate through the test cases and expect an error to be thrown
|
||||
for (const { field, value } of nanCases) {
|
||||
expect(() =>
|
||||
ExponentialBackoff.validateOptions({
|
||||
...validExponentialBackoffOptions,
|
||||
[field]: value,
|
||||
})).toThrow(`Invalid option: ${field} is not finite`);
|
||||
}
|
||||
};
|
||||
|
||||
const runTests = async (): Promise<void> => {
|
||||
test('ExponentialBackoff.run: delegates to a new instance using default options', testExponentialBackoffRunUsesDefaultOptions);
|
||||
test('ExponentialBackoff.run: retries and succeeds with partial options', testExponentialBackoffRunWithPartialOptions);
|
||||
test('ExponentialBackoff.run: uses the instance default onError when omitted', testExponentialBackoffInstanceRunUsesDefaultOnError);
|
||||
test('ExponentialBackoff: returns the result on first success', testExponentialBackoffSucceedsOnFirstAttempt);
|
||||
test('ExponentialBackoff: retries until the function succeeds', testExponentialBackoffRetriesUntilSuccess);
|
||||
test('ExponentialBackoff: calls onError for each failed attempt', testExponentialBackoffCallsOnErrorForEachFailure);
|
||||
test(
|
||||
'ExponentialBackoff: throws ExponentialBackoffMaxRetriesHitError when max attempts are exhausted',
|
||||
testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted,
|
||||
);
|
||||
test('ExponentialBackoff: wraps non-Error throws before calling onError', testExponentialBackoffWrapsNonErrorThrows);
|
||||
test('ExponentialBackoff: succeeds and aborts with abort signal', testExponentialBackoffRunSuccessAndAbortSignal);
|
||||
test('ExponentialBackoff: aborts with abort signal', testExponentialBackoffRunWithAbortSignal);
|
||||
test('ExponentialBackoff: aborts with aborted string creates error', testExponentialBackoffRunAbortedStringCreatesError);
|
||||
test('ExponentialBackoff: works via from and instance run', testExponentialBackoffFromAndInstanceRun);
|
||||
test('ExponentialBackoff: retries indefinitely when maxAttempts is 0', testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero);
|
||||
test('ExponentialBackoff: increases delay exponentially between attempts', testExponentialBackoffIncreasesDelayExponentially);
|
||||
test('ExponentialBackoff: caps delay at maxDelay', testExponentialBackoffCapsDelayAtMaxDelay);
|
||||
test('ExponentialBackoff: subtracts jitter from the capped delay', testExponentialBackoffAppliesJitter);
|
||||
test('ExponentialBackoff.validateOptions: accepts valid options', testExponentialBackoffValidateOptionsAcceptsValidOptions);
|
||||
test('ExponentialBackoff.validateOptions: rejects negative values', testExponentialBackoffValidateOptionsRejectsNegativeValues);
|
||||
test('ExponentialBackoff.validateOptions: rejects invalid jitter', testExponentialBackoffValidateOptionsRejectsInvalidJitter);
|
||||
test('ExponentialBackoff.validateOptions: rejects Infinity', testExponentialBackoffValidateOptionsRejectsNonFiniteValues);
|
||||
test('ExponentialBackoff.validateOptions: rejects NaN', testExponentialBackoffValidateOptionsRejectsNaN);
|
||||
};
|
||||
|
||||
await runTests();
|
||||
70
tests/utils/misc.test.ts
Normal file
70
tests/utils/misc.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { tryAsync } from '../../src/utils/misc.js';
|
||||
|
||||
/** Spy used to confirm the wrapped async function ran successfully. */
|
||||
const successFlagFn = vi.fn();
|
||||
|
||||
/** Spy used to confirm the error callback was invoked on failure. */
|
||||
const errorFlagFn = vi.fn();
|
||||
|
||||
/**
|
||||
* Tests that tryAsync invokes the function and skips the error callback on success.
|
||||
*/
|
||||
const testTryAsyncCallsFunctionOnSuccess = async (): Promise<void> => {
|
||||
// Reset spies so prior test runs do not affect call counts.
|
||||
vi.clearAllMocks();
|
||||
|
||||
const successFn = async (): Promise<void> => {
|
||||
successFlagFn();
|
||||
};
|
||||
|
||||
await tryAsync(successFn);
|
||||
|
||||
// The wrapped function should run and no error handler should be called.
|
||||
expect(successFlagFn).toHaveBeenCalledOnce();
|
||||
expect(errorFlagFn).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that tryAsync invokes the error callback when the function throws.
|
||||
*/
|
||||
const testTryAsyncCallsErrorCallbackOnFailure = async (): Promise<void> => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const errorFn = async (): Promise<void> => {
|
||||
throw new Error('test');
|
||||
};
|
||||
|
||||
await tryAsync(errorFn, errorFlagFn);
|
||||
|
||||
// The success path should not run; the error callback should receive the failure.
|
||||
expect(successFlagFn).not.toHaveBeenCalled();
|
||||
expect(errorFlagFn).toHaveBeenCalledOnce();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that tryAsync wraps non-Error throws in Error instances before calling the error callback.
|
||||
*/
|
||||
const testTryAsyncConvertsNonErrorThrows = async (): Promise<void> => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const errorFn = async (): Promise<void> => {
|
||||
/* eslint-disable-next-line */
|
||||
throw 'test';
|
||||
};
|
||||
|
||||
await tryAsync(errorFn, errorFlagFn);
|
||||
|
||||
// Non-Error throws must be normalized to Error before onError is called.
|
||||
expect(successFlagFn).not.toHaveBeenCalled();
|
||||
expect(errorFlagFn).toHaveBeenCalledOnce();
|
||||
expect(errorFlagFn).toHaveBeenCalledWith(new Error('test'));
|
||||
};
|
||||
|
||||
const runTests = async (): Promise<void> => {
|
||||
test('tryAsync: calls the function and skips the error callback on success', testTryAsyncCallsFunctionOnSuccess);
|
||||
test('tryAsync: calls the error callback when the function fails', testTryAsyncCallsErrorCallbackOnFailure);
|
||||
test('tryAsync: converts non-Error throws to Error instances', testTryAsyncConvertsNonErrorThrows);
|
||||
};
|
||||
|
||||
await runTests();
|
||||
Reference in New Issue
Block a user