import { expect, test, vi } from 'vitest'; import { AsyncPushIterator } from '../../source/sse-session/async-push-iterator.ts'; /** * Collects every value from the iterator into an array. * * @param iterator - Iterator under test. */ const collectAll = async (iterator: AsyncPushIterator): Promise => { 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 => { const iterator = new AsyncPushIterator(); const result = (): Promise => 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 => { const iterator = new AsyncPushIterator(); 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 => { const iterator = new AsyncPushIterator(); const result = async (): Promise => collectAll(iterator); iterator.close(); await expect(result()).resolves.toEqual([]); }; /** * Tests that values pushed after {@link AsyncPushIterator.close} are ignored. */ const testPushComposedIgnoresValuesAfterClose = async (): Promise => { const iterator = new AsyncPushIterator(); const result = async (): Promise => 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 => { const iterator = new AsyncPushIterator(); const failureFlag = vi.fn(); const successfulIterator = (): Promise => collectAll(iterator); const failedIterator = async (): Promise => { try { /* eslint-disable-next-line */ for await (const _value of iterator) { } } catch (error) { failureFlag(); } }; const promises = [ successfulIterator(), failedIterator() ]; iterator.close(); await Promise.all(promises); expect(failureFlag).toHaveBeenCalledOnce(); }; /** * Tests that closing before iteration starts lets the loop finish immediately. */ const testPushComposedResolvesWhenClosedBeforeLoop = async (): Promise => { const iterator = new AsyncPushIterator(); 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 => { const iterator = new AsyncPushIterator(); 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 ]); }; /** * Tests that the iterator rejects after {@link AsyncPushIterator.error} is called. */ const testPushIteratorRejectsAfterError = async (): Promise => { const iterator = new AsyncPushIterator(); iterator.error(new Error('Stream has been closed for a test')); await expect(collectAll(iterator)).rejects.toThrow('Stream has been closed for a test'); }; /** * Tests that the iterator closes the stream when error() is called. */ const testPushIteratorClosesWhenErrorIsCalled = async (): Promise => { const iterator = new AsyncPushIterator(); iterator.error(new Error('Stream has been closed for a test')); expect(iterator.closed).toBe(true); }; /** * Tests that subsequent calls to error() are ignored. */ const testPushIteratorIgnoresSubsequentErrorCalls = async (): Promise => { const iterator = new AsyncPushIterator(); iterator.error(new Error('Stream has been closed for a test')); expect(iterator.closed).toBe(true); expect(iterator.error(new Error('Second error'))).toBe(undefined); }; /** * Tests that a consumer can check if the iterator is closed. */ const testPushIteratorCanCheckIfClosed = async (): Promise => { const iterator = new AsyncPushIterator(); expect(iterator.closed).toBe(false); iterator.close(); expect(iterator.closed).toBe(true); }; const runTests = async (): Promise => { test('AsyncPushIterator: pushes and consumes values', testPushComposedPushAndConsume); test('AsyncPushIterator: buffers values pushed before the for-await loop starts', testPushComposedBuffersValuesPushedBeforeLoopStarts); test('AsyncPushIterator: resolves with no values when nothing was pushed', testPushComposedResolvesWithNoValues); test('AsyncPushIterator: ignores values pushed after close', testPushComposedIgnoresValuesAfterClose); test('AsyncPushIterator: rejects multiple consumers', testPushComposedRejectsMultipleConsumers); test('AsyncPushIterator: resolves immediately when closed before the loop starts', testPushComposedResolvesWhenClosedBeforeLoop); test('AsyncPushIterator: keeps the stream open after an early break', testPushComposedAllowsPushingAfterEarlyBreak); test('AsyncPushIterator: rejects after error', testPushIteratorRejectsAfterError); test('AsyncPushIterator: closes the stream when error() is called', testPushIteratorClosesWhenErrorIsCalled); test('AsyncPushIterator: ignores subsequent error() calls', testPushIteratorIgnoresSubsequentErrorCalls); test('AsyncPushIterator: can check if closed', testPushIteratorCanCheckIfClosed); }; await runTests();