prom-utils
Version:
Promise utilities: rate limiting, queueing/batching, defer, etc.
928 lines (867 loc) • 28.6 kB
text/typescript
import { setTimeout } from 'node:timers/promises'
import { describe, expect, test } from 'vitest'
import {
batchQueue,
batchQueueParallel,
defer,
multiplex,
OptionsError,
pacemaker,
pausable,
raceTimeout,
rateLimit,
throughputLimiter,
TIMEOUT,
TimeoutError,
waitUntil,
} from './fns'
describe('rateLimit', () => {
test('should add up to limit - 1 promises without delay', async () => {
expect.assertions(1)
const limiter = rateLimit(3)
const startTime = new Date().getTime()
await limiter.add(setTimeout(1000))
await limiter.add(setTimeout(1000))
const endTime = new Date().getTime()
const elapsed = endTime - startTime
expect(elapsed).toBeLessThan(100)
})
test('should wait for one promise to resolve when limit is reached', async () => {
expect.assertions(1)
const limiter = rateLimit(3)
const startTime = new Date().getTime()
await limiter.add(setTimeout(1000))
await limiter.add(setTimeout(1000))
await limiter.add(setTimeout(1000))
await limiter.finish()
const endTime = new Date().getTime()
const elapsed = endTime - startTime
expect(elapsed).toBeGreaterThan(900)
})
test('should finish awaiting remaining promises', async () => {
expect.assertions(4)
// Pass type variable
const limiter = rateLimit<string>(3)
const done: string[] = []
const createProm = () =>
setTimeout(1000, 'done').then((p) => {
done.push(p)
return p
})
const startTime = new Date().getTime()
for (let i = 0; i < 5; i++) {
await limiter.add(createProm())
}
await limiter.finish()
const endTime = new Date().getTime()
const elapsed = endTime - startTime
expect(elapsed).toBeGreaterThanOrEqual(2000)
expect(elapsed).toBeLessThan(3000)
expect(done.length).toBe(5)
expect(limiter.length).toBe(0)
})
// This test is here to ensure that the rejected promise doesn't
// throw an UnhandledPromiseRejection exception.
test('rejections should not bubble up .add', async () => {
expect.assertions(1)
const limiter = rateLimit(3)
// Shorter timeout to make sure that this rejects before the other promises resolve
await limiter.add(
setTimeout(5).then(() => {
throw new Error('rejectedPromise')
})
)
await limiter.add(setTimeout(10))
await limiter.finish()
await setTimeout(20)
// Check that the rejected promise was removed from the set.
expect(limiter.length).toBe(0)
})
test('rejections should not bubble up .finish', () => {
expect.assertions(1)
expect(async () => {
const limiter = rateLimit(3)
await limiter.add(setTimeout(10))
await limiter.add(
setTimeout(10).then(() => {
throw new Error('rejectedPromise')
})
)
// Call finish before reaching the limit of 3
await limiter.finish()
}).not.toThrow('rejectedPromise')
})
test('should allow at most maxItemsPerPeriod if option is set', async () => {
expect.assertions(1)
// 1 unit per sec
const limiter = rateLimit(3, { maxItemsPerPeriod: 1 })
const startTime = new Date().getTime()
await limiter.add(setTimeout(10))
await limiter.add(setTimeout(10))
await limiter.add(setTimeout(10))
await limiter.finish()
const endTime = new Date().getTime()
const elapsed = endTime - startTime
expect(elapsed).toBeGreaterThanOrEqual(3000)
})
test('should handle infinite concurrency while limiting via maxItemsPerPeriod', async () => {
expect.assertions(2)
// Simulate an API that allows 2 requests per 100 ms
const limiter = rateLimit(Infinity, {
maxItemsPerPeriod: 2,
period: 100,
})
const startTime = new Date().getTime()
await limiter.add(setTimeout(10))
await limiter.add(setTimeout(10))
await limiter.add(setTimeout(10))
await limiter.finish()
const endTime = new Date().getTime()
const elapsed = endTime - startTime
expect(elapsed).toBeGreaterThan(100)
expect(elapsed).toBeLessThan(150)
})
test('should not wait to end of period when bypass is set to true', async () => {
expect.assertions(1)
// 3 units per 10 ms
const limiter = rateLimit(3, { maxItemsPerPeriod: 3, period: 1000 })
const startTime = new Date().getTime()
await limiter.add(setTimeout(100))
await limiter.add(setTimeout(100))
await limiter.add(setTimeout(100), { bypass: true })
await limiter.finish()
const endTime = new Date().getTime()
const elapsed = endTime - startTime
expect(elapsed).toBeLessThan(150)
})
test('should handle multiple rate limiters', async () => {
expect.assertions(1)
// 2 requests per 100ms AND 5 requests per 500ms
const limiter = rateLimit(
Infinity,
{ maxItemsPerPeriod: 2, period: 100 },
{ maxItemsPerPeriod: 5, period: 500 }
)
const startTime = new Date().getTime()
// Add 6 requests - should be limited by the 5 per 500ms limiter
for (let i = 0; i < 6; i++) {
await limiter.add(setTimeout(10))
}
await limiter.finish()
const endTime = new Date().getTime()
const elapsed = endTime - startTime
// Should take at least 500ms due to the second limiter
expect(elapsed).toBeGreaterThanOrEqual(500)
})
test('should return stats for all rate limiters', async () => {
expect.assertions(2)
const limiter = rateLimit(
Infinity,
{ maxItemsPerPeriod: 10, period: 1000 },
{ maxItemsPerPeriod: 100, period: 60000 }
)
await limiter.add(setTimeout(10))
await limiter.add(setTimeout(10))
await limiter.add(setTimeout(10))
await limiter.finish()
const stats = limiter.getStats()
// Should return an array with rates for each limiter
expect(stats.itemsPerPeriod).toBeInstanceOf(Array)
expect(stats.itemsPerPeriod.length).toBe(2)
})
test('should return empty array for getStats when no rate limiters', async () => {
expect.assertions(1)
const limiter = rateLimit(3)
await limiter.add(setTimeout(10))
await limiter.finish()
const stats = limiter.getStats()
expect(stats.itemsPerPeriod).toEqual([])
})
test('should enforce the most restrictive rate limiter', async () => {
expect.assertions(1)
// First limiter: 10 per 100ms (very permissive)
// Second limiter: 2 per 1000ms (very restrictive)
const limiter = rateLimit(
Infinity,
{ maxItemsPerPeriod: 10, period: 100 },
{ maxItemsPerPeriod: 2, period: 1000 }
)
const startTime = new Date().getTime()
// Add 3 requests - should be limited by the 2 per 1000ms limiter
await limiter.add(setTimeout(10))
await limiter.add(setTimeout(10))
await limiter.add(setTimeout(10))
await limiter.finish()
const endTime = new Date().getTime()
const elapsed = endTime - startTime
// Should take at least 1000ms due to the restrictive limiter
expect(elapsed).toBeGreaterThanOrEqual(1000)
})
})
describe('batchQueue', () => {
test('should batch items up to batchSize', async () => {
expect.assertions(4)
const calls: any[] = []
const fn = async (records: string[]) => {
calls.push(records)
return records.length
}
const batchSize = 2
const queue = batchQueue(fn, { batchSize })
const records = ['Joe', 'Frank', 'Bob']
for (const record of records) {
await queue.enqueue(record)
}
await queue.flush()
expect(calls).toEqual([['Joe', 'Frank'], ['Bob']])
expect(queue.lastResult).toBe(1)
expect(queue.lastFlush).toEqual({ batchSize })
expect(queue.length).toBe(0)
})
test('should flush queue if timeout is reached before batchSize', async () => {
expect.assertions(2)
const calls: any[] = []
const fn = async (records: string[]) => {
calls.push(records)
}
const batchSize = 2
const timeout = 50
const queue = batchQueue(fn, { batchSize, timeout })
const records = ['Joe', 'Frank', 'Bob']
for (const record of records) {
await queue.enqueue(record)
await setTimeout(75)
}
await queue.flush()
expect(calls).toEqual([['Joe'], ['Frank'], ['Bob']])
expect(queue.lastFlush).toEqual({ timeout })
})
test('should reset timer if batchSize is reached before timeout', async () => {
expect.assertions(1)
const calls: any[] = []
const fn = async (records: string[]) => {
calls.push(records)
}
const batchSize = 2
const timeout = 100
const queue = batchQueue(fn, { batchSize, timeout })
const records = ['Joe', 'Frank', 'Bob']
for (const record of records) {
await queue.enqueue(record)
await setTimeout(20)
}
await queue.flush()
expect(calls).toEqual([['Joe', 'Frank'], ['Bob']])
})
test('fn should only be called once if timeout flush was triggered before queue.flush()', async () => {
expect.assertions(1)
const calls: any[] = []
const fn = async (records: string[]) => {
calls.push(records)
await setTimeout(100)
}
const batchSize = 5
const timeout = 20
const queue = batchQueue(fn, { batchSize, timeout })
const records = ['Joe', 'Frank', 'Bob']
for (const record of records) {
await queue.enqueue(record)
}
await setTimeout(50)
await queue.flush()
expect(calls).toEqual([['Joe', 'Frank', 'Bob']])
})
test('should flush queue if batchBytes is reached before batchSize', async () => {
expect.assertions(2)
const calls: any[] = []
const fn = async (records: string[]) => {
calls.push(records)
}
const batchSize = 3
const batchBytes = 8
const queue = batchQueue(fn, { batchSize, batchBytes })
const records = ['Joe', 'Frank', 'Bob', 'Tim']
for (const record of records) {
await queue.enqueue(record)
}
await queue.flush()
expect(calls).toEqual([
['Joe', 'Frank'],
['Bob', 'Tim'],
])
expect(queue.lastFlush).toEqual({ batchBytes })
})
test('should flush queue if batchSize is reached before batchBytes', async () => {
expect.assertions(1)
const calls: any[] = []
const fn = async (records: string[]) => {
calls.push(records)
}
const batchSize = 3
const batchBytes = 100
const queue = batchQueue(fn, { batchSize, batchBytes })
const records = ['Joe', 'Frank', 'Bob', 'Tim']
for (const record of records) {
await queue.enqueue(record)
}
await queue.flush()
expect(calls).toEqual([['Joe', 'Frank', 'Bob'], ['Tim']])
})
test('flush should govern throughput - maxItemsPerSec', async () => {
expect.assertions(1)
const calls: any[] = []
const fn = async (records: string[]) => {
calls.push(records)
await setTimeout(100)
}
const batchSize = 3
const maxItemsPerSec = 5
const queue = batchQueue(fn, { batchSize, maxItemsPerSec })
const records = ['Joe', 'Frank', 'Bob', 'Tim']
const startTime = new Date().getTime()
for (const record of records) {
await queue.enqueue(record)
}
await queue.flush()
const endTime = new Date().getTime()
const elapsed = endTime - startTime
// Total time would be around 200 ms without throughput governing
expect(elapsed).toBeGreaterThan(500)
})
test('flush should govern throughput - maxBytesPerSec', async () => {
expect.assertions(1)
const calls: any[] = []
const fn = async (records: string[]) => {
calls.push(records)
await setTimeout(100)
}
const batchSize = 3
const maxBytesPerSec = 50
const queue = batchQueue(fn, { batchSize, maxBytesPerSec })
const records = ['Joe', 'Frank', 'Bob', 'Tim']
const startTime = new Date().getTime()
for (const record of records) {
await queue.enqueue(record)
}
await queue.flush()
const endTime = new Date().getTime()
const elapsed = endTime - startTime
// Total time would be around 200 ms without throughput governing
expect(elapsed).toBeGreaterThan(500)
})
test('flush should govern throughput - maxItemsPerSec & maxBtyesPerSec', async () => {
expect.assertions(3)
const calls: any[] = []
const fn = async (records: string[]) => {
calls.push(records)
await setTimeout(100)
}
const batchSize = 3
const maxItemsPerSec = 5
const maxBytesPerSec = 50
const queue = batchQueue(fn, { batchSize, maxItemsPerSec, maxBytesPerSec })
const records = ['Joe', 'Frank', 'Bob', 'Tim']
const startTime = new Date().getTime()
for (const record of records) {
await queue.enqueue(record)
}
await queue.flush()
const endTime = new Date().getTime()
const elapsed = endTime - startTime
// Total time would be around 200 ms without throughput governing
expect(elapsed).toBeGreaterThan(500)
// Stats
const stats = queue.getStats()
expect(stats.bytesPerSec).toBeLessThan(100)
expect(stats.itemsPerSec).toBeLessThan(10)
})
})
describe('batchQueueParallel', () => {
test('should batch items up to batchSize', () => {
expect.assertions(3)
const calls: any[] = []
const fn = (records: string[]) => {
calls.push([...records]) // Copy array since it gets reset
}
const batchSize = 2
const queue = batchQueueParallel(fn, { batchSize })
const records = ['Joe', 'Frank', 'Bob']
for (const record of records) {
queue.enqueue(record)
}
queue.flush()
expect(calls).toEqual([['Joe', 'Frank'], ['Bob']])
expect(queue.length).toBe(0)
expect(calls.length).toBe(2)
})
test('should flush automatically when batchSize is reached', () => {
expect.assertions(3)
const calls: any[] = []
const fn = (records: string[]) => {
calls.push([...records])
}
const batchSize = 3
const queue = batchQueueParallel(fn, { batchSize })
const records = ['Joe', 'Frank', 'Bob', 'Tim', 'Alice']
for (const record of records) {
queue.enqueue(record)
}
// Should have auto-flushed once when batchSize was reached
expect(calls).toEqual([['Joe', 'Frank', 'Bob']])
expect(queue.length).toBe(2) // Tim and Alice still in queue
expect(queue.lastFlush).toEqual({ batchSize })
})
test('should handle batchBytes option', () => {
expect.assertions(4)
const calls: any[] = []
const fn = (records: string[]) => {
calls.push([...records])
}
// Each string is roughly 3-5 bytes, so batchBytes of 10 should trigger with 3-4 items
const batchBytes = 10
const queue = batchQueueParallel(fn, { batchBytes })
const records = ['Joe', 'Frank', 'Bob', 'Tim']
for (const record of records) {
queue.enqueue(record)
}
// Should have auto-flushed when batchBytes was reached
expect(calls.length).toBeGreaterThan(0)
expect(calls[0].length).toBeGreaterThan(0)
expect(queue.length).toBeGreaterThanOrEqual(0)
expect(queue.lastFlush).toEqual({ batchBytes })
})
test('should flush manually with remaining items', () => {
expect.assertions(3)
const calls: any[] = []
const fn = (records: string[]) => {
calls.push([...records])
}
const batchSize = 3
const queue = batchQueueParallel(fn, { batchSize })
const records = ['Joe', 'Frank']
for (const record of records) {
queue.enqueue(record)
}
expect(queue.length).toBe(2)
queue.flush()
expect(calls).toEqual([['Joe', 'Frank']])
expect(queue.length).toBe(0)
})
test('calling flush on an empty queue should not call fn', () => {
expect.assertions(2)
const calls: any[] = []
const fn = (records: string[]) => {
calls.push([...records])
}
const queue = batchQueueParallel(fn)
queue.flush()
expect(calls.length).toBe(0)
expect(queue.length).toBe(0)
})
test('should be safe for concurrent access', async () => {
expect.assertions(4)
const calls: any[] = []
const fn = async (records: string[]) => {
// Simulate async processing
await setTimeout(10)
calls.push([...records])
console.log('fn finished: %o', records)
}
const batchSize = 2
const queue = batchQueueParallel(fn, { batchSize })
// Simulate concurrent enqueuing with async delays
queue.enqueue('A')
await setTimeout(5)
queue.enqueue('B') // Should trigger flush
queue.enqueue('C')
await setTimeout(5)
queue.enqueue('D') // Should trigger another flush
queue.enqueue('E')
queue.flush()
// Wait for async fn calls to complete
await Promise.all(queue.results)
expect(calls.length).toBe(3)
expect(calls[0]).toEqual(['A', 'B'])
expect(calls[1]).toEqual(['C', 'D'])
expect(calls[2]).toEqual(['E'])
})
})
describe('pausable', () => {
test('should pause and resume', async () => {
expect.assertions(2)
const shouldProcess = pausable()
const processed: string[] = []
const processRecord = async (record: string) => {
processed.push(record)
await setTimeout(50)
}
const records = ['Joe', 'Frank', 'Bob']
global.setTimeout(shouldProcess.pause, 100)
global.setTimeout(shouldProcess.resume, 150)
const startTime = new Date().getTime()
for (const record of records) {
await shouldProcess.maybeBlock()
await processRecord(record)
}
const endTime = new Date().getTime()
const elapsed = endTime - startTime
expect(elapsed).toBeGreaterThanOrEqual(200)
expect(records).toEqual(['Joe', 'Frank', 'Bob'])
})
test('should pause and resume after timeout', async () => {
expect.assertions(2)
const shouldProcess = pausable(50)
const processed: string[] = []
const processRecord = async (record: string) => {
processed.push(record)
await setTimeout(50)
}
const records = ['Joe', 'Frank', 'Bob']
global.setTimeout(shouldProcess.pause, 100)
const startTime = new Date().getTime()
for (const record of records) {
await shouldProcess.maybeBlock()
await processRecord(record)
}
const endTime = new Date().getTime()
const elapsed = endTime - startTime
expect(elapsed).toBeGreaterThanOrEqual(200)
expect(records).toEqual(['Joe', 'Frank', 'Bob'])
})
test('isPaused works', () => {
const shouldProcess = pausable()
expect(shouldProcess.isPaused).toBe(false)
shouldProcess.pause()
expect(shouldProcess.isPaused).toBe(true)
shouldProcess.resume()
expect(shouldProcess.isPaused).toBe(false)
})
})
describe('defer', () => {
test('should defer', async () => {
expect.assertions(1)
const delay = (milliseconds: number) => {
const deferred = defer()
global.setTimeout(deferred.done, milliseconds)
return deferred.promise
}
const startTime = new Date().getTime()
await delay(100)
const endTime = new Date().getTime()
const elapsed = endTime - startTime
expect(elapsed).toBeGreaterThanOrEqual(100)
})
})
describe('pacemaker', () => {
test('should call heartbeatFn 2 times', async () => {
expect.assertions(2)
let count = 0
const heartbeatFn = () => {
console.log('heartbeat')
count++
}
const result = await pacemaker(heartbeatFn, setTimeout(250, 'done'), 100)
expect(count).toBe(2)
expect(result).toBe('done')
})
test('should throw', async () => {
expect.assertions(1)
const heartbeatFn = () => {
console.log('heartbeat')
}
const promFn = async () => {
await setTimeout(150)
throw 'fail'
}
await expect(pacemaker(heartbeatFn, promFn(), 100)).rejects.toMatch('fail')
})
})
describe('waitUntil', () => {
test('should wait until async pred returns truthy and return the result', async () => {
expect.assertions(1)
let counter = 0
const check = async () => {
await setTimeout(100)
return counter++ === 1
}
await expect(waitUntil(check)).resolves.toBe(true)
})
test('should wait until pred returns truthy and return the result', async () => {
expect.assertions(1)
let isTruthy = false
global.setTimeout(() => {
isTruthy = true
}, 250)
await expect(waitUntil(() => isTruthy)).resolves.toBe(true)
})
test('should handle infinite timeout and return the result', async () => {
expect.assertions(1)
let isTruthy = false
global.setTimeout(() => {
isTruthy = true
}, 250)
await expect(
waitUntil(async () => isTruthy, { timeout: Infinity })
).resolves.toBe(true)
})
test('should return string value when predicate returns string', async () => {
expect.assertions(1)
let value: string | null = null
global.setTimeout(() => {
value = 'ready'
}, 250)
await expect(waitUntil(() => value)).resolves.toBe('ready')
})
test('should throw TimeoutError if the timeout expires', async () => {
expect.assertions(1)
let isTruthy = false
global.setTimeout(() => {
isTruthy = true
}, 250)
await expect(
waitUntil(async () => isTruthy, { timeout: 100 })
).rejects.toThrow(new TimeoutError('Did not complete in 100 ms'))
})
test('rejects when pred throws', async () => {
expect.assertions(1)
await expect(
waitUntil(
async () => {
throw 'fail'
},
{ timeout: 100 }
)
).rejects.toMatch('fail')
})
})
describe('throughputLimiter', () => {
test('call to throttle with empty sliding window completes without delay', async () => {
expect.assertions(2)
const limiter = throughputLimiter(100)
const startTime = new Date().getTime()
expect(limiter.getCurrentRate()).toBe(0)
await limiter.throttle()
const endTime = new Date().getTime()
expect(endTime - startTime).toBeLessThan(5)
})
test('second call to throttle completes without delay if maxUnitsPerTime is Infinity', async () => {
expect.assertions(1)
const limiter = throughputLimiter(Infinity)
const startTime = new Date().getTime()
await limiter.throttle()
limiter.append(1000)
await limiter.throttle()
const endTime = new Date().getTime()
expect(endTime - startTime).toBeLessThan(5)
})
test('throughput should be throttled', async () => {
expect.assertions(2)
const limiter = throughputLimiter(500)
const startTime = new Date().getTime()
await limiter.throttleAndAppend(250)
await setTimeout(100)
await limiter.throttleAndAppend(250)
await setTimeout(100)
await limiter.throttleAndAppend(250)
await setTimeout(100)
await limiter.throttleAndAppend(250)
const endTime = new Date().getTime()
const elapsed = endTime - startTime
expect(elapsed).toBeGreaterThanOrEqual(1000)
expect(limiter.getCurrentRate()).toBeLessThan(1000)
})
test('minWindowLength option should work as expected', async () => {
expect.assertions(1)
const limiter = throughputLimiter(4, { minWindowLength: 4 })
const startTime = new Date().getTime()
await limiter.throttleAndAppend(1)
await limiter.throttleAndAppend(1)
await limiter.throttleAndAppend(1)
await limiter.throttleAndAppend(1)
const endTime = new Date().getTime()
const elapsed = endTime - startTime
expect(elapsed).toBeLessThan(5)
})
test('maxWindowLength option should work as expected', async () => {
expect.assertions(1)
const limiter = throughputLimiter(500, {
maxWindowLength: 2,
})
const startTime = new Date().getTime()
await limiter.throttleAndAppend(250)
await setTimeout(100)
await limiter.throttleAndAppend(250)
await setTimeout(100)
await limiter.throttleAndAppend(250)
await setTimeout(100)
await limiter.throttleAndAppend(250)
const endTime = new Date().getTime()
const elapsed = endTime - startTime
expect(elapsed).toBeGreaterThanOrEqual(1500)
})
test('period option should work as expected', async () => {
expect.assertions(2)
// 5 units per 100 ms
const limiter = throughputLimiter(5, { period: 100 })
const startTime = new Date().getTime()
await limiter.throttleAndAppend(5)
await setTimeout(100)
await limiter.throttleAndAppend(5)
await setTimeout(100)
await limiter.throttleAndAppend(5)
const endTime = new Date().getTime()
const elapsed = endTime - startTime
expect(elapsed).toBeGreaterThanOrEqual(200)
expect(elapsed).toBeLessThan(250)
})
test('expireAfter option should work as expected', async () => {
expect.assertions(2)
// 5 units per 100 ms, with sliding window entries resetting after 50 ms
const limiter = throughputLimiter(5, {
period: 100,
expireAfter: 50,
})
const startTime = new Date().getTime()
await limiter.throttleAndAppend(5)
await setTimeout(50)
await limiter.throttleAndAppend(5)
await setTimeout(50)
await limiter.throttleAndAppend(5)
const endTime = new Date().getTime()
const elapsed = endTime - startTime
expect(elapsed).toBeGreaterThanOrEqual(100)
expect(elapsed).toBeLessThan(150)
})
test('should throw if both maxWindowLength and expireAfter are Inifinity', async () => {
expect(() => {
throughputLimiter(5, {
maxWindowLength: Infinity,
expireAfter: Infinity,
})
}).toThrow(OptionsError)
})
})
describe('raceTimeout', () => {
test('It should return promise if resolved before timeout', async () => {
const winner = await raceTimeout(setTimeout(5, 'done'), 10)
expect(winner).toBe('done')
})
test('It should return the TIMEOUT symbol', async () => {
const winner = await raceTimeout(setTimeout(10, 'done'), 5)
expect(winner).toBe(TIMEOUT)
})
})
describe('multiplex', () => {
test('should handle async iterables', async () => {
expect.assertions(1)
const iterable1 = {
async *[Symbol.asyncIterator]() {
yield 'a'
yield 'b'
},
}
const iterable2 = {
async *[Symbol.asyncIterator]() {
yield 'c'
yield 'd'
},
}
const results: string[] = []
for await (const value of multiplex(iterable1, iterable2)) {
results.push(value)
}
expect(results.sort()).toEqual(['a', 'b', 'c', 'd'])
})
test('should yield values as they become available', async () => {
expect.assertions(1)
async function* slowGen() {
await setTimeout(100)
yield 'slow'
}
async function* fastGen() {
yield 'fast1'
yield 'fast2'
}
const results: string[] = []
for await (const value of multiplex(slowGen(), fastGen())) {
results.push(value)
}
// Should get fast values before slow value
expect(results).toEqual(['fast1', 'fast2', 'slow'])
})
test('should handle empty iterators', async () => {
expect.assertions(1)
async function* emptyGen() {
// yields nothing
}
async function* gen() {
yield 1
yield 2
}
const results: number[] = []
for await (const value of multiplex(emptyGen(), gen())) {
results.push(value)
}
expect(results).toEqual([1, 2])
})
test('should throw error if any iterator throws', async () => {
expect.assertions(1)
async function* errorGen() {
yield 1
throw new Error('Iterator error')
}
async function* normalGen() {
yield 2
yield 3
}
await expect(async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _val of multiplex(errorGen(), normalGen())) {
// consume values
}
}).rejects.toThrow('Iterator error')
})
test('should clean up iterators on early exit', async () => {
expect.assertions(1)
let cleaned = false
async function* gen() {
try {
yield 1
yield 2
yield 3
} finally {
cleaned = true
}
}
for await (const value of multiplex(gen())) {
if (value === 1) break
}
// Give cleanup time to run
await setTimeout(10)
expect(cleaned).toBe(true)
})
test('should handle iterators with different speeds', async () => {
expect.assertions(1)
async function* gen1() {
yield 1
await setTimeout(50)
yield 2
await setTimeout(50)
yield 3
}
async function* gen2() {
await setTimeout(25)
yield 4
await setTimeout(25)
yield 5
await setTimeout(25)
yield 6
}
const results: number[] = []
for await (const value of multiplex(gen1(), gen2())) {
results.push(value)
}
// All values should be present
expect(results.sort()).toEqual([1, 2, 3, 4, 5, 6])
})
})