ai-props 2.1.3 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dev.vars +2 -0
- package/CHANGELOG.md +11 -0
- package/README.md +2 -0
- package/package.json +39 -13
- package/src/ai.ts +12 -31
- package/src/cascade.ts +795 -0
- package/src/client.ts +440 -0
- package/src/durable-cascade.ts +743 -0
- package/src/event-bridge.ts +478 -0
- package/src/generate.ts +14 -12
- package/src/hoc.ts +15 -19
- package/src/hono-jsx.ts +675 -0
- package/src/index.ts +30 -0
- package/src/mdx-types.ts +169 -0
- package/src/mdx-utils.ts +437 -0
- package/src/mdx.ts +1008 -0
- package/src/rpc.ts +614 -0
- package/src/streaming.ts +618 -0
- package/src/validate.ts +15 -29
- package/src/worker.ts +547 -0
- package/test/cascade.test.ts +338 -0
- package/test/durable-cascade.test.ts +319 -0
- package/test/event-bridge.test.ts +351 -0
- package/test/generate.test.ts +6 -16
- package/test/mdx.test.ts +817 -0
- package/test/worker/capnweb-rpc.test.ts +1084 -0
- package/test/worker/full-flow.integration.test.ts +1463 -0
- package/test/worker/hono-jsx.test.ts +1258 -0
- package/test/worker/mdx-parsing.test.ts +1148 -0
- package/test/worker/setup.ts +56 -0
- package/test/worker.test.ts +595 -0
- package/tsconfig.json +2 -1
- package/vitest.config.js +6 -0
- package/vitest.config.ts +15 -1
- package/vitest.workers.config.ts +58 -0
- package/wrangler.jsonc +27 -0
- package/.turbo/turbo-build.log +0 -4
- package/LICENSE +0 -21
- package/dist/ai.d.ts +0 -125
- package/dist/ai.d.ts.map +0 -1
- package/dist/ai.js +0 -199
- package/dist/ai.js.map +0 -1
- package/dist/cache.d.ts +0 -66
- package/dist/cache.d.ts.map +0 -1
- package/dist/cache.js +0 -183
- package/dist/cache.js.map +0 -1
- package/dist/generate.d.ts +0 -69
- package/dist/generate.d.ts.map +0 -1
- package/dist/generate.js +0 -221
- package/dist/generate.js.map +0 -1
- package/dist/hoc.d.ts +0 -164
- package/dist/hoc.d.ts.map +0 -1
- package/dist/hoc.js +0 -236
- package/dist/hoc.js.map +0 -1
- package/dist/index.d.ts +0 -15
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -21
- package/dist/index.js.map +0 -1
- package/dist/types.d.ts +0 -152
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -7
- package/dist/types.js.map +0 -1
- package/dist/validate.d.ts +0 -58
- package/dist/validate.d.ts.map +0 -1
- package/dist/validate.js +0 -253
- package/dist/validate.js.map +0 -1
- package/src/ai.js +0 -198
- package/src/cache.js +0 -182
- package/src/generate.js +0 -220
- package/src/hoc.js +0 -235
- package/src/index.js +0 -20
- package/src/types.js +0 -6
- package/src/validate.js +0 -252
|
@@ -0,0 +1,1463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full Integration Tests for ai-props /worker Flow
|
|
3
|
+
*
|
|
4
|
+
* RED phase: Comprehensive failing tests for the complete ai-props /worker flow.
|
|
5
|
+
* These tests cover the full pipeline from schema definition through rendering output.
|
|
6
|
+
*
|
|
7
|
+
* Uses @cloudflare/vitest-pool-workers with real bindings (NO MOCKS).
|
|
8
|
+
*
|
|
9
|
+
* Key flows tested:
|
|
10
|
+
* 1. End-to-End Props Generation via RPC
|
|
11
|
+
* 2. Full MDX Pipeline (parse -> generate -> render)
|
|
12
|
+
* 3. hono/jsx Complete Flow with streaming and hydration
|
|
13
|
+
* 4. Cross-Worker Integration via service bindings
|
|
14
|
+
* 5. Error Recovery and graceful degradation
|
|
15
|
+
*
|
|
16
|
+
* Bead: aip-ynj6
|
|
17
|
+
*
|
|
18
|
+
* @packageDocumentation
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect, beforeEach, beforeAll, afterEach } from 'vitest'
|
|
22
|
+
import { env, SELF } from 'cloudflare:test'
|
|
23
|
+
|
|
24
|
+
// Import worker modules
|
|
25
|
+
import { PropsService, PropsServiceCore } from '../../src/worker.js'
|
|
26
|
+
import { generateProps, prefetchProps, mergeWithGenerated, clearCache } from '../../src/generate.js'
|
|
27
|
+
import { getDefaultCache } from '../../src/cache.js'
|
|
28
|
+
|
|
29
|
+
// Import MDX modules
|
|
30
|
+
import {
|
|
31
|
+
parseMDX,
|
|
32
|
+
extractComponentSchemas,
|
|
33
|
+
createMDXPropsGenerator,
|
|
34
|
+
renderMDXWithProps,
|
|
35
|
+
streamMDXWithProps,
|
|
36
|
+
compileMDX,
|
|
37
|
+
clearMDXCache,
|
|
38
|
+
} from '../../src/mdx.js'
|
|
39
|
+
|
|
40
|
+
// Import hono/jsx modules
|
|
41
|
+
import {
|
|
42
|
+
collectHydrationData,
|
|
43
|
+
createHydrationContext,
|
|
44
|
+
serializeHydrationData,
|
|
45
|
+
HydrationProvider,
|
|
46
|
+
useHydration,
|
|
47
|
+
renderToReadableStream,
|
|
48
|
+
streamJSXResponse,
|
|
49
|
+
createStreamingRenderer,
|
|
50
|
+
createAIComponent,
|
|
51
|
+
withAIProps,
|
|
52
|
+
AIPropsProvider,
|
|
53
|
+
} from '../../src/hono-jsx.js'
|
|
54
|
+
|
|
55
|
+
// Import streaming utilities
|
|
56
|
+
import {
|
|
57
|
+
renderToReadableStream as optimizedRenderToReadableStream,
|
|
58
|
+
streamJSXResponse as optimizedStreamJSXResponse,
|
|
59
|
+
createStreamingRenderer as optimizedCreateStreamingRenderer,
|
|
60
|
+
streamMDXWithProps as optimizedStreamMDXWithProps,
|
|
61
|
+
} from '../../src/streaming.js'
|
|
62
|
+
|
|
63
|
+
// Import types
|
|
64
|
+
import type {
|
|
65
|
+
PropSchema,
|
|
66
|
+
GeneratePropsOptions,
|
|
67
|
+
GeneratePropsResult,
|
|
68
|
+
ValidationResult,
|
|
69
|
+
PropsCacheEntry,
|
|
70
|
+
AIPropsConfig,
|
|
71
|
+
} from '../../src/types.js'
|
|
72
|
+
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// Type definitions for service binding
|
|
75
|
+
// ============================================================================
|
|
76
|
+
|
|
77
|
+
interface PropsServiceRpc {
|
|
78
|
+
generate<T = Record<string, unknown>>(
|
|
79
|
+
options: GeneratePropsOptions
|
|
80
|
+
): Promise<GeneratePropsResult<T>>
|
|
81
|
+
getSync<T = Record<string, unknown>>(schema: PropSchema, context?: Record<string, unknown>): T
|
|
82
|
+
prefetch(requests: GeneratePropsOptions[]): Promise<void>
|
|
83
|
+
generateMany<T = Record<string, unknown>>(
|
|
84
|
+
requests: GeneratePropsOptions[]
|
|
85
|
+
): Promise<GeneratePropsResult<T>[]>
|
|
86
|
+
mergeWithGenerated<T extends Record<string, unknown>>(
|
|
87
|
+
schema: PropSchema,
|
|
88
|
+
partialProps: Partial<T>,
|
|
89
|
+
options?: Omit<GeneratePropsOptions, 'schema' | 'context'>
|
|
90
|
+
): Promise<T>
|
|
91
|
+
configure(config: Partial<AIPropsConfig>): void
|
|
92
|
+
getConfig(): AIPropsConfig
|
|
93
|
+
resetConfig(): void
|
|
94
|
+
getCached<T>(key: string): PropsCacheEntry<T> | undefined
|
|
95
|
+
setCached<T>(key: string, props: T): void
|
|
96
|
+
deleteCached(key: string): boolean
|
|
97
|
+
clearCache(): void
|
|
98
|
+
getCacheSize(): number
|
|
99
|
+
createCacheKey(schema: PropSchema, context?: Record<string, unknown>): string
|
|
100
|
+
configureCache(ttl: number): void
|
|
101
|
+
validate(props: Record<string, unknown>, schema: PropSchema): ValidationResult
|
|
102
|
+
hasRequired(props: Record<string, unknown>, required: string[]): boolean
|
|
103
|
+
getMissing(props: Record<string, unknown>, schema: PropSchema): string[]
|
|
104
|
+
isComplete(props: Record<string, unknown>, schema: PropSchema): boolean
|
|
105
|
+
sanitize<T extends Record<string, unknown>>(props: T, schema: PropSchema): Partial<T>
|
|
106
|
+
mergeDefaults<T extends Record<string, unknown>>(
|
|
107
|
+
props: Partial<T>,
|
|
108
|
+
defaults: Partial<T>,
|
|
109
|
+
schema: PropSchema
|
|
110
|
+
): Partial<T>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface TestEnv {
|
|
114
|
+
PROPS: {
|
|
115
|
+
getService(): PropsServiceRpc
|
|
116
|
+
}
|
|
117
|
+
AI?: unknown
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// 1. End-to-End Props Generation Tests
|
|
122
|
+
// ============================================================================
|
|
123
|
+
|
|
124
|
+
describe('E2E props generation', () => {
|
|
125
|
+
let service: PropsServiceRpc
|
|
126
|
+
|
|
127
|
+
beforeEach(async () => {
|
|
128
|
+
const testEnv = env as unknown as TestEnv
|
|
129
|
+
service = testEnv.PROPS.getService()
|
|
130
|
+
await service.clearCache()
|
|
131
|
+
await service.resetConfig()
|
|
132
|
+
clearMDXCache()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('generates props from schema via RPC', () => {
|
|
136
|
+
it('generates simple string props', async () => {
|
|
137
|
+
const schema = {
|
|
138
|
+
headline: 'A compelling headline for the page',
|
|
139
|
+
subheadline: 'A supporting subheadline that adds context',
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const result = await service.generate({ schema })
|
|
143
|
+
|
|
144
|
+
expect(result).toBeDefined()
|
|
145
|
+
expect(result.props).toBeDefined()
|
|
146
|
+
expect(result.props.headline).toBeDefined()
|
|
147
|
+
expect(typeof result.props.headline).toBe('string')
|
|
148
|
+
expect((result.props.headline as string).length).toBeGreaterThan(0)
|
|
149
|
+
expect(result.props.subheadline).toBeDefined()
|
|
150
|
+
expect(typeof result.props.subheadline).toBe('string')
|
|
151
|
+
expect(result.cached).toBe(false)
|
|
152
|
+
expect(result.metadata?.model).toBeDefined()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('generates props with context awareness', async () => {
|
|
156
|
+
const schema = {
|
|
157
|
+
productName: 'A catchy product name',
|
|
158
|
+
tagline: 'A memorable tagline for the product',
|
|
159
|
+
features: ['Key product features (3 items)'],
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const context = {
|
|
163
|
+
industry: 'AI and Machine Learning',
|
|
164
|
+
targetAudience: 'software developers',
|
|
165
|
+
pricePoint: 'enterprise',
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const result = await service.generate({ schema, context })
|
|
169
|
+
|
|
170
|
+
expect(result.props).toBeDefined()
|
|
171
|
+
expect(result.props.productName).toBeDefined()
|
|
172
|
+
expect(result.props.tagline).toBeDefined()
|
|
173
|
+
expect(result.props.features).toBeDefined()
|
|
174
|
+
// Context should influence generation
|
|
175
|
+
expect(typeof result.props.productName).toBe('string')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('generates complex nested schemas', async () => {
|
|
179
|
+
const schema = {
|
|
180
|
+
hero: {
|
|
181
|
+
title: 'Hero section title',
|
|
182
|
+
subtitle: 'Hero section subtitle',
|
|
183
|
+
cta: {
|
|
184
|
+
text: 'Call to action button text',
|
|
185
|
+
href: 'Link URL',
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
features: [
|
|
189
|
+
{
|
|
190
|
+
icon: 'Icon name',
|
|
191
|
+
title: 'Feature title',
|
|
192
|
+
description: 'Feature description',
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
testimonial: {
|
|
196
|
+
quote: 'Customer testimonial quote',
|
|
197
|
+
author: 'Author name',
|
|
198
|
+
role: 'Author job title',
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const result = await service.generate({ schema })
|
|
203
|
+
|
|
204
|
+
expect(result.props).toBeDefined()
|
|
205
|
+
expect(result.props.hero).toBeDefined()
|
|
206
|
+
expect((result.props.hero as Record<string, unknown>).title).toBeDefined()
|
|
207
|
+
expect((result.props.hero as Record<string, unknown>).cta).toBeDefined()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('validates generated props against schema', async () => {
|
|
211
|
+
const schema = {
|
|
212
|
+
title: 'Page title',
|
|
213
|
+
description: 'Page description',
|
|
214
|
+
published: 'Is published (boolean)',
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const result = await service.generate({ schema })
|
|
218
|
+
const validation = await service.validate(result.props, schema)
|
|
219
|
+
|
|
220
|
+
expect(validation).toBeDefined()
|
|
221
|
+
expect(validation.valid).toBe(true)
|
|
222
|
+
expect(validation.errors).toHaveLength(0)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('uses AI Gateway with caching', () => {
|
|
227
|
+
it('returns cached result on second request', async () => {
|
|
228
|
+
const schema = { title: 'Cached title test' }
|
|
229
|
+
const context = { testId: `cache-${Date.now()}` }
|
|
230
|
+
|
|
231
|
+
// First call - should not be cached
|
|
232
|
+
const result1 = await service.generate({ schema, context })
|
|
233
|
+
expect(result1.cached).toBe(false)
|
|
234
|
+
|
|
235
|
+
// Second call - should be cached
|
|
236
|
+
const result2 = await service.generate({ schema, context })
|
|
237
|
+
expect(result2.cached).toBe(true)
|
|
238
|
+
expect(result2.props.title).toBe(result1.props.title)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('invalidates cache when schema changes', async () => {
|
|
242
|
+
const context = { testId: `schema-change-${Date.now()}` }
|
|
243
|
+
|
|
244
|
+
const result1 = await service.generate({
|
|
245
|
+
schema: { title: 'First schema' },
|
|
246
|
+
context,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
const result2 = await service.generate({
|
|
250
|
+
schema: { title: 'Different schema description' },
|
|
251
|
+
context,
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// Different schema should not hit cache
|
|
255
|
+
expect(result2.cached).toBe(false)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('invalidates cache when context changes', async () => {
|
|
259
|
+
const schema = { title: 'Context test title' }
|
|
260
|
+
|
|
261
|
+
const result1 = await service.generate({
|
|
262
|
+
schema,
|
|
263
|
+
context: { topic: 'Technology' },
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
const result2 = await service.generate({
|
|
267
|
+
schema,
|
|
268
|
+
context: { topic: 'Healthcare' },
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
// Different context should not hit cache
|
|
272
|
+
expect(result2.cached).toBe(false)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('respects cache TTL configuration', async () => {
|
|
276
|
+
// Configure very short TTL (1ms)
|
|
277
|
+
await service.configureCache(1)
|
|
278
|
+
|
|
279
|
+
const schema = { title: 'TTL test' }
|
|
280
|
+
const context = { id: `ttl-${Date.now()}` }
|
|
281
|
+
|
|
282
|
+
const result1 = await service.generate({ schema, context })
|
|
283
|
+
|
|
284
|
+
// Wait for TTL to expire
|
|
285
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
286
|
+
|
|
287
|
+
const result2 = await service.generate({ schema, context })
|
|
288
|
+
|
|
289
|
+
// Should not be cached after TTL expiry
|
|
290
|
+
expect(result2.cached).toBe(false)
|
|
291
|
+
|
|
292
|
+
// Reset to default
|
|
293
|
+
await service.configureCache(5 * 60 * 1000)
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
describe('handles complex nested schemas', () => {
|
|
298
|
+
it('generates deeply nested object structures', async () => {
|
|
299
|
+
const schema = {
|
|
300
|
+
page: {
|
|
301
|
+
meta: {
|
|
302
|
+
seo: {
|
|
303
|
+
title: 'SEO title',
|
|
304
|
+
description: 'SEO description',
|
|
305
|
+
keywords: ['SEO keywords'],
|
|
306
|
+
},
|
|
307
|
+
og: {
|
|
308
|
+
title: 'Open Graph title',
|
|
309
|
+
description: 'Open Graph description',
|
|
310
|
+
image: 'OG image URL',
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
content: {
|
|
314
|
+
sections: [
|
|
315
|
+
{
|
|
316
|
+
id: 'Section ID',
|
|
317
|
+
heading: 'Section heading',
|
|
318
|
+
body: 'Section body content',
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const result = await service.generate({ schema })
|
|
326
|
+
|
|
327
|
+
expect(result.props.page).toBeDefined()
|
|
328
|
+
const page = result.props.page as Record<string, unknown>
|
|
329
|
+
expect(page.meta).toBeDefined()
|
|
330
|
+
expect(page.content).toBeDefined()
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('generates arrays with consistent item structure', async () => {
|
|
334
|
+
const schema = {
|
|
335
|
+
items: [
|
|
336
|
+
{
|
|
337
|
+
id: 'Unique item ID',
|
|
338
|
+
name: 'Item name',
|
|
339
|
+
price: 'Price (number)',
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const result = await service.generate({ schema })
|
|
345
|
+
|
|
346
|
+
expect(result.props.items).toBeDefined()
|
|
347
|
+
expect(Array.isArray(result.props.items)).toBe(true)
|
|
348
|
+
const items = result.props.items as Array<Record<string, unknown>>
|
|
349
|
+
expect(items.length).toBeGreaterThan(0)
|
|
350
|
+
expect(items[0]?.id).toBeDefined()
|
|
351
|
+
expect(items[0]?.name).toBeDefined()
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('handles mixed type schemas', async () => {
|
|
355
|
+
const schema = {
|
|
356
|
+
count: 'A positive integer',
|
|
357
|
+
enabled: 'A boolean flag',
|
|
358
|
+
rating: 'A decimal rating (1-5)',
|
|
359
|
+
tags: ['String tags'],
|
|
360
|
+
metadata: {
|
|
361
|
+
createdAt: 'ISO date string',
|
|
362
|
+
version: 'Semantic version string',
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const result = await service.generate({ schema })
|
|
367
|
+
|
|
368
|
+
expect(result.props).toBeDefined()
|
|
369
|
+
// Props should be generated for all fields
|
|
370
|
+
expect(Object.keys(result.props).length).toBe(5)
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
// ============================================================================
|
|
376
|
+
// 2. Full MDX Pipeline Tests
|
|
377
|
+
// ============================================================================
|
|
378
|
+
|
|
379
|
+
describe('Full MDX pipeline', () => {
|
|
380
|
+
beforeEach(() => {
|
|
381
|
+
clearMDXCache()
|
|
382
|
+
const cache = getDefaultCache()
|
|
383
|
+
cache.clear()
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
describe('parses MDX, generates props, renders output', () => {
|
|
387
|
+
it('completes full pipeline from MDX string to rendered output', async () => {
|
|
388
|
+
const mdxContent = `---
|
|
389
|
+
topic: Cloud Computing
|
|
390
|
+
audience: developers
|
|
391
|
+
---
|
|
392
|
+
|
|
393
|
+
# Welcome to {topic}
|
|
394
|
+
|
|
395
|
+
<Hero />
|
|
396
|
+
|
|
397
|
+
<FeatureCard />
|
|
398
|
+
|
|
399
|
+
<CallToAction />`
|
|
400
|
+
|
|
401
|
+
// Step 1: Parse MDX
|
|
402
|
+
const parsed = parseMDX(mdxContent)
|
|
403
|
+
|
|
404
|
+
expect(parsed.frontmatter.topic).toBe('Cloud Computing')
|
|
405
|
+
expect(parsed.frontmatter.audience).toBe('developers')
|
|
406
|
+
expect(parsed.components).toContain('Hero')
|
|
407
|
+
expect(parsed.components).toContain('FeatureCard')
|
|
408
|
+
expect(parsed.components).toContain('CallToAction')
|
|
409
|
+
|
|
410
|
+
// Step 2: Create generator with schemas
|
|
411
|
+
const generator = createMDXPropsGenerator({
|
|
412
|
+
schemas: {
|
|
413
|
+
Hero: {
|
|
414
|
+
title: 'Hero section title relevant to the topic',
|
|
415
|
+
subtitle: 'Supporting text for the hero',
|
|
416
|
+
},
|
|
417
|
+
FeatureCard: {
|
|
418
|
+
heading: 'Feature heading',
|
|
419
|
+
description: 'Feature description',
|
|
420
|
+
icon: 'Icon name',
|
|
421
|
+
},
|
|
422
|
+
CallToAction: {
|
|
423
|
+
buttonText: 'CTA button text',
|
|
424
|
+
href: 'Link URL',
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
// Step 3: Generate props
|
|
430
|
+
const props = await generator.generate(mdxContent)
|
|
431
|
+
|
|
432
|
+
expect(props.Hero).toBeDefined()
|
|
433
|
+
expect(props.Hero.title).toBeDefined()
|
|
434
|
+
expect(typeof props.Hero.title).toBe('string')
|
|
435
|
+
expect(props.FeatureCard).toBeDefined()
|
|
436
|
+
expect(props.FeatureCard.heading).toBeDefined()
|
|
437
|
+
expect(props.CallToAction).toBeDefined()
|
|
438
|
+
expect(props.CallToAction.buttonText).toBeDefined()
|
|
439
|
+
|
|
440
|
+
// Step 4: Render with props
|
|
441
|
+
const rendered = await renderMDXWithProps(mdxContent, props)
|
|
442
|
+
|
|
443
|
+
expect(rendered).toBeDefined()
|
|
444
|
+
expect(typeof rendered).toBe('string')
|
|
445
|
+
expect(rendered).toContain('Cloud Computing')
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('preserves explicit props while generating missing ones', async () => {
|
|
449
|
+
const mdxContent = `<ProductCard
|
|
450
|
+
name="AI Props"
|
|
451
|
+
price={99}
|
|
452
|
+
/>
|
|
453
|
+
|
|
454
|
+
<ReviewCard />`
|
|
455
|
+
|
|
456
|
+
const generator = createMDXPropsGenerator({
|
|
457
|
+
schemas: {
|
|
458
|
+
ProductCard: {
|
|
459
|
+
name: 'Product name',
|
|
460
|
+
price: 'Price (number)',
|
|
461
|
+
description: 'Product description',
|
|
462
|
+
},
|
|
463
|
+
ReviewCard: {
|
|
464
|
+
author: 'Reviewer name',
|
|
465
|
+
rating: 'Star rating (1-5)',
|
|
466
|
+
text: 'Review text',
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
const props = await generator.generate(mdxContent)
|
|
472
|
+
|
|
473
|
+
// Explicit props should be preserved
|
|
474
|
+
expect(props.ProductCard.name).toBe('AI Props')
|
|
475
|
+
expect(props.ProductCard.price).toBe(99)
|
|
476
|
+
// Missing prop should be generated
|
|
477
|
+
expect(props.ProductCard.description).toBeDefined()
|
|
478
|
+
|
|
479
|
+
// Component without explicit props should have all generated
|
|
480
|
+
expect(props.ReviewCard).toBeDefined()
|
|
481
|
+
expect(props.ReviewCard.author).toBeDefined()
|
|
482
|
+
expect(props.ReviewCard.rating).toBeDefined()
|
|
483
|
+
expect(props.ReviewCard.text).toBeDefined()
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('uses frontmatter context for generation', async () => {
|
|
487
|
+
const mdxContent = `---
|
|
488
|
+
product: AI Dashboard
|
|
489
|
+
industry: Healthcare
|
|
490
|
+
targetUsers: hospital administrators
|
|
491
|
+
---
|
|
492
|
+
|
|
493
|
+
<HeroBanner />
|
|
494
|
+
<ValueProposition />`
|
|
495
|
+
|
|
496
|
+
const generator = createMDXPropsGenerator({
|
|
497
|
+
schemas: {
|
|
498
|
+
HeroBanner: {
|
|
499
|
+
headline: 'Headline matching the product and industry',
|
|
500
|
+
subheadline: 'Supporting text for target users',
|
|
501
|
+
},
|
|
502
|
+
ValueProposition: {
|
|
503
|
+
title: 'Value prop title',
|
|
504
|
+
points: ['Key value points (3 items)'],
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
const props = await generator.generate(mdxContent)
|
|
510
|
+
|
|
511
|
+
expect(props.HeroBanner).toBeDefined()
|
|
512
|
+
expect(props.HeroBanner.headline).toBeDefined()
|
|
513
|
+
expect(props.ValueProposition).toBeDefined()
|
|
514
|
+
// Generated content should be contextually relevant
|
|
515
|
+
})
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
describe('streams MDX render with hydration data', () => {
|
|
519
|
+
it('returns a ReadableStream for MDX content', async () => {
|
|
520
|
+
const mdxContent = `# Streaming Test
|
|
521
|
+
|
|
522
|
+
<Card title="Test Card" description="Test description" />`
|
|
523
|
+
|
|
524
|
+
const stream = await streamMDXWithProps(mdxContent, {
|
|
525
|
+
Card: { title: 'Test Card', description: 'Test description' },
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
expect(stream).toBeInstanceOf(ReadableStream)
|
|
529
|
+
|
|
530
|
+
const reader = stream.getReader()
|
|
531
|
+
const decoder = new TextDecoder()
|
|
532
|
+
let content = ''
|
|
533
|
+
|
|
534
|
+
while (true) {
|
|
535
|
+
const { done, value } = await reader.read()
|
|
536
|
+
if (done) break
|
|
537
|
+
content += decoder.decode(value, { stream: true })
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
expect(content.length).toBeGreaterThan(0)
|
|
541
|
+
expect(content).toContain('Test Card')
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
it('streams large MDX content in chunks', async () => {
|
|
545
|
+
// Create MDX with many components
|
|
546
|
+
const components = Array.from(
|
|
547
|
+
{ length: 20 },
|
|
548
|
+
(_, i) => `<Section${i} title="Section ${i}" />`
|
|
549
|
+
).join('\n\n')
|
|
550
|
+
|
|
551
|
+
const mdxContent = `# Large Document\n\n${components}`
|
|
552
|
+
|
|
553
|
+
const props: Record<string, Record<string, unknown>> = {}
|
|
554
|
+
for (let i = 0; i < 20; i++) {
|
|
555
|
+
props[`Section${i}`] = { title: `Section ${i}` }
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const stream = await optimizedStreamMDXWithProps(mdxContent, props, {
|
|
559
|
+
chunkSize: 256, // Small chunks to ensure multiple
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
const reader = stream.getReader()
|
|
563
|
+
const chunks: Uint8Array[] = []
|
|
564
|
+
|
|
565
|
+
while (true) {
|
|
566
|
+
const { done, value } = await reader.read()
|
|
567
|
+
if (done) break
|
|
568
|
+
chunks.push(value)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
expect(chunks.length).toBeGreaterThan(1)
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
it('provides progress callbacks during streaming', async () => {
|
|
575
|
+
const mdxContent = `<Widget data="test" />`
|
|
576
|
+
|
|
577
|
+
const progressEvents: Array<{
|
|
578
|
+
phase: string
|
|
579
|
+
bytesProcessed: number
|
|
580
|
+
chunksProcessed: number
|
|
581
|
+
}> = []
|
|
582
|
+
|
|
583
|
+
const stream = await optimizedStreamMDXWithProps(
|
|
584
|
+
mdxContent,
|
|
585
|
+
{ Widget: { data: 'test' } },
|
|
586
|
+
{
|
|
587
|
+
onProgress: (progress) => {
|
|
588
|
+
progressEvents.push({
|
|
589
|
+
phase: progress.phase,
|
|
590
|
+
bytesProcessed: progress.bytesProcessed,
|
|
591
|
+
chunksProcessed: progress.chunksProcessed,
|
|
592
|
+
})
|
|
593
|
+
},
|
|
594
|
+
}
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
// Consume stream
|
|
598
|
+
const reader = stream.getReader()
|
|
599
|
+
while (true) {
|
|
600
|
+
const { done } = await reader.read()
|
|
601
|
+
if (done) break
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
expect(progressEvents.length).toBeGreaterThan(0)
|
|
605
|
+
expect(progressEvents.some((e) => e.phase === 'starting')).toBe(true)
|
|
606
|
+
expect(progressEvents.some((e) => e.phase === 'complete')).toBe(true)
|
|
607
|
+
})
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
describe('handles MDX with multiple components', () => {
|
|
611
|
+
it('processes page with 10+ components', async () => {
|
|
612
|
+
const mdxContent = `---
|
|
613
|
+
title: Product Page
|
|
614
|
+
---
|
|
615
|
+
|
|
616
|
+
<Navigation />
|
|
617
|
+
<Hero />
|
|
618
|
+
<Features />
|
|
619
|
+
<Pricing />
|
|
620
|
+
<Testimonials />
|
|
621
|
+
<FAQ />
|
|
622
|
+
<Contact />
|
|
623
|
+
<Newsletter />
|
|
624
|
+
<SocialProof />
|
|
625
|
+
<Footer />`
|
|
626
|
+
|
|
627
|
+
const parsed = parseMDX(mdxContent)
|
|
628
|
+
|
|
629
|
+
expect(parsed.components.length).toBe(10)
|
|
630
|
+
expect(parsed.components).toContain('Navigation')
|
|
631
|
+
expect(parsed.components).toContain('Footer')
|
|
632
|
+
|
|
633
|
+
const generator = createMDXPropsGenerator({
|
|
634
|
+
schemas: {
|
|
635
|
+
Navigation: { links: ['Navigation links'] },
|
|
636
|
+
Hero: { title: 'Hero title', subtitle: 'Hero subtitle' },
|
|
637
|
+
Features: { items: ['Feature items'] },
|
|
638
|
+
Pricing: { plans: ['Pricing plans'] },
|
|
639
|
+
Testimonials: { quotes: ['Customer quotes'] },
|
|
640
|
+
FAQ: { questions: ['FAQ items'] },
|
|
641
|
+
Contact: { email: 'Contact email', phone: 'Contact phone' },
|
|
642
|
+
Newsletter: { heading: 'Newsletter heading', placeholder: 'Email placeholder' },
|
|
643
|
+
SocialProof: { logos: ['Company logos'] },
|
|
644
|
+
Footer: { copyright: 'Copyright text', links: ['Footer links'] },
|
|
645
|
+
},
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
const props = await generator.generate(mdxContent)
|
|
649
|
+
|
|
650
|
+
// All components should have generated props
|
|
651
|
+
expect(Object.keys(props).length).toBe(10)
|
|
652
|
+
for (const component of parsed.components) {
|
|
653
|
+
expect(props[component]).toBeDefined()
|
|
654
|
+
}
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
it('handles components with same schema but different instances', async () => {
|
|
658
|
+
const mdxContent = `<Card title="First" />
|
|
659
|
+
<Card title="Second" />
|
|
660
|
+
<Card title="Third" />`
|
|
661
|
+
|
|
662
|
+
const parsed = parseMDX(mdxContent)
|
|
663
|
+
|
|
664
|
+
// Should identify Card component once
|
|
665
|
+
expect(parsed.components.filter((c) => c === 'Card').length).toBe(1)
|
|
666
|
+
|
|
667
|
+
const schemas = extractComponentSchemas(mdxContent)
|
|
668
|
+
expect(schemas.Card).toBeDefined()
|
|
669
|
+
expect(Object.keys(schemas.Card)).toContain('title')
|
|
670
|
+
})
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
describe('works through service binding', () => {
|
|
674
|
+
it('generates props via RPC and renders MDX', async () => {
|
|
675
|
+
const testEnv = env as unknown as TestEnv
|
|
676
|
+
const service = testEnv.PROPS.getService()
|
|
677
|
+
|
|
678
|
+
const mdxContent = `<Hero />
|
|
679
|
+
<Card />`
|
|
680
|
+
|
|
681
|
+
// Use service to generate props for each component
|
|
682
|
+
const heroResult = await service.generate({
|
|
683
|
+
schema: { title: 'Hero title', subtitle: 'Hero subtitle' },
|
|
684
|
+
context: { component: 'Hero' },
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
const cardResult = await service.generate({
|
|
688
|
+
schema: { heading: 'Card heading', body: 'Card body' },
|
|
689
|
+
context: { component: 'Card' },
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
expect(heroResult.props).toBeDefined()
|
|
693
|
+
expect(cardResult.props).toBeDefined()
|
|
694
|
+
|
|
695
|
+
// Render with generated props
|
|
696
|
+
const rendered = await renderMDXWithProps(mdxContent, {
|
|
697
|
+
Hero: heroResult.props as Record<string, unknown>,
|
|
698
|
+
Card: cardResult.props as Record<string, unknown>,
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
expect(rendered).toBeDefined()
|
|
702
|
+
expect(typeof rendered).toBe('string')
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
it('validates MDX component props via service', async () => {
|
|
706
|
+
const testEnv = env as unknown as TestEnv
|
|
707
|
+
const service = testEnv.PROPS.getService()
|
|
708
|
+
|
|
709
|
+
const schema = {
|
|
710
|
+
title: 'Component title',
|
|
711
|
+
description: 'Component description',
|
|
712
|
+
items: ['List items'],
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const generatedProps = await service.generate({ schema })
|
|
716
|
+
const validation = await service.validate(generatedProps.props, schema)
|
|
717
|
+
|
|
718
|
+
expect(validation.valid).toBe(true)
|
|
719
|
+
expect(validation.errors).toHaveLength(0)
|
|
720
|
+
})
|
|
721
|
+
})
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
// ============================================================================
|
|
725
|
+
// 3. hono/jsx Complete Flow Tests
|
|
726
|
+
// ============================================================================
|
|
727
|
+
|
|
728
|
+
describe('hono/jsx complete flow', () => {
|
|
729
|
+
beforeEach(() => {
|
|
730
|
+
const cache = getDefaultCache()
|
|
731
|
+
cache.clear()
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
describe('renders JSX with AI props through streaming', () => {
|
|
735
|
+
it('streams simple component with AI-generated props', async () => {
|
|
736
|
+
const AIButton = createAIComponent({
|
|
737
|
+
name: 'Button',
|
|
738
|
+
schema: {
|
|
739
|
+
label: 'Button label text',
|
|
740
|
+
color: 'Button color',
|
|
741
|
+
},
|
|
742
|
+
render: ({ label, color }) => `<button style="background:${color}">${label}</button>`,
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
const stream = await renderToReadableStream(AIButton, {})
|
|
746
|
+
|
|
747
|
+
expect(stream).toBeInstanceOf(ReadableStream)
|
|
748
|
+
|
|
749
|
+
const reader = stream.getReader()
|
|
750
|
+
const decoder = new TextDecoder()
|
|
751
|
+
let content = ''
|
|
752
|
+
|
|
753
|
+
while (true) {
|
|
754
|
+
const { done, value } = await reader.read()
|
|
755
|
+
if (done) break
|
|
756
|
+
content += decoder.decode(value, { stream: true })
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
expect(content).toContain('<button')
|
|
760
|
+
expect(content).toContain('</button>')
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
it('generates props only for missing fields', async () => {
|
|
764
|
+
const AICard = createAIComponent({
|
|
765
|
+
name: 'Card',
|
|
766
|
+
schema: {
|
|
767
|
+
title: 'Card title',
|
|
768
|
+
body: 'Card body text',
|
|
769
|
+
footer: 'Card footer',
|
|
770
|
+
},
|
|
771
|
+
render: ({ title, body, footer }) =>
|
|
772
|
+
`<div class="card"><h2>${title}</h2><p>${body}</p><footer>${footer}</footer></div>`,
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
// Provide partial props
|
|
776
|
+
const result = await AICard({
|
|
777
|
+
title: 'Explicit Title',
|
|
778
|
+
// body and footer should be generated
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
expect(result).toContain('Explicit Title')
|
|
782
|
+
// body and footer should be AI-generated (non-empty)
|
|
783
|
+
expect(result).toContain('<p>')
|
|
784
|
+
expect(result).toContain('<footer>')
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
it('uses context for generation', async () => {
|
|
788
|
+
const AIHero = createAIComponent({
|
|
789
|
+
name: 'Hero',
|
|
790
|
+
schema: {
|
|
791
|
+
headline: 'Headline matching the topic',
|
|
792
|
+
subheadline: 'Supporting text',
|
|
793
|
+
},
|
|
794
|
+
render: ({ headline, subheadline }) =>
|
|
795
|
+
`<section><h1>${headline}</h1><p>${subheadline}</p></section>`,
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
const result = await AIHero({
|
|
799
|
+
context: {
|
|
800
|
+
topic: 'Developer Tools',
|
|
801
|
+
audience: 'software engineers',
|
|
802
|
+
},
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
expect(result).toContain('<section>')
|
|
806
|
+
expect(result).toContain('<h1>')
|
|
807
|
+
expect(result).toContain('</h1>')
|
|
808
|
+
})
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
describe('includes hydration script in output', () => {
|
|
812
|
+
it('adds hydration data when includeHydration is true', async () => {
|
|
813
|
+
const Component = ({ title }: { title: string }) => `<h1>${title}</h1>`
|
|
814
|
+
|
|
815
|
+
const stream = await renderToReadableStream(
|
|
816
|
+
Component,
|
|
817
|
+
{ title: 'Test' },
|
|
818
|
+
{ includeHydration: true }
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
const reader = stream.getReader()
|
|
822
|
+
const decoder = new TextDecoder()
|
|
823
|
+
let content = ''
|
|
824
|
+
|
|
825
|
+
while (true) {
|
|
826
|
+
const { done, value } = await reader.read()
|
|
827
|
+
if (done) break
|
|
828
|
+
content += decoder.decode(value, { stream: true })
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
expect(content).toContain('__HYDRATION_DATA__')
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
it('serializes component props for hydration', async () => {
|
|
835
|
+
const ctx = createHydrationContext()
|
|
836
|
+
|
|
837
|
+
ctx.register('Header', { title: 'Test Title', sticky: true })
|
|
838
|
+
ctx.register('Footer', { year: 2026, links: ['home', 'about'] })
|
|
839
|
+
|
|
840
|
+
const data = ctx.getData()
|
|
841
|
+
const serialized = serializeHydrationData(data)
|
|
842
|
+
|
|
843
|
+
expect(typeof serialized).toBe('string')
|
|
844
|
+
const parsed = JSON.parse(serialized)
|
|
845
|
+
expect(parsed.components).toBeDefined()
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
it('escapes script tags in hydration data', async () => {
|
|
849
|
+
const ctx = createHydrationContext()
|
|
850
|
+
|
|
851
|
+
ctx.register('Component', {
|
|
852
|
+
content: '<script>alert("xss")</script>',
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
const data = ctx.getData()
|
|
856
|
+
const serialized = serializeHydrationData(data)
|
|
857
|
+
|
|
858
|
+
// Should not contain raw script tags
|
|
859
|
+
expect(serialized).not.toContain('<script>')
|
|
860
|
+
expect(serialized).not.toContain('</script>')
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
it('uses shell wrapper with hydration injection', async () => {
|
|
864
|
+
const renderer = createStreamingRenderer({
|
|
865
|
+
shell: (content, hydration) =>
|
|
866
|
+
`<!DOCTYPE html><html><body>${content}<script>${hydration}</script></body></html>`,
|
|
867
|
+
includeHydration: true,
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
const Component = ({ message }: { message: string }) => `<div>${message}</div>`
|
|
871
|
+
|
|
872
|
+
const stream = await renderer.render(Component, { message: 'Hello' })
|
|
873
|
+
|
|
874
|
+
const reader = stream.getReader()
|
|
875
|
+
const decoder = new TextDecoder()
|
|
876
|
+
let content = ''
|
|
877
|
+
|
|
878
|
+
while (true) {
|
|
879
|
+
const { done, value } = await reader.read()
|
|
880
|
+
if (done) break
|
|
881
|
+
content += decoder.decode(value, { stream: true })
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
expect(content).toContain('<!DOCTYPE html>')
|
|
885
|
+
expect(content).toContain('__HYDRATION_DATA__')
|
|
886
|
+
expect(content).toContain('Hello')
|
|
887
|
+
})
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
describe('works with complex component trees', () => {
|
|
891
|
+
it('renders nested component structure', async () => {
|
|
892
|
+
const Card = ({ title, children }: { title: string; children?: string }) =>
|
|
893
|
+
`<div class="card"><h2>${title}</h2>${children || ''}</div>`
|
|
894
|
+
|
|
895
|
+
const Button = ({ label }: { label: string }) => `<button>${label}</button>`
|
|
896
|
+
|
|
897
|
+
const Layout = ({ header, content }: { header: string; content: string }) =>
|
|
898
|
+
`<div class="layout"><header>${header}</header><main>${content}</main></div>`
|
|
899
|
+
|
|
900
|
+
const App = () => {
|
|
901
|
+
const card = Card({ title: 'Card Title', children: Button({ label: 'Click' }) })
|
|
902
|
+
return Layout({ header: 'My App', content: card })
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const stream = await renderToReadableStream(App, {})
|
|
906
|
+
|
|
907
|
+
const reader = stream.getReader()
|
|
908
|
+
const decoder = new TextDecoder()
|
|
909
|
+
let content = ''
|
|
910
|
+
|
|
911
|
+
while (true) {
|
|
912
|
+
const { done, value } = await reader.read()
|
|
913
|
+
if (done) break
|
|
914
|
+
content += decoder.decode(value, { stream: true })
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
expect(content).toContain('class="layout"')
|
|
918
|
+
expect(content).toContain('class="card"')
|
|
919
|
+
expect(content).toContain('<button>')
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
it('handles multiple AI components in tree', async () => {
|
|
923
|
+
const AIHeading = createAIComponent({
|
|
924
|
+
name: 'Heading',
|
|
925
|
+
schema: { text: 'Heading text' },
|
|
926
|
+
render: ({ text }) => `<h1>${text}</h1>`,
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
const AIParagraph = createAIComponent({
|
|
930
|
+
name: 'Paragraph',
|
|
931
|
+
schema: { text: 'Paragraph text' },
|
|
932
|
+
render: ({ text }) => `<p>${text}</p>`,
|
|
933
|
+
})
|
|
934
|
+
|
|
935
|
+
const Page = async () => {
|
|
936
|
+
const heading = await AIHeading({})
|
|
937
|
+
const para = await AIParagraph({})
|
|
938
|
+
return `<article>${heading}${para}</article>`
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const stream = await renderToReadableStream(Page, {})
|
|
942
|
+
|
|
943
|
+
const reader = stream.getReader()
|
|
944
|
+
const decoder = new TextDecoder()
|
|
945
|
+
let content = ''
|
|
946
|
+
|
|
947
|
+
while (true) {
|
|
948
|
+
const { done, value } = await reader.read()
|
|
949
|
+
if (done) break
|
|
950
|
+
content += decoder.decode(value, { stream: true })
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
expect(content).toContain('<article>')
|
|
954
|
+
expect(content).toContain('<h1>')
|
|
955
|
+
expect(content).toContain('<p>')
|
|
956
|
+
})
|
|
957
|
+
})
|
|
958
|
+
|
|
959
|
+
describe('handles async components', () => {
|
|
960
|
+
it('awaits async component render', async () => {
|
|
961
|
+
const AsyncComponent = async ({ delay }: { delay: number }) => {
|
|
962
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
963
|
+
return `<div>Loaded after ${delay}ms</div>`
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const stream = await renderToReadableStream(AsyncComponent, { delay: 10 })
|
|
967
|
+
|
|
968
|
+
const reader = stream.getReader()
|
|
969
|
+
const decoder = new TextDecoder()
|
|
970
|
+
let content = ''
|
|
971
|
+
|
|
972
|
+
while (true) {
|
|
973
|
+
const { done, value } = await reader.read()
|
|
974
|
+
if (done) break
|
|
975
|
+
content += decoder.decode(value, { stream: true })
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
expect(content).toContain('Loaded after 10ms')
|
|
979
|
+
})
|
|
980
|
+
|
|
981
|
+
it('handles async AI components', async () => {
|
|
982
|
+
const AsyncAIComponent = createAIComponent({
|
|
983
|
+
name: 'AsyncWidget',
|
|
984
|
+
schema: {
|
|
985
|
+
data: 'Widget data',
|
|
986
|
+
status: 'Widget status',
|
|
987
|
+
},
|
|
988
|
+
render: async ({ data, status }) => {
|
|
989
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
990
|
+
return `<div class="widget" data-status="${status}">${data}</div>`
|
|
991
|
+
},
|
|
992
|
+
})
|
|
993
|
+
|
|
994
|
+
const result = await AsyncAIComponent({})
|
|
995
|
+
|
|
996
|
+
expect(result).toContain('class="widget"')
|
|
997
|
+
expect(result).toContain('data-status=')
|
|
998
|
+
})
|
|
999
|
+
|
|
1000
|
+
it('handles errors in async components', async () => {
|
|
1001
|
+
const FailingComponent = async () => {
|
|
1002
|
+
throw new Error('Component failed')
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const stream = await renderToReadableStream(
|
|
1006
|
+
FailingComponent,
|
|
1007
|
+
{},
|
|
1008
|
+
{
|
|
1009
|
+
onError: (error) => `<div class="error">${error.message}</div>`,
|
|
1010
|
+
}
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
const reader = stream.getReader()
|
|
1014
|
+
const decoder = new TextDecoder()
|
|
1015
|
+
let content = ''
|
|
1016
|
+
|
|
1017
|
+
while (true) {
|
|
1018
|
+
const { done, value } = await reader.read()
|
|
1019
|
+
if (done) break
|
|
1020
|
+
content += decoder.decode(value, { stream: true })
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
expect(content).toContain('Component failed')
|
|
1024
|
+
})
|
|
1025
|
+
})
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
// ============================================================================
|
|
1029
|
+
// 4. Cross-Worker Integration Tests
|
|
1030
|
+
// ============================================================================
|
|
1031
|
+
|
|
1032
|
+
describe('cross-worker integration', () => {
|
|
1033
|
+
let service: PropsServiceRpc
|
|
1034
|
+
|
|
1035
|
+
beforeEach(async () => {
|
|
1036
|
+
const testEnv = env as unknown as TestEnv
|
|
1037
|
+
service = testEnv.PROPS.getService()
|
|
1038
|
+
await service.clearCache()
|
|
1039
|
+
await service.resetConfig()
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
describe('works from consumer worker via binding', () => {
|
|
1043
|
+
it('generates props through service binding', async () => {
|
|
1044
|
+
const schema = {
|
|
1045
|
+
message: 'A friendly greeting message',
|
|
1046
|
+
timestamp: 'Current timestamp string',
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const result = await service.generate({ schema })
|
|
1050
|
+
|
|
1051
|
+
expect(result).toBeDefined()
|
|
1052
|
+
expect(result.props.message).toBeDefined()
|
|
1053
|
+
expect(result.props.timestamp).toBeDefined()
|
|
1054
|
+
})
|
|
1055
|
+
|
|
1056
|
+
it('uses generateMany for batch operations', async () => {
|
|
1057
|
+
const requests: GeneratePropsOptions[] = [
|
|
1058
|
+
{ schema: { title: 'Card 1 title' }, context: { index: 0 } },
|
|
1059
|
+
{ schema: { title: 'Card 2 title' }, context: { index: 1 } },
|
|
1060
|
+
{ schema: { title: 'Card 3 title' }, context: { index: 2 } },
|
|
1061
|
+
]
|
|
1062
|
+
|
|
1063
|
+
const results = await service.generateMany(requests)
|
|
1064
|
+
|
|
1065
|
+
expect(results).toHaveLength(3)
|
|
1066
|
+
for (const result of results) {
|
|
1067
|
+
expect(result.props).toBeDefined()
|
|
1068
|
+
expect(result.props.title).toBeDefined()
|
|
1069
|
+
}
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1072
|
+
it('merges partial props through binding', async () => {
|
|
1073
|
+
const schema = {
|
|
1074
|
+
name: 'User name',
|
|
1075
|
+
email: 'User email',
|
|
1076
|
+
bio: 'User biography',
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const partial = { name: 'John Doe', email: 'john@example.com' }
|
|
1080
|
+
|
|
1081
|
+
const merged = await service.mergeWithGenerated(schema, partial)
|
|
1082
|
+
|
|
1083
|
+
expect(merged.name).toBe('John Doe')
|
|
1084
|
+
expect(merged.email).toBe('john@example.com')
|
|
1085
|
+
expect(merged.bio).toBeDefined() // Generated
|
|
1086
|
+
})
|
|
1087
|
+
})
|
|
1088
|
+
|
|
1089
|
+
describe('handles concurrent requests', () => {
|
|
1090
|
+
it('processes multiple simultaneous requests', async () => {
|
|
1091
|
+
const promises = Array.from({ length: 5 }, (_, i) =>
|
|
1092
|
+
service.generate({
|
|
1093
|
+
schema: { value: `Value for request ${i}` },
|
|
1094
|
+
context: { requestId: i, timestamp: Date.now() },
|
|
1095
|
+
})
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
const results = await Promise.all(promises)
|
|
1099
|
+
|
|
1100
|
+
expect(results).toHaveLength(5)
|
|
1101
|
+
for (const result of results) {
|
|
1102
|
+
expect(result.props).toBeDefined()
|
|
1103
|
+
expect(result.props.value).toBeDefined()
|
|
1104
|
+
}
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
it('maintains data integrity under concurrent access', async () => {
|
|
1108
|
+
const baseKey = `concurrent-${Date.now()}`
|
|
1109
|
+
|
|
1110
|
+
// Set values concurrently
|
|
1111
|
+
const setPromises = Array.from({ length: 5 }, (_, i) =>
|
|
1112
|
+
service.setCached(`${baseKey}-${i}`, { index: i, value: `value-${i}` })
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
await Promise.all(setPromises)
|
|
1116
|
+
|
|
1117
|
+
// Get values concurrently
|
|
1118
|
+
const getPromises = Array.from({ length: 5 }, (_, i) => service.getCached(`${baseKey}-${i}`))
|
|
1119
|
+
|
|
1120
|
+
const entries = await Promise.all(getPromises)
|
|
1121
|
+
|
|
1122
|
+
for (let i = 0; i < 5; i++) {
|
|
1123
|
+
expect(entries[i]).toBeDefined()
|
|
1124
|
+
expect((entries[i]?.props as Record<string, unknown>)?.index).toBe(i)
|
|
1125
|
+
}
|
|
1126
|
+
})
|
|
1127
|
+
})
|
|
1128
|
+
|
|
1129
|
+
describe('maintains isolation via worker_loaders', () => {
|
|
1130
|
+
it('isolates cache between service calls', async () => {
|
|
1131
|
+
// First worker-like context
|
|
1132
|
+
const service1 = service
|
|
1133
|
+
const key1 = `isolation-${Date.now()}-1`
|
|
1134
|
+
await service1.setCached(key1, { from: 'service1' })
|
|
1135
|
+
|
|
1136
|
+
// Check isolation (same worker for testing, but demonstrates pattern)
|
|
1137
|
+
const entry = await service1.getCached(key1)
|
|
1138
|
+
expect(entry?.props).toEqual({ from: 'service1' })
|
|
1139
|
+
})
|
|
1140
|
+
|
|
1141
|
+
it('shares configuration within service instance', async () => {
|
|
1142
|
+
await service.configure({ model: 'anthropic/claude-sonnet-4.5' })
|
|
1143
|
+
|
|
1144
|
+
const config = await service.getConfig()
|
|
1145
|
+
expect(config.model).toBe('anthropic/claude-sonnet-4.5')
|
|
1146
|
+
|
|
1147
|
+
await service.resetConfig()
|
|
1148
|
+
})
|
|
1149
|
+
})
|
|
1150
|
+
|
|
1151
|
+
describe('performs well under load', () => {
|
|
1152
|
+
it('handles burst of 10 requests', async () => {
|
|
1153
|
+
const startTime = Date.now()
|
|
1154
|
+
|
|
1155
|
+
const promises = Array.from({ length: 10 }, (_, i) =>
|
|
1156
|
+
service.generate({
|
|
1157
|
+
schema: { text: `Generated text ${i}` },
|
|
1158
|
+
context: { batchId: Date.now(), index: i },
|
|
1159
|
+
})
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
const results = await Promise.all(promises)
|
|
1163
|
+
const duration = Date.now() - startTime
|
|
1164
|
+
|
|
1165
|
+
expect(results).toHaveLength(10)
|
|
1166
|
+
for (const result of results) {
|
|
1167
|
+
expect(result.props).toBeDefined()
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// Log timing for performance tracking
|
|
1171
|
+
console.log(`10 concurrent requests completed in ${duration}ms`)
|
|
1172
|
+
})
|
|
1173
|
+
|
|
1174
|
+
it('cache improves performance on repeated requests', async () => {
|
|
1175
|
+
const schema = { title: 'Performance test title' }
|
|
1176
|
+
const context = { testId: `perf-${Date.now()}` }
|
|
1177
|
+
|
|
1178
|
+
// First request (uncached)
|
|
1179
|
+
const start1 = Date.now()
|
|
1180
|
+
const result1 = await service.generate({ schema, context })
|
|
1181
|
+
const duration1 = Date.now() - start1
|
|
1182
|
+
|
|
1183
|
+
// Second request (cached)
|
|
1184
|
+
const start2 = Date.now()
|
|
1185
|
+
const result2 = await service.generate({ schema, context })
|
|
1186
|
+
const duration2 = Date.now() - start2
|
|
1187
|
+
|
|
1188
|
+
expect(result1.cached).toBe(false)
|
|
1189
|
+
expect(result2.cached).toBe(true)
|
|
1190
|
+
|
|
1191
|
+
// Cached request should be faster
|
|
1192
|
+
console.log(`Uncached: ${duration1}ms, Cached: ${duration2}ms`)
|
|
1193
|
+
expect(duration2).toBeLessThan(duration1)
|
|
1194
|
+
})
|
|
1195
|
+
})
|
|
1196
|
+
})
|
|
1197
|
+
|
|
1198
|
+
// ============================================================================
|
|
1199
|
+
// 5. Error Recovery Tests
|
|
1200
|
+
// ============================================================================
|
|
1201
|
+
|
|
1202
|
+
describe('error recovery', () => {
|
|
1203
|
+
let service: PropsServiceRpc
|
|
1204
|
+
|
|
1205
|
+
beforeEach(async () => {
|
|
1206
|
+
const testEnv = env as unknown as TestEnv
|
|
1207
|
+
service = testEnv.PROPS.getService()
|
|
1208
|
+
await service.clearCache()
|
|
1209
|
+
await service.resetConfig()
|
|
1210
|
+
})
|
|
1211
|
+
|
|
1212
|
+
describe('handles AI Gateway failures gracefully', () => {
|
|
1213
|
+
it('provides fallback props when AI fails', async () => {
|
|
1214
|
+
const AICard = createAIComponent({
|
|
1215
|
+
name: 'Card',
|
|
1216
|
+
schema: {
|
|
1217
|
+
title: 'Card title',
|
|
1218
|
+
body: 'Card body',
|
|
1219
|
+
},
|
|
1220
|
+
render: ({ title, body }) => `<div><h2>${title}</h2><p>${body}</p></div>`,
|
|
1221
|
+
fallback: {
|
|
1222
|
+
title: 'Default Title',
|
|
1223
|
+
body: 'Default Body',
|
|
1224
|
+
},
|
|
1225
|
+
})
|
|
1226
|
+
|
|
1227
|
+
// Even if AI fails, should use fallback
|
|
1228
|
+
const result = await AICard({})
|
|
1229
|
+
|
|
1230
|
+
expect(result).toBeDefined()
|
|
1231
|
+
expect(typeof result).toBe('string')
|
|
1232
|
+
// Either AI-generated or fallback
|
|
1233
|
+
expect(result).toContain('<div>')
|
|
1234
|
+
})
|
|
1235
|
+
|
|
1236
|
+
it('handles timeout gracefully', async () => {
|
|
1237
|
+
// Configure short timeout for testing
|
|
1238
|
+
await service.configure({ cacheTTL: 1000 })
|
|
1239
|
+
|
|
1240
|
+
const schema = { value: 'Test value' }
|
|
1241
|
+
|
|
1242
|
+
// Should complete within timeout or fail gracefully
|
|
1243
|
+
try {
|
|
1244
|
+
const result = await service.generate({ schema })
|
|
1245
|
+
expect(result.props).toBeDefined()
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
expect(error).toBeInstanceOf(Error)
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
await service.resetConfig()
|
|
1251
|
+
})
|
|
1252
|
+
})
|
|
1253
|
+
|
|
1254
|
+
describe('recovers from MDX parse errors', () => {
|
|
1255
|
+
it('throws descriptive error for invalid MDX', () => {
|
|
1256
|
+
const invalidMDX = `<Unclosed
|
|
1257
|
+
|
|
1258
|
+
This is broken.`
|
|
1259
|
+
|
|
1260
|
+
expect(() => parseMDX(invalidMDX)).toThrow()
|
|
1261
|
+
})
|
|
1262
|
+
|
|
1263
|
+
it('provides error location for syntax errors', () => {
|
|
1264
|
+
const invalidMDX = `# Valid heading
|
|
1265
|
+
<Valid />
|
|
1266
|
+
<Broken prop=>`
|
|
1267
|
+
|
|
1268
|
+
try {
|
|
1269
|
+
parseMDX(invalidMDX)
|
|
1270
|
+
expect.fail('Should have thrown')
|
|
1271
|
+
} catch (error) {
|
|
1272
|
+
expect(error).toBeInstanceOf(Error)
|
|
1273
|
+
expect((error as Error).message).toBeDefined()
|
|
1274
|
+
}
|
|
1275
|
+
})
|
|
1276
|
+
|
|
1277
|
+
it('handles malformed frontmatter', () => {
|
|
1278
|
+
const invalidMDX = `---
|
|
1279
|
+
title: [broken yaml
|
|
1280
|
+
nested: {incomplete
|
|
1281
|
+
---
|
|
1282
|
+
|
|
1283
|
+
# Content`
|
|
1284
|
+
|
|
1285
|
+
expect(() => parseMDX(invalidMDX)).toThrow()
|
|
1286
|
+
})
|
|
1287
|
+
|
|
1288
|
+
it('continues rendering valid parts after error recovery', async () => {
|
|
1289
|
+
const validMDX = `# Valid Document
|
|
1290
|
+
|
|
1291
|
+
<ValidComponent title="Works" />`
|
|
1292
|
+
|
|
1293
|
+
const rendered = await renderMDXWithProps(validMDX, {
|
|
1294
|
+
ValidComponent: { title: 'Works' },
|
|
1295
|
+
})
|
|
1296
|
+
|
|
1297
|
+
expect(rendered).toContain('Valid Document')
|
|
1298
|
+
expect(rendered).toContain('Works')
|
|
1299
|
+
})
|
|
1300
|
+
})
|
|
1301
|
+
|
|
1302
|
+
describe('handles RPC timeouts', () => {
|
|
1303
|
+
it('getSync throws for cache miss', () => {
|
|
1304
|
+
expect(() => {
|
|
1305
|
+
service.getSync({ missing: 'schema' })
|
|
1306
|
+
}).toThrow()
|
|
1307
|
+
})
|
|
1308
|
+
|
|
1309
|
+
it('returns undefined for non-existent cache key', async () => {
|
|
1310
|
+
const entry = await service.getCached(`non-existent-${Date.now()}`)
|
|
1311
|
+
expect(entry).toBeUndefined()
|
|
1312
|
+
})
|
|
1313
|
+
})
|
|
1314
|
+
|
|
1315
|
+
describe('provides meaningful error messages', () => {
|
|
1316
|
+
it('includes method name in error', async () => {
|
|
1317
|
+
try {
|
|
1318
|
+
service.getSync({ notCached: 'value' })
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
expect(error).toBeInstanceOf(Error)
|
|
1321
|
+
expect((error as Error).message).toContain('Props not in cache')
|
|
1322
|
+
}
|
|
1323
|
+
})
|
|
1324
|
+
|
|
1325
|
+
it('returns validation errors with field paths', async () => {
|
|
1326
|
+
const props = { score: 'not a number' }
|
|
1327
|
+
const schema = { score: 'Score (number)' }
|
|
1328
|
+
|
|
1329
|
+
const result = await service.validate(props, schema)
|
|
1330
|
+
|
|
1331
|
+
expect(result.valid).toBe(false)
|
|
1332
|
+
expect(result.errors.length).toBeGreaterThan(0)
|
|
1333
|
+
expect(result.errors[0]?.path).toBeDefined()
|
|
1334
|
+
})
|
|
1335
|
+
|
|
1336
|
+
it('handles null props gracefully', async () => {
|
|
1337
|
+
const mdx = `<Component />`
|
|
1338
|
+
|
|
1339
|
+
await expect(
|
|
1340
|
+
renderMDXWithProps(mdx, {
|
|
1341
|
+
Component: null as unknown as Record<string, unknown>,
|
|
1342
|
+
})
|
|
1343
|
+
).rejects.toThrow()
|
|
1344
|
+
})
|
|
1345
|
+
})
|
|
1346
|
+
})
|
|
1347
|
+
|
|
1348
|
+
// ============================================================================
|
|
1349
|
+
// 6. HTTP Endpoint Integration Tests
|
|
1350
|
+
// ============================================================================
|
|
1351
|
+
|
|
1352
|
+
describe('HTTP endpoint integration', () => {
|
|
1353
|
+
it('responds to GET / with service info', async () => {
|
|
1354
|
+
const response = await SELF.fetch('http://localhost/')
|
|
1355
|
+
|
|
1356
|
+
expect(response).toBeDefined()
|
|
1357
|
+
|
|
1358
|
+
if (response.ok) {
|
|
1359
|
+
const data = (await response.json()) as Record<string, unknown>
|
|
1360
|
+
expect(data.name).toBe('ai-props')
|
|
1361
|
+
expect(data.methods).toBeDefined()
|
|
1362
|
+
expect(Array.isArray(data.methods)).toBe(true)
|
|
1363
|
+
}
|
|
1364
|
+
})
|
|
1365
|
+
|
|
1366
|
+
it('handles POST /rpc for method calls', async () => {
|
|
1367
|
+
const response = await SELF.fetch('http://localhost/rpc', {
|
|
1368
|
+
method: 'POST',
|
|
1369
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1370
|
+
body: JSON.stringify({
|
|
1371
|
+
method: 'getCacheSize',
|
|
1372
|
+
args: [],
|
|
1373
|
+
}),
|
|
1374
|
+
})
|
|
1375
|
+
|
|
1376
|
+
expect(response).toBeDefined()
|
|
1377
|
+
|
|
1378
|
+
if (response.ok) {
|
|
1379
|
+
const data = (await response.json()) as { result?: number; error?: string }
|
|
1380
|
+
expect(typeof data.result).toBe('number')
|
|
1381
|
+
}
|
|
1382
|
+
})
|
|
1383
|
+
|
|
1384
|
+
it('returns 404 for unknown routes', async () => {
|
|
1385
|
+
const response = await SELF.fetch('http://localhost/unknown')
|
|
1386
|
+
|
|
1387
|
+
expect(response.status).toBe(404)
|
|
1388
|
+
})
|
|
1389
|
+
|
|
1390
|
+
it('returns error for invalid RPC method', async () => {
|
|
1391
|
+
const response = await SELF.fetch('http://localhost/rpc', {
|
|
1392
|
+
method: 'POST',
|
|
1393
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1394
|
+
body: JSON.stringify({
|
|
1395
|
+
method: 'nonExistentMethod',
|
|
1396
|
+
args: [],
|
|
1397
|
+
}),
|
|
1398
|
+
})
|
|
1399
|
+
|
|
1400
|
+
if (response.ok) {
|
|
1401
|
+
const data = (await response.json()) as { error?: string }
|
|
1402
|
+
// Should have error response
|
|
1403
|
+
expect(data.error).toBeDefined()
|
|
1404
|
+
} else {
|
|
1405
|
+
expect(response.status).toBe(404)
|
|
1406
|
+
}
|
|
1407
|
+
})
|
|
1408
|
+
})
|
|
1409
|
+
|
|
1410
|
+
// ============================================================================
|
|
1411
|
+
// 7. Streaming Response Tests
|
|
1412
|
+
// ============================================================================
|
|
1413
|
+
|
|
1414
|
+
describe('streaming response integration', () => {
|
|
1415
|
+
it('creates streaming Response with correct headers', async () => {
|
|
1416
|
+
const Component = ({ text }: { text: string }) => `<div>${text}</div>`
|
|
1417
|
+
|
|
1418
|
+
const response = await streamJSXResponse(Component, { text: 'Streaming test' })
|
|
1419
|
+
|
|
1420
|
+
expect(response).toBeInstanceOf(Response)
|
|
1421
|
+
expect(response.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
|
|
1422
|
+
expect(response.body).toBeInstanceOf(ReadableStream)
|
|
1423
|
+
})
|
|
1424
|
+
|
|
1425
|
+
it('streams content progressively', async () => {
|
|
1426
|
+
const LargeComponent = ({ count }: { count: number }) => {
|
|
1427
|
+
const items = Array.from({ length: count }, (_, i) => `<div>Item ${i}</div>`)
|
|
1428
|
+
return items.join('')
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const response = await optimizedStreamJSXResponse(
|
|
1432
|
+
LargeComponent,
|
|
1433
|
+
{ count: 100 },
|
|
1434
|
+
{ chunkSize: 256 }
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
const reader = response.body!.getReader()
|
|
1438
|
+
const chunks: Uint8Array[] = []
|
|
1439
|
+
|
|
1440
|
+
while (true) {
|
|
1441
|
+
const { done, value } = await reader.read()
|
|
1442
|
+
if (done) break
|
|
1443
|
+
chunks.push(value)
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// Should have multiple chunks
|
|
1447
|
+
expect(chunks.length).toBeGreaterThan(1)
|
|
1448
|
+
})
|
|
1449
|
+
|
|
1450
|
+
it('includes hydration script at end of stream', async () => {
|
|
1451
|
+
const Component = ({ data }: { data: string }) => `<div>${data}</div>`
|
|
1452
|
+
|
|
1453
|
+
const response = await streamJSXResponse(
|
|
1454
|
+
Component,
|
|
1455
|
+
{ data: 'test' },
|
|
1456
|
+
{ includeHydration: true }
|
|
1457
|
+
)
|
|
1458
|
+
|
|
1459
|
+
const text = await response.text()
|
|
1460
|
+
|
|
1461
|
+
expect(text).toContain('__HYDRATION_DATA__')
|
|
1462
|
+
})
|
|
1463
|
+
})
|