ai-props 2.1.3 → 2.4.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.
Files changed (105) hide show
  1. package/.dev.vars +2 -0
  2. package/.turbo/turbo-build.log +1 -1
  3. package/CHANGELOG.md +20 -0
  4. package/README.md +2 -0
  5. package/dist/ai.d.ts.map +1 -1
  6. package/dist/ai.js +4 -4
  7. package/dist/ai.js.map +1 -1
  8. package/dist/cascade.d.ts +329 -0
  9. package/dist/cascade.d.ts.map +1 -0
  10. package/dist/cascade.js +522 -0
  11. package/dist/cascade.js.map +1 -0
  12. package/dist/client.d.ts +233 -0
  13. package/dist/client.d.ts.map +1 -0
  14. package/dist/client.js +191 -0
  15. package/dist/client.js.map +1 -0
  16. package/dist/durable-cascade.d.ts +280 -0
  17. package/dist/durable-cascade.d.ts.map +1 -0
  18. package/dist/durable-cascade.js +469 -0
  19. package/dist/durable-cascade.js.map +1 -0
  20. package/dist/event-bridge.d.ts +257 -0
  21. package/dist/event-bridge.d.ts.map +1 -0
  22. package/dist/event-bridge.js +317 -0
  23. package/dist/event-bridge.js.map +1 -0
  24. package/dist/generate.d.ts.map +1 -1
  25. package/dist/generate.js +12 -6
  26. package/dist/generate.js.map +1 -1
  27. package/dist/hoc.d.ts.map +1 -1
  28. package/dist/hoc.js +13 -13
  29. package/dist/hoc.js.map +1 -1
  30. package/dist/hono-jsx.d.ts +208 -0
  31. package/dist/hono-jsx.d.ts.map +1 -0
  32. package/dist/hono-jsx.js +459 -0
  33. package/dist/hono-jsx.js.map +1 -0
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/mdx-types.d.ts +152 -0
  39. package/dist/mdx-types.d.ts.map +1 -0
  40. package/dist/mdx-types.js +9 -0
  41. package/dist/mdx-types.js.map +1 -0
  42. package/dist/mdx-utils.d.ts +106 -0
  43. package/dist/mdx-utils.d.ts.map +1 -0
  44. package/dist/mdx-utils.js +384 -0
  45. package/dist/mdx-utils.js.map +1 -0
  46. package/dist/mdx.d.ts +230 -0
  47. package/dist/mdx.d.ts.map +1 -0
  48. package/dist/mdx.js +820 -0
  49. package/dist/mdx.js.map +1 -0
  50. package/dist/rpc.d.ts +313 -0
  51. package/dist/rpc.d.ts.map +1 -0
  52. package/dist/rpc.js +359 -0
  53. package/dist/rpc.js.map +1 -0
  54. package/dist/streaming.d.ts +199 -0
  55. package/dist/streaming.d.ts.map +1 -0
  56. package/dist/streaming.js +402 -0
  57. package/dist/streaming.js.map +1 -0
  58. package/dist/validate.d.ts.map +1 -1
  59. package/dist/validate.js +11 -13
  60. package/dist/validate.js.map +1 -1
  61. package/dist/worker.d.ts +270 -0
  62. package/dist/worker.d.ts.map +1 -0
  63. package/dist/worker.js +405 -0
  64. package/dist/worker.js.map +1 -0
  65. package/package.json +39 -13
  66. package/src/ai.ts +12 -31
  67. package/src/cascade.ts +795 -0
  68. package/src/client.ts +440 -0
  69. package/src/durable-cascade.ts +743 -0
  70. package/src/event-bridge.ts +478 -0
  71. package/src/generate.ts +14 -12
  72. package/src/hoc.ts +15 -19
  73. package/src/hono-jsx.ts +675 -0
  74. package/src/index.ts +30 -0
  75. package/src/mdx-types.ts +169 -0
  76. package/src/mdx-utils.ts +437 -0
  77. package/src/mdx.ts +1008 -0
  78. package/src/rpc.ts +614 -0
  79. package/src/streaming.ts +618 -0
  80. package/src/validate.ts +15 -29
  81. package/src/worker.ts +547 -0
  82. package/test/cascade.test.ts +338 -0
  83. package/test/durable-cascade.test.ts +319 -0
  84. package/test/event-bridge.test.ts +351 -0
  85. package/test/generate.test.ts +6 -16
  86. package/test/mdx.test.ts +817 -0
  87. package/test/worker/capnweb-rpc.test.ts +1084 -0
  88. package/test/worker/full-flow.integration.test.ts +1463 -0
  89. package/test/worker/hono-jsx.test.ts +1258 -0
  90. package/test/worker/mdx-parsing.test.ts +1148 -0
  91. package/test/worker/setup.ts +56 -0
  92. package/test/worker.test.ts +595 -0
  93. package/tsconfig.json +2 -1
  94. package/vitest.config.js +6 -0
  95. package/vitest.config.ts +15 -1
  96. package/vitest.workers.config.ts +58 -0
  97. package/wrangler.jsonc +27 -0
  98. package/LICENSE +0 -21
  99. package/src/ai.js +0 -198
  100. package/src/cache.js +0 -182
  101. package/src/generate.js +0 -220
  102. package/src/hoc.js +0 -235
  103. package/src/index.js +0 -20
  104. package/src/types.js +0 -6
  105. 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
+ })