ai-functions
Version:
Core AI primitives for building intelligent applications
1,165 lines (962 loc) • 36.5 kB
text/typescript
/**
* Tests for template.ts module
*
* This module provides core utilities for all AI functions:
* - parseTemplate: Parse tagged template literals with YAML conversion
* - createChainablePromise: Create promises that support options chaining
* - createTemplateFunction: Create functions supporting both template and regular calls
* - withBatch: Add batch capability to template functions
* - createAsyncIterable: Create async iterables from arrays or generators
* - createStreamableList: Create dual Promise/AsyncIterable results
*/
import { describe, it, expect, vi } from 'vitest'
import {
parseTemplate,
createChainablePromise,
createTemplateFunction,
withBatch,
createAsyncIterable,
createStreamableList,
type FunctionOptions,
type ChainablePromise,
type TemplateFunction,
type BatchableFunction,
type StreamableList,
} from '../src/template.js'
// ============================================================================
// parseTemplate Tests
// ============================================================================
describe('parseTemplate', () => {
describe('basic interpolation', () => {
it('parses simple string interpolation', () => {
const topic = 'TypeScript'
const result = parseTemplate`Write about ${topic}`
expect(result).toBe('Write about TypeScript')
})
it('parses multiple interpolations', () => {
const topic = 'TypeScript'
const audience = 'beginners'
const result = parseTemplate`Write about ${topic} for ${audience}`
expect(result).toBe('Write about TypeScript for beginners')
})
it('handles empty template', () => {
const result = parseTemplate``
expect(result).toBe('')
})
it('handles template with no interpolations', () => {
const result = parseTemplate`Just plain text`
expect(result).toBe('Just plain text')
})
it('handles adjacent interpolations', () => {
const a = 'Hello'
const b = 'World'
const result = parseTemplate`${a}${b}`
expect(result).toBe('HelloWorld')
})
})
describe('primitive types', () => {
it('handles numbers', () => {
const count = 42
const result = parseTemplate`Generate ${count} items`
expect(result).toBe('Generate 42 items')
})
it('handles booleans', () => {
const active = true
const inactive = false
const result = parseTemplate`Active: ${active}, Inactive: ${inactive}`
expect(result).toBe('Active: true, Inactive: false')
})
it('handles zero', () => {
const zero = 0
const result = parseTemplate`Count: ${zero}`
expect(result).toBe('Count: 0')
})
it('handles empty string', () => {
const empty = ''
const result = parseTemplate`Value: ${empty}!`
expect(result).toBe('Value: !')
})
it('handles BigInt', () => {
const big = BigInt(9007199254740991)
const result = parseTemplate`Big number: ${big}`
expect(result).toBe('Big number: 9007199254740991')
})
})
describe('null and undefined handling', () => {
it('handles undefined at end (trailing template part)', () => {
const value = undefined
const result = parseTemplate`Value is ${value}`
expect(result).toBe('Value is ')
})
it('handles undefined in middle', () => {
const value = undefined
const result = parseTemplate`Before ${value} after`
expect(result).toBe('Before after')
})
it('handles null as object (converts to YAML)', () => {
const value = null
const result = parseTemplate`Value is ${value}`
// null is typeof 'object' but === null, so it goes to String()
expect(result).toContain('Value is')
})
it('handles multiple undefined values', () => {
const a = undefined
const b = undefined
const result = parseTemplate`${a} and ${b}`
expect(result).toBe(' and ')
})
})
describe('object and array YAML conversion', () => {
it('converts simple objects to YAML', () => {
const context = { topic: 'TypeScript', level: 'beginner' }
const result = parseTemplate`Write about ${{ context }}`
expect(result).toContain('context:')
expect(result).toContain('topic: TypeScript')
expect(result).toContain('level: beginner')
})
it('converts arrays to YAML lists', () => {
const topics = ['React', 'Vue', 'Angular']
const result = parseTemplate`Compare ${topics}`
expect(result).toContain('- React')
expect(result).toContain('- Vue')
expect(result).toContain('- Angular')
})
it('handles nested objects', () => {
const brand = {
hero: 'developers',
problem: {
internal: 'complexity',
external: 'time constraints',
},
}
const result = parseTemplate`Create a story for ${{ brand }}`
expect(result).toContain('brand:')
expect(result).toContain('hero: developers')
expect(result).toContain('problem:')
expect(result).toContain('internal: complexity')
expect(result).toContain('external: time constraints')
})
it('handles arrays of objects', () => {
const users = [
{ name: 'Alice', role: 'admin' },
{ name: 'Bob', role: 'user' },
]
const result = parseTemplate`Process users: ${users}`
expect(result).toContain('- name: Alice')
expect(result).toContain('role: admin')
expect(result).toContain('- name: Bob')
expect(result).toContain('role: user')
})
it('handles empty objects', () => {
const empty = {}
const result = parseTemplate`Config: ${empty}`
expect(result).toContain('Config:')
expect(result).toContain('{}')
})
it('handles empty arrays', () => {
const empty: string[] = []
const result = parseTemplate`Items: ${empty}`
expect(result).toContain('Items:')
expect(result).toContain('[]')
})
it('handles deeply nested structures', () => {
const data = {
level1: {
level2: {
level3: {
value: 'deep',
},
},
},
}
const result = parseTemplate`Data: ${data}`
expect(result).toContain('level1:')
expect(result).toContain('level2:')
expect(result).toContain('level3:')
expect(result).toContain('value: deep')
})
it('adds newline before YAML content', () => {
const obj = { key: 'value' }
const result = parseTemplate`Config:${obj}`
// Should have newline between text and YAML
expect(result).toMatch(/Config:\n/)
})
})
describe('mixed content', () => {
it('handles mix of primitives and objects', () => {
const count = 5
const options = { format: 'json', verbose: true }
const result = parseTemplate`Generate ${count} items with ${options}`
expect(result).toContain('Generate 5 items with')
expect(result).toContain('format: json')
expect(result).toContain('verbose: true')
})
it('preserves template structure with objects inline', () => {
const requirements = {
pages: ['home', 'about', 'contact'],
features: ['dark mode', 'responsive'],
}
const result = parseTemplate`marketing site${{ requirements }}`
expect(result).toContain('marketing site')
expect(result).toContain('requirements:')
expect(result).toContain('pages:')
expect(result).toContain('- home')
expect(result).toContain('features:')
expect(result).toContain('- dark mode')
})
})
describe('special values', () => {
it('handles Date objects', () => {
const date = new Date('2024-01-15T10:30:00Z')
const result = parseTemplate`Date: ${date}`
// Date objects are converted via YAML
expect(result).toContain('Date:')
})
it('handles RegExp objects', () => {
const regex = /test\d+/gi
const result = parseTemplate`Pattern: ${regex}`
expect(result).toContain('Pattern:')
})
it('handles functions (converts to string)', () => {
const fn = () => 'test'
// Functions are not objects in typeof sense for this check
// Actually typeof function is 'function', not 'object'
const result = parseTemplate`Function: ${fn}`
expect(result).toContain('Function:')
})
it('handles Symbol', () => {
const sym = Symbol('test')
const result = parseTemplate`Symbol: ${sym}`
expect(result).toContain('Symbol(test)')
})
})
})
// ============================================================================
// createChainablePromise Tests
// ============================================================================
describe('createChainablePromise', () => {
describe('basic promise behavior', () => {
it('can be awaited directly', async () => {
const executor = vi.fn().mockResolvedValue('result')
const chainable = createChainablePromise(executor)
const result = await chainable
expect(result).toBe('result')
expect(executor).toHaveBeenCalledTimes(1)
})
it('supports .then()', async () => {
const executor = vi.fn().mockResolvedValue('result')
const chainable = createChainablePromise(executor)
const result = await chainable.then((v) => v.toUpperCase())
expect(result).toBe('RESULT')
})
it('supports .catch()', async () => {
const executor = vi.fn().mockRejectedValue(new Error('test error'))
const chainable = createChainablePromise(executor)
const error = await chainable.catch((e) => e.message)
expect(error).toBe('test error')
})
it('supports .finally()', async () => {
const executor = vi.fn().mockResolvedValue('result')
const finallyFn = vi.fn()
const chainable = createChainablePromise(executor)
await chainable.finally(finallyFn)
expect(finallyFn).toHaveBeenCalled()
})
it('supports chained .then().catch().finally()', async () => {
const executor = vi.fn().mockResolvedValue(5)
const finallyFn = vi.fn()
const chainable = createChainablePromise(executor)
const result = await chainable
.then((v) => v * 2)
.catch(() => 0)
.finally(finallyFn)
expect(result).toBe(10)
expect(finallyFn).toHaveBeenCalled()
})
})
describe('options chaining', () => {
it('can be called with options', async () => {
const executor = vi.fn().mockImplementation((opts) => Promise.resolve(opts))
const chainable = createChainablePromise(executor)
const result = await chainable({ model: 'claude-opus-4-5' })
expect(result).toEqual({ model: 'claude-opus-4-5' })
})
it('merges options with default options', async () => {
const executor = vi.fn().mockImplementation((opts) => Promise.resolve(opts))
const defaultOptions: FunctionOptions = { model: 'sonnet', temperature: 0.5 }
const chainable = createChainablePromise(executor, defaultOptions)
const result = await chainable({ temperature: 0.9, maxTokens: 1000 })
expect(result).toEqual({
model: 'sonnet',
temperature: 0.9,
maxTokens: 1000,
})
})
it('overrides default options when called with same option', async () => {
const executor = vi.fn().mockImplementation((opts) => Promise.resolve(opts))
const defaultOptions: FunctionOptions = { model: 'sonnet' }
const chainable = createChainablePromise(executor, defaultOptions)
const result = await chainable({ model: 'claude-opus-4-5' })
expect(result).toEqual({ model: 'claude-opus-4-5' })
})
it('uses default options when awaited directly', async () => {
const executor = vi.fn().mockImplementation((opts) => Promise.resolve(opts))
const defaultOptions: FunctionOptions = { model: 'sonnet' }
const chainable = createChainablePromise(executor, defaultOptions)
const result = await chainable
expect(result).toEqual({ model: 'sonnet' })
})
it('handles undefined options call', async () => {
const executor = vi.fn().mockImplementation((opts) => Promise.resolve(opts))
const chainable = createChainablePromise(executor)
// Calling with undefined merges {...defaultOptions, ...undefined} = {}
const result = await chainable(undefined)
expect(result).toEqual({})
})
it('handles empty options object', async () => {
const executor = vi.fn().mockImplementation((opts) => Promise.resolve(opts))
const defaultOptions: FunctionOptions = { model: 'sonnet' }
const chainable = createChainablePromise(executor, defaultOptions)
const result = await chainable({})
expect(result).toEqual({ model: 'sonnet' })
})
})
describe('executor behavior', () => {
it('calls executor with default options on creation', () => {
const executor = vi.fn().mockResolvedValue('result')
const defaultOptions: FunctionOptions = { model: 'sonnet' }
createChainablePromise(executor, defaultOptions)
expect(executor).toHaveBeenCalledWith(defaultOptions)
})
it('creates new promise when called with options', async () => {
const executor = vi.fn().mockResolvedValue('result')
const chainable = createChainablePromise(executor)
// First call happens on creation
expect(executor).toHaveBeenCalledTimes(1)
// Calling with options creates a new promise
await chainable({ model: 'claude-opus-4-5' })
expect(executor).toHaveBeenCalledTimes(2)
})
})
describe('type behavior', () => {
it('preserves generic type through await', async () => {
interface User {
name: string
age: number
}
const user: User = { name: 'Alice', age: 30 }
const executor = vi.fn().mockResolvedValue(user)
const chainable = createChainablePromise<User>(executor)
const result = await chainable
expect(result.name).toBe('Alice')
expect(result.age).toBe(30)
})
it('preserves generic type through options call', async () => {
interface User {
name: string
}
const user: User = { name: 'Bob' }
const executor = vi.fn().mockResolvedValue(user)
const chainable = createChainablePromise<User>(executor)
const result = await chainable({ model: 'sonnet' })
expect(result.name).toBe('Bob')
})
})
})
// ============================================================================
// createTemplateFunction Tests
// ============================================================================
describe('createTemplateFunction', () => {
describe('tagged template literal syntax', () => {
it('handles tagged template with simple interpolation', async () => {
let capturedPrompt = ''
const handler = async (prompt: string) => {
capturedPrompt = prompt
return 'result'
}
const fn = createTemplateFunction(handler)
const result = await fn`Hello ${'world'}`
expect(capturedPrompt).toBe('Hello world')
expect(result).toBe('result')
})
it('handles tagged template with objects', async () => {
let capturedPrompt = ''
const handler = async (prompt: string) => {
capturedPrompt = prompt
return 'result'
}
const fn = createTemplateFunction(handler)
const data = { name: 'test', value: 42 }
await fn`Process ${data}`
expect(capturedPrompt).toContain('name: test')
expect(capturedPrompt).toContain('value: 42')
})
it('handles tagged template without interpolations', async () => {
let capturedPrompt = ''
const handler = async (prompt: string) => {
capturedPrompt = prompt
return 'result'
}
const fn = createTemplateFunction(handler)
await fn`Plain prompt`
expect(capturedPrompt).toBe('Plain prompt')
})
it('returns chainable promise from tagged template', async () => {
const handler = async (prompt: string, options?: FunctionOptions) =>
options?.model ?? 'default'
const fn = createTemplateFunction(handler)
// Should be chainable
const result = await fn`test`({ model: 'claude-opus-4-5' })
expect(result).toBe('claude-opus-4-5')
})
})
describe('regular function call syntax', () => {
it('handles regular string call', async () => {
let capturedPrompt = ''
const handler = async (prompt: string) => {
capturedPrompt = prompt
return 'result'
}
const fn = createTemplateFunction(handler)
await fn('Hello world')
expect(capturedPrompt).toBe('Hello world')
})
it('handles regular call with options', async () => {
let capturedOptions: FunctionOptions | undefined
const handler = async (prompt: string, options?: FunctionOptions) => {
capturedOptions = options
return 'result'
}
const fn = createTemplateFunction(handler)
await fn('Hello world', { model: 'claude-opus-4-5', temperature: 0.7 })
expect(capturedOptions).toEqual({ model: 'claude-opus-4-5', temperature: 0.7 })
})
it('returns regular promise from string call', async () => {
const handler = async () => 'result'
const fn = createTemplateFunction(handler)
const promise = fn('test')
expect(promise).toBeInstanceOf(Promise)
expect(await promise).toBe('result')
})
})
describe('options chaining on tagged templates', () => {
it('supports model option', async () => {
let capturedOptions: FunctionOptions | undefined
const handler = async (_: string, options?: FunctionOptions) => {
capturedOptions = options
return 'ok'
}
const fn = createTemplateFunction(handler)
await fn`test`({ model: 'claude-opus-4-5' })
expect(capturedOptions?.model).toBe('claude-opus-4-5')
})
it('supports thinking option', async () => {
let capturedOptions: FunctionOptions | undefined
const handler = async (_: string, options?: FunctionOptions) => {
capturedOptions = options
return 'ok'
}
const fn = createTemplateFunction(handler)
await fn`analysis`({ thinking: 'high' })
expect(capturedOptions?.thinking).toBe('high')
})
it('supports thinking as token budget', async () => {
let capturedOptions: FunctionOptions | undefined
const handler = async (_: string, options?: FunctionOptions) => {
capturedOptions = options
return 'ok'
}
const fn = createTemplateFunction(handler)
await fn`analysis`({ thinking: 10000 })
expect(capturedOptions?.thinking).toBe(10000)
})
it('supports temperature option', async () => {
let capturedOptions: FunctionOptions | undefined
const handler = async (_: string, options?: FunctionOptions) => {
capturedOptions = options
return 'ok'
}
const fn = createTemplateFunction(handler)
await fn`creative`({ temperature: 0.9 })
expect(capturedOptions?.temperature).toBe(0.9)
})
it('supports maxTokens option', async () => {
let capturedOptions: FunctionOptions | undefined
const handler = async (_: string, options?: FunctionOptions) => {
capturedOptions = options
return 'ok'
}
const fn = createTemplateFunction(handler)
await fn`long article`({ maxTokens: 4000 })
expect(capturedOptions?.maxTokens).toBe(4000)
})
it('supports system option', async () => {
let capturedOptions: FunctionOptions | undefined
const handler = async (_: string, options?: FunctionOptions) => {
capturedOptions = options
return 'ok'
}
const fn = createTemplateFunction(handler)
await fn`task`({ system: 'You are helpful' })
expect(capturedOptions?.system).toBe('You are helpful')
})
it('supports mode option', async () => {
let capturedOptions: FunctionOptions | undefined
const handler = async (_: string, options?: FunctionOptions) => {
capturedOptions = options
return 'ok'
}
const fn = createTemplateFunction(handler)
await fn`task`({ mode: 'background' })
expect(capturedOptions?.mode).toBe('background')
})
it('supports multiple options together', async () => {
let capturedOptions: FunctionOptions | undefined
const handler = async (_: string, options?: FunctionOptions) => {
capturedOptions = options
return 'ok'
}
const fn = createTemplateFunction(handler)
await fn`complex task`({
model: 'claude-opus-4-5',
thinking: 'high',
temperature: 0.7,
maxTokens: 8000,
system: 'Be precise',
mode: 'background',
})
expect(capturedOptions).toEqual({
model: 'claude-opus-4-5',
thinking: 'high',
temperature: 0.7,
maxTokens: 8000,
system: 'Be precise',
mode: 'background',
})
})
})
describe('error handling', () => {
it('propagates errors from handler in tagged template', async () => {
const handler = async () => {
throw new Error('Handler error')
}
const fn = createTemplateFunction(handler)
await expect(fn`test`).rejects.toThrow('Handler error')
})
it('propagates errors from handler in regular call', async () => {
const handler = async () => {
throw new Error('Handler error')
}
const fn = createTemplateFunction(handler)
await expect(fn('test')).rejects.toThrow('Handler error')
})
it('propagates errors from options chaining', async () => {
const handler = async (_: string, opts?: FunctionOptions) => {
if (opts?.model === 'invalid') {
throw new Error('Invalid model')
}
return 'ok'
}
const fn = createTemplateFunction(handler)
await expect(fn`test`({ model: 'invalid' })).rejects.toThrow('Invalid model')
})
})
describe('type preservation', () => {
it('preserves return type', async () => {
interface Response {
text: string
tokens: number
}
const handler = async (): Promise<Response> => ({
text: 'hello',
tokens: 10,
})
const fn = createTemplateFunction(handler)
const result = await fn`test`
expect(result.text).toBe('hello')
expect(result.tokens).toBe(10)
})
})
})
// ============================================================================
// withBatch Tests
// ============================================================================
describe('withBatch', () => {
it('adds batch method to template function', () => {
const handler = async (prompt: string) => prompt.toUpperCase()
const fn = createTemplateFunction(handler)
const batchHandler = async (inputs: string[]) => inputs.map((i) => i.toUpperCase())
const batchable = withBatch(fn, batchHandler)
expect(typeof batchable.batch).toBe('function')
})
it('batch method processes array of inputs', async () => {
const handler = async (prompt: string) => prompt.toUpperCase()
const fn = createTemplateFunction(handler)
const batchHandler = async (inputs: string[]) => inputs.map((i) => i.toUpperCase())
const batchable = withBatch(fn, batchHandler)
const results = await batchable.batch(['hello', 'world'])
expect(results).toEqual(['HELLO', 'WORLD'])
})
it('preserves original template function behavior', async () => {
let capturedPrompt = ''
const handler = async (prompt: string) => {
capturedPrompt = prompt
return 'result'
}
const fn = createTemplateFunction(handler)
const batchHandler = async (inputs: string[]) => inputs
const batchable = withBatch(fn, batchHandler)
// Template syntax still works
await batchable`test ${'value'}`
expect(capturedPrompt).toBe('test value')
// Regular call still works
await batchable('direct call')
expect(capturedPrompt).toBe('direct call')
})
it('handles empty batch input', async () => {
const handler = async (prompt: string) => prompt
const fn = createTemplateFunction(handler)
const batchHandler = async (inputs: string[]) => inputs.map((i) => i.toUpperCase())
const batchable = withBatch(fn, batchHandler)
const results = await batchable.batch([])
expect(results).toEqual([])
})
it('supports typed batch inputs', async () => {
interface Task {
id: number
content: string
}
interface Result {
taskId: number
output: string
}
const handler = async () => ({ taskId: 0, output: '' })
const fn = createTemplateFunction(handler)
const batchHandler = async (inputs: Task[]): Promise<Result[]> =>
inputs.map((t) => ({ taskId: t.id, output: t.content.toUpperCase() }))
const batchable = withBatch<Result, Task>(fn, batchHandler)
const results = await batchable.batch([
{ id: 1, content: 'hello' },
{ id: 2, content: 'world' },
])
expect(results).toEqual([
{ taskId: 1, output: 'HELLO' },
{ taskId: 2, output: 'WORLD' },
])
})
})
// ============================================================================
// createAsyncIterable Tests
// ============================================================================
describe('createAsyncIterable', () => {
describe('from array', () => {
it('creates async iterable from array', async () => {
const items = [1, 2, 3]
const iterable = createAsyncIterable(items)
const results: number[] = []
for await (const item of iterable) {
results.push(item)
}
expect(results).toEqual([1, 2, 3])
})
it('handles empty array', async () => {
const items: number[] = []
const iterable = createAsyncIterable(items)
const results: number[] = []
for await (const item of iterable) {
results.push(item)
}
expect(results).toEqual([])
})
it('handles array of objects', async () => {
const items = [{ id: 1 }, { id: 2 }]
const iterable = createAsyncIterable(items)
const results: { id: number }[] = []
for await (const item of iterable) {
results.push(item)
}
expect(results).toEqual([{ id: 1 }, { id: 2 }])
})
it('can be iterated multiple times (array source)', async () => {
const items = [1, 2, 3]
const iterable = createAsyncIterable(items)
const results1: number[] = []
for await (const item of iterable) {
results1.push(item)
}
const results2: number[] = []
for await (const item of iterable) {
results2.push(item)
}
expect(results1).toEqual([1, 2, 3])
expect(results2).toEqual([1, 2, 3])
})
})
describe('from generator', () => {
it('creates async iterable from generator function', async () => {
async function* generator() {
yield 1
yield 2
yield 3
}
const iterable = createAsyncIterable(generator)
const results: number[] = []
for await (const item of iterable) {
results.push(item)
}
expect(results).toEqual([1, 2, 3])
})
it('handles generator that yields nothing', async () => {
async function* generator() {
// yields nothing
}
const iterable = createAsyncIterable(generator)
const results: number[] = []
for await (const item of iterable) {
results.push(item)
}
expect(results).toEqual([])
})
it('handles generator with delays', async () => {
async function* generator() {
yield 1
await new Promise((r) => setTimeout(r, 10))
yield 2
}
const iterable = createAsyncIterable(generator)
const results: number[] = []
for await (const item of iterable) {
results.push(item)
}
expect(results).toEqual([1, 2])
})
it('propagates generator errors', async () => {
async function* generator() {
yield 1
throw new Error('Generator error')
}
const iterable = createAsyncIterable(generator)
const results: number[] = []
await expect(async () => {
for await (const item of iterable) {
results.push(item)
}
}).rejects.toThrow('Generator error')
expect(results).toEqual([1])
})
})
})
// ============================================================================
// createStreamableList Tests
// ============================================================================
describe('createStreamableList', () => {
describe('promise behavior', () => {
it('can be awaited to get full array', async () => {
const getItems = async () => [1, 2, 3]
const streamable = createStreamableList(getItems)
const result = await streamable
expect(result).toEqual([1, 2, 3])
})
it('supports .then()', async () => {
const getItems = async () => [1, 2, 3]
const streamable = createStreamableList(getItems)
const result = await streamable.then((items) => items.length)
expect(result).toBe(3)
})
it('supports .catch()', async () => {
const getItems = async () => {
throw new Error('Get items error')
}
const streamable = createStreamableList<number>(getItems)
const error = await streamable.catch((e) => e.message)
expect(error).toBe('Get items error')
})
it('supports .finally()', async () => {
const getItems = async () => [1, 2, 3]
const finallyFn = vi.fn()
const streamable = createStreamableList(getItems)
await streamable.finally(finallyFn)
expect(finallyFn).toHaveBeenCalled()
})
it('has Promise Symbol.toStringTag', () => {
const getItems = async () => [1, 2, 3]
const streamable = createStreamableList(getItems)
expect(Object.prototype.toString.call(streamable)).toBe('[object Promise]')
})
})
describe('async iteration without custom stream', () => {
it('iterates over resolved items when no streamItems provided', async () => {
const getItems = async () => [1, 2, 3]
const streamable = createStreamableList(getItems)
const results: number[] = []
for await (const item of streamable) {
results.push(item)
}
expect(results).toEqual([1, 2, 3])
})
it('handles empty array', async () => {
const getItems = async () => [] as number[]
const streamable = createStreamableList(getItems)
const results: number[] = []
for await (const item of streamable) {
results.push(item)
}
expect(results).toEqual([])
})
})
describe('async iteration with custom stream', () => {
it('uses custom streamItems generator when provided', async () => {
const getItems = async () => [1, 2, 3]
async function* streamItems() {
yield 10
yield 20
yield 30
}
const streamable = createStreamableList(getItems, streamItems)
const results: number[] = []
for await (const item of streamable) {
results.push(item)
}
// Should use the stream, not the array
expect(results).toEqual([10, 20, 30])
})
it('stream can yield different items than getItems returns', async () => {
// This simulates streaming where you might get incremental items
const getItems = async () => ['final1', 'final2']
async function* streamItems() {
yield 'stream1'
yield 'stream2'
yield 'stream3'
}
const streamable = createStreamableList(getItems, streamItems)
// Await gets the promise result
const awaited = await streamable
// But iterating uses the stream
const streamed: string[] = []
const freshStreamable = createStreamableList(getItems, streamItems)
for await (const item of freshStreamable) {
streamed.push(item)
}
expect(awaited).toEqual(['final1', 'final2'])
expect(streamed).toEqual(['stream1', 'stream2', 'stream3'])
})
it('handles stream errors separately from promise', async () => {
const getItems = async () => [1, 2, 3]
async function* streamItems() {
yield 1
throw new Error('Stream error')
}
const streamable = createStreamableList(getItems, streamItems)
// Promise should resolve fine
const awaited = await streamable
expect(awaited).toEqual([1, 2, 3])
// But streaming should error
const freshStreamable = createStreamableList(getItems, streamItems)
await expect(async () => {
for await (const _item of freshStreamable) {
// iterate
}
}).rejects.toThrow('Stream error')
})
})
describe('dual usage patterns', () => {
it('can await and iterate on same streamable (with default stream)', async () => {
let callCount = 0
const getItems = async () => {
callCount++
return [1, 2, 3]
}
const streamable = createStreamableList(getItems)
// Await first
const awaited = await streamable
expect(awaited).toEqual([1, 2, 3])
// Then iterate (uses same resolved promise)
const iterated: number[] = []
for await (const item of streamable) {
iterated.push(item)
}
expect(iterated).toEqual([1, 2, 3])
// getItems should only be called once
expect(callCount).toBe(1)
})
it('works with Promise.all', async () => {
const streamable1 = createStreamableList(async () => [1, 2])
const streamable2 = createStreamableList(async () => [3, 4])
const [result1, result2] = await Promise.all([streamable1, streamable2])
expect(result1).toEqual([1, 2])
expect(result2).toEqual([3, 4])
})
it('works with Promise.race', async () => {
const slow = createStreamableList(async () => {
await new Promise((r) => setTimeout(r, 100))
return ['slow']
})
const fast = createStreamableList(async () => ['fast'])
const result = await Promise.race([slow, fast])
expect(result).toEqual(['fast'])
})
})
describe('type preservation', () => {
it('preserves generic type through await', async () => {
interface Item {
id: number
name: string
}
const getItems = async (): Promise<Item[]> => [
{ id: 1, name: 'one' },
{ id: 2, name: 'two' },
]
const streamable = createStreamableList(getItems)
const result = await streamable
expect(result[0].id).toBe(1)
expect(result[0].name).toBe('one')
})
it('preserves generic type through iteration', async () => {
interface Item {
id: number
}
const getItems = async (): Promise<Item[]> => [{ id: 1 }, { id: 2 }]
const streamable = createStreamableList(getItems)
for await (const item of streamable) {
expect(typeof item.id).toBe('number')
}
})
})
})
// ============================================================================
// Type Export Tests
// ============================================================================
describe('type exports', () => {
it('exports FunctionOptions type', () => {
const opts: FunctionOptions = {
model: 'claude-opus-4-5',
thinking: 'high',
temperature: 0.7,
maxTokens: 1000,
system: 'Be helpful',
mode: 'background',
}
expect(opts.model).toBe('claude-opus-4-5')
})
it('exports ChainablePromise type', () => {
// Type check - this should compile
const _check: ChainablePromise<string> = createChainablePromise(async () => 'test')
expect(_check).toBeDefined()
})
it('exports TemplateFunction type', () => {
// Type check - this should compile
const _check: TemplateFunction<string> = createTemplateFunction(async () => 'test')
expect(_check).toBeDefined()
})
it('exports BatchableFunction type', () => {
const fn = createTemplateFunction(async () => 'test')
const _check: BatchableFunction<string> = withBatch(fn, async (inputs) =>
inputs.map(() => 'test')
)
expect(_check.batch).toBeDefined()
})
it('exports StreamableList type', () => {
const _check: StreamableList<number> = createStreamableList(async () => [1, 2, 3])
expect(_check).toBeDefined()
})
})