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,1258 @@
1
+ /**
2
+ * Tests for hono/jsx hydration and streaming with AI-generated props
3
+ *
4
+ * RED phase: These tests define the expected behavior for hono/jsx integration
5
+ * in a real Cloudflare Workers environment using @cloudflare/vitest-pool-workers.
6
+ *
7
+ * NO MOCKS - all tests run against real Workers runtime and AI Gateway bindings.
8
+ *
9
+ * The hono/jsx integration should:
10
+ * 1. Collect hydration data from props used during render
11
+ * 2. Track component hierarchy for hydration
12
+ * 3. Serialize hydration data correctly
13
+ * 4. Stream JSX to response
14
+ * 5. Include hydration script in stream
15
+ * 6. Support async components with Suspense
16
+ * 7. Generate AI props for hono/jsx components
17
+ *
18
+ * Bead: aip-fxpy
19
+ *
20
+ * @packageDocumentation
21
+ */
22
+
23
+ import { describe, it, expect, beforeEach } from 'vitest'
24
+ import { env } from 'cloudflare:test'
25
+
26
+ // ============================================================================
27
+ // Import hono/jsx modules
28
+ // These imports will fail initially since hono/jsx integration is not implemented
29
+ // ============================================================================
30
+
31
+ // Import the hydration and streaming functions that need to be implemented
32
+ import {
33
+ // Hydration data collection
34
+ collectHydrationData,
35
+ createHydrationContext,
36
+ serializeHydrationData,
37
+ HydrationProvider,
38
+ useHydration,
39
+
40
+ // Streaming render
41
+ renderToReadableStream,
42
+ streamJSXResponse,
43
+ createStreamingRenderer,
44
+
45
+ // Component utilities
46
+ createAIComponent,
47
+ withAIProps,
48
+ AIPropsProvider,
49
+
50
+ // Types
51
+ type HydrationData,
52
+ type HydrationContext,
53
+ type StreamingOptions,
54
+ type AIComponentProps,
55
+ } from '../../src/hono-jsx.js'
56
+
57
+ // Import existing ai-props modules
58
+ import { PropsServiceCore } from '../../src/worker.js'
59
+ import { generateProps } from '../../src/generate.js'
60
+ import { clearCache } from '../../src/cache.js'
61
+
62
+ // ============================================================================
63
+ // Type definitions for expected hono/jsx integration interfaces
64
+ // ============================================================================
65
+
66
+ /**
67
+ * Hydration data structure collected during render
68
+ */
69
+ interface ExpectedHydrationData {
70
+ /** Map of component ID to props used during render */
71
+ components: Map<string, Record<string, unknown>>
72
+ /** Component hierarchy tree */
73
+ tree: HydrationNode[]
74
+ /** Serializable JSON representation */
75
+ toJSON(): string
76
+ }
77
+
78
+ /**
79
+ * Node in the component hydration tree
80
+ */
81
+ interface HydrationNode {
82
+ id: string
83
+ component: string
84
+ props: Record<string, unknown>
85
+ children: HydrationNode[]
86
+ }
87
+
88
+ /**
89
+ * Context for hydration tracking
90
+ */
91
+ interface ExpectedHydrationContext {
92
+ /** Register a component render with props */
93
+ register(componentName: string, props: Record<string, unknown>): string
94
+ /** Get collected hydration data */
95
+ getData(): ExpectedHydrationData
96
+ /** Clear collected data */
97
+ clear(): void
98
+ }
99
+
100
+ // ============================================================================
101
+ // 1. Hydration Data Collection Tests
102
+ // ============================================================================
103
+
104
+ describe('hydration data collection', () => {
105
+ describe('collectHydrationData()', () => {
106
+ it('collects props used during render', async () => {
107
+ // Define a simple component that uses props
108
+ const TestComponent = ({ title, description }: { title: string; description: string }) => {
109
+ return `<div><h1>${title}</h1><p>${description}</p></div>`
110
+ }
111
+
112
+ const props = { title: 'Hello', description: 'World' }
113
+
114
+ const hydrationData = await collectHydrationData(TestComponent, props)
115
+
116
+ expect(hydrationData).toBeDefined()
117
+ expect(hydrationData.components).toBeDefined()
118
+ expect(hydrationData.components.size).toBeGreaterThan(0)
119
+ })
120
+
121
+ it('tracks component hierarchy', async () => {
122
+ // Define nested components
123
+ const ChildComponent = ({ text }: { text: string }) => `<span>${text}</span>`
124
+
125
+ const ParentComponent = ({ title, items }: { title: string; items: string[] }) => {
126
+ return `<div><h1>${title}</h1>${items
127
+ .map((item) => ChildComponent({ text: item }))
128
+ .join('')}</div>`
129
+ }
130
+
131
+ const props = { title: 'List', items: ['a', 'b', 'c'] }
132
+
133
+ const hydrationData = await collectHydrationData(ParentComponent, props)
134
+
135
+ expect(hydrationData.tree).toBeDefined()
136
+ expect(Array.isArray(hydrationData.tree)).toBe(true)
137
+ // Parent should have children
138
+ expect(hydrationData.tree.length).toBeGreaterThan(0)
139
+ })
140
+
141
+ it('serializes hydration data correctly', async () => {
142
+ const Component = ({ value }: { value: number }) => `<span>${value}</span>`
143
+
144
+ const hydrationData = await collectHydrationData(Component, { value: 42 })
145
+
146
+ const serialized = hydrationData.toJSON()
147
+
148
+ expect(typeof serialized).toBe('string')
149
+ expect(() => JSON.parse(serialized)).not.toThrow()
150
+
151
+ const parsed = JSON.parse(serialized)
152
+ expect(parsed).toBeDefined()
153
+ })
154
+
155
+ it('handles nested component props', async () => {
156
+ const DeepChild = ({ label }: { label: string }) => `<span>${label}</span>`
157
+
158
+ const Child = ({ name, active }: { name: string; active: boolean }) => {
159
+ return `<div>${DeepChild({ label: name })} - ${active ? 'active' : 'inactive'}</div>`
160
+ }
161
+
162
+ const Parent = ({ users }: { users: Array<{ name: string; active: boolean }> }) => {
163
+ return `<ul>${users.map((u) => Child(u)).join('')}</ul>`
164
+ }
165
+
166
+ const props = {
167
+ users: [
168
+ { name: 'Alice', active: true },
169
+ { name: 'Bob', active: false },
170
+ ],
171
+ }
172
+
173
+ const hydrationData = await collectHydrationData(Parent, props)
174
+
175
+ // Should track all nested components
176
+ expect(hydrationData.components.size).toBeGreaterThanOrEqual(1)
177
+
178
+ // Tree should reflect nesting
179
+ const serialized = JSON.parse(hydrationData.toJSON())
180
+ expect(serialized).toBeDefined()
181
+ })
182
+
183
+ it('assigns unique IDs to each component instance', async () => {
184
+ const Item = ({ id }: { id: number }) => `<li>${id}</li>`
185
+
186
+ const List = ({ count }: { count: number }) => {
187
+ const items = Array.from({ length: count }, (_, i) => Item({ id: i }))
188
+ return `<ul>${items.join('')}</ul>`
189
+ }
190
+
191
+ const hydrationData = await collectHydrationData(List, { count: 5 })
192
+
193
+ // Each Item should have a unique ID
194
+ const ids = Array.from(hydrationData.components.keys())
195
+ const uniqueIds = new Set(ids)
196
+ expect(uniqueIds.size).toBe(ids.length)
197
+ })
198
+ })
199
+
200
+ describe('createHydrationContext()', () => {
201
+ it('creates a hydration context', () => {
202
+ const ctx = createHydrationContext()
203
+
204
+ expect(ctx).toBeDefined()
205
+ expect(typeof ctx.register).toBe('function')
206
+ expect(typeof ctx.getData).toBe('function')
207
+ expect(typeof ctx.clear).toBe('function')
208
+ })
209
+
210
+ it('registers component props during render', () => {
211
+ const ctx = createHydrationContext()
212
+
213
+ const id1 = ctx.register('Header', { title: 'Welcome' })
214
+ const id2 = ctx.register('Footer', { year: 2026 })
215
+
216
+ expect(id1).toBeDefined()
217
+ expect(id2).toBeDefined()
218
+ expect(id1).not.toBe(id2)
219
+ })
220
+
221
+ it('retrieves collected data', () => {
222
+ const ctx = createHydrationContext()
223
+
224
+ ctx.register('Card', { title: 'Test', body: 'Content' })
225
+ ctx.register('Button', { label: 'Click', disabled: false })
226
+
227
+ const data = ctx.getData()
228
+
229
+ expect(data.components.size).toBe(2)
230
+ expect(data.components.has('Card')).toBe(true)
231
+ expect(data.components.has('Button')).toBe(true)
232
+ })
233
+
234
+ it('clears collected data', () => {
235
+ const ctx = createHydrationContext()
236
+
237
+ ctx.register('Component', { prop: 'value' })
238
+ expect(ctx.getData().components.size).toBe(1)
239
+
240
+ ctx.clear()
241
+ expect(ctx.getData().components.size).toBe(0)
242
+ })
243
+ })
244
+
245
+ describe('serializeHydrationData()', () => {
246
+ it('serializes hydration data to JSON string', () => {
247
+ const data: HydrationData = {
248
+ components: new Map([
249
+ ['comp-1', { title: 'Hello' }],
250
+ ['comp-2', { count: 42 }],
251
+ ]),
252
+ tree: [],
253
+ toJSON: () => '',
254
+ }
255
+
256
+ const serialized = serializeHydrationData(data)
257
+
258
+ expect(typeof serialized).toBe('string')
259
+ expect(() => JSON.parse(serialized)).not.toThrow()
260
+ })
261
+
262
+ it('handles circular references gracefully', () => {
263
+ const circular: Record<string, unknown> = { name: 'test' }
264
+ circular.self = circular
265
+
266
+ const data: HydrationData = {
267
+ components: new Map([['comp', circular]]),
268
+ tree: [],
269
+ toJSON: () => '',
270
+ }
271
+
272
+ // Should not throw on circular references
273
+ expect(() => serializeHydrationData(data)).not.toThrow()
274
+ })
275
+
276
+ it('escapes special characters for script embedding', () => {
277
+ const data: HydrationData = {
278
+ components: new Map([['comp', { html: '<script>alert("xss")</script>' }]]),
279
+ tree: [],
280
+ toJSON: () => '',
281
+ }
282
+
283
+ const serialized = serializeHydrationData(data)
284
+
285
+ // Should escape script tags
286
+ expect(serialized).not.toContain('<script>')
287
+ expect(serialized).not.toContain('</script>')
288
+ })
289
+
290
+ it('produces valid JavaScript object literal', () => {
291
+ const data: HydrationData = {
292
+ components: new Map([['hero', { title: 'Welcome', subtitle: 'To the app' }]]),
293
+ tree: [{ id: 'hero', component: 'Hero', props: { title: 'Welcome' }, children: [] }],
294
+ toJSON: () => '',
295
+ }
296
+
297
+ const serialized = serializeHydrationData(data)
298
+
299
+ // Should be parseable as JSON
300
+ const parsed = JSON.parse(serialized)
301
+ expect(parsed.components).toBeDefined()
302
+ expect(parsed.tree).toBeDefined()
303
+ })
304
+ })
305
+
306
+ describe('HydrationProvider', () => {
307
+ it('provides hydration context to children', () => {
308
+ // This would be a JSX component test
309
+ // HydrationProvider wraps children and provides context
310
+ expect(HydrationProvider).toBeDefined()
311
+ expect(typeof HydrationProvider).toBe('function')
312
+ })
313
+
314
+ it('collects hydration data from nested components', async () => {
315
+ // Create a render tree with HydrationProvider
316
+ const ctx = createHydrationContext()
317
+
318
+ // Simulate rendering with provider
319
+ const result = HydrationProvider({ context: ctx, children: null })
320
+
321
+ expect(result).toBeDefined()
322
+ })
323
+ })
324
+
325
+ describe('useHydration()', () => {
326
+ it('returns hydration context from provider', () => {
327
+ // useHydration is a hook that returns the current hydration context
328
+ expect(useHydration).toBeDefined()
329
+ expect(typeof useHydration).toBe('function')
330
+ })
331
+
332
+ it('allows components to register themselves', () => {
333
+ // Components can use useHydration to register their props
334
+ const ctx = createHydrationContext()
335
+
336
+ // Simulate a component using the hook
337
+ const register = () => {
338
+ const hydration = useHydration()
339
+ hydration.register('TestComponent', { prop: 'value' })
340
+ }
341
+
342
+ // This would need proper JSX context to work
343
+ expect(typeof useHydration).toBe('function')
344
+ })
345
+ })
346
+ })
347
+
348
+ // ============================================================================
349
+ // 2. Streaming Render Tests
350
+ // ============================================================================
351
+
352
+ describe('streaming render', () => {
353
+ describe('renderToReadableStream()', () => {
354
+ it('streams JSX to response', async () => {
355
+ const Component = ({ message }: { message: string }) => `<div>${message}</div>`
356
+
357
+ const stream = await renderToReadableStream(Component, { message: 'Hello World' })
358
+
359
+ expect(stream).toBeInstanceOf(ReadableStream)
360
+ })
361
+
362
+ it('includes hydration script in stream', async () => {
363
+ const Component = ({ title }: { title: string }) => `<h1>${title}</h1>`
364
+
365
+ const stream = await renderToReadableStream(
366
+ Component,
367
+ { title: 'Test' },
368
+ {
369
+ includeHydration: true,
370
+ }
371
+ )
372
+
373
+ const reader = stream.getReader()
374
+ const decoder = new TextDecoder()
375
+ let content = ''
376
+
377
+ while (true) {
378
+ const { done, value } = await reader.read()
379
+ if (done) break
380
+ content += decoder.decode(value, { stream: true })
381
+ }
382
+
383
+ // Should include hydration script
384
+ expect(content).toContain('__HYDRATION_DATA__')
385
+ })
386
+
387
+ it('handles async components', async () => {
388
+ const AsyncComponent = async ({ delay }: { delay: number }) => {
389
+ await new Promise((resolve) => setTimeout(resolve, delay))
390
+ return `<div>Loaded after ${delay}ms</div>`
391
+ }
392
+
393
+ const stream = await renderToReadableStream(AsyncComponent, { delay: 10 })
394
+
395
+ const reader = stream.getReader()
396
+ const decoder = new TextDecoder()
397
+ let content = ''
398
+
399
+ while (true) {
400
+ const { done, value } = await reader.read()
401
+ if (done) break
402
+ content += decoder.decode(value, { stream: true })
403
+ }
404
+
405
+ expect(content).toContain('Loaded after 10ms')
406
+ })
407
+
408
+ it('supports suspense boundaries', async () => {
409
+ // Define components with suspense support
410
+ const SlowComponent = async () => {
411
+ await new Promise((resolve) => setTimeout(resolve, 50))
412
+ return '<div>Slow content</div>'
413
+ }
414
+
415
+ const FastComponent = () => '<div>Fast content</div>'
416
+
417
+ const App = async () => {
418
+ return `<div>${FastComponent()}${await SlowComponent()}</div>`
419
+ }
420
+
421
+ const stream = await renderToReadableStream(
422
+ App,
423
+ {},
424
+ {
425
+ suspense: {
426
+ fallback: '<div>Loading...</div>',
427
+ },
428
+ }
429
+ )
430
+
431
+ const reader = stream.getReader()
432
+ const chunks: string[] = []
433
+ const decoder = new TextDecoder()
434
+
435
+ while (true) {
436
+ const { done, value } = await reader.read()
437
+ if (done) break
438
+ chunks.push(decoder.decode(value, { stream: true }))
439
+ }
440
+
441
+ const content = chunks.join('')
442
+ expect(content).toContain('Fast content')
443
+ expect(content).toContain('Slow content')
444
+ })
445
+
446
+ it('streams chunks progressively', async () => {
447
+ const LargeComponent = ({ count }: { count: number }) => {
448
+ const items = Array.from({ length: count }, (_, i) => `<div>Item ${i}</div>`)
449
+ return items.join('')
450
+ }
451
+
452
+ const stream = await renderToReadableStream(LargeComponent, { count: 100 })
453
+
454
+ const reader = stream.getReader()
455
+ const chunks: Uint8Array[] = []
456
+
457
+ while (true) {
458
+ const { done, value } = await reader.read()
459
+ if (done) break
460
+ chunks.push(value)
461
+ }
462
+
463
+ // Should receive multiple chunks for large content
464
+ expect(chunks.length).toBeGreaterThan(0)
465
+ })
466
+
467
+ it('handles render errors gracefully', async () => {
468
+ const ErrorComponent = () => {
469
+ throw new Error('Render error')
470
+ }
471
+
472
+ const stream = await renderToReadableStream(
473
+ ErrorComponent,
474
+ {},
475
+ {
476
+ onError: (error: Error) => `<div class="error">${error.message}</div>`,
477
+ }
478
+ )
479
+
480
+ const reader = stream.getReader()
481
+ const decoder = new TextDecoder()
482
+ let content = ''
483
+
484
+ while (true) {
485
+ const { done, value } = await reader.read()
486
+ if (done) break
487
+ content += decoder.decode(value, { stream: true })
488
+ }
489
+
490
+ expect(content).toContain('Render error')
491
+ })
492
+ })
493
+
494
+ describe('streamJSXResponse()', () => {
495
+ it('returns a Response object with streaming body', async () => {
496
+ const Component = ({ text }: { text: string }) => `<p>${text}</p>`
497
+
498
+ const response = await streamJSXResponse(Component, { text: 'Hello' })
499
+
500
+ expect(response).toBeInstanceOf(Response)
501
+ expect(response.body).toBeInstanceOf(ReadableStream)
502
+ })
503
+
504
+ it('sets correct Content-Type header', async () => {
505
+ const Component = () => '<html></html>'
506
+
507
+ const response = await streamJSXResponse(Component, {})
508
+
509
+ expect(response.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
510
+ })
511
+
512
+ it('supports custom headers', async () => {
513
+ const Component = () => '<div></div>'
514
+
515
+ const response = await streamJSXResponse(
516
+ Component,
517
+ {},
518
+ {
519
+ headers: {
520
+ 'X-Custom-Header': 'value',
521
+ },
522
+ }
523
+ )
524
+
525
+ expect(response.headers.get('X-Custom-Header')).toBe('value')
526
+ })
527
+
528
+ it('sets transfer-encoding chunked for streaming', async () => {
529
+ const Component = () => '<div>Content</div>'
530
+
531
+ const response = await streamJSXResponse(
532
+ Component,
533
+ {},
534
+ {
535
+ streaming: true,
536
+ }
537
+ )
538
+
539
+ // Streaming responses typically have no Content-Length
540
+ // and may have Transfer-Encoding: chunked
541
+ expect(response.headers.get('Content-Length')).toBeNull()
542
+ })
543
+ })
544
+
545
+ describe('createStreamingRenderer()', () => {
546
+ it('creates a reusable streaming renderer', () => {
547
+ const renderer = createStreamingRenderer({
548
+ doctype: '<!DOCTYPE html>',
549
+ shell: (content: string) => `<html><body>${content}</body></html>`,
550
+ })
551
+
552
+ expect(renderer).toBeDefined()
553
+ expect(typeof renderer.render).toBe('function')
554
+ })
555
+
556
+ it('applies shell wrapper to rendered content', async () => {
557
+ const renderer = createStreamingRenderer({
558
+ shell: (content: string) => `<main>${content}</main>`,
559
+ })
560
+
561
+ const Component = () => '<section>Content</section>'
562
+
563
+ const stream = await renderer.render(Component, {})
564
+ const reader = stream.getReader()
565
+ const decoder = new TextDecoder()
566
+ let content = ''
567
+
568
+ while (true) {
569
+ const { done, value } = await reader.read()
570
+ if (done) break
571
+ content += decoder.decode(value, { stream: true })
572
+ }
573
+
574
+ expect(content).toContain('<main>')
575
+ expect(content).toContain('</main>')
576
+ expect(content).toContain('<section>Content</section>')
577
+ })
578
+
579
+ it('injects hydration data at specified location', async () => {
580
+ const renderer = createStreamingRenderer({
581
+ shell: (content: string, hydration: string) =>
582
+ `<html><body>${content}<script>${hydration}</script></body></html>`,
583
+ includeHydration: true,
584
+ })
585
+
586
+ const Component = ({ title }: { title: string }) => `<h1>${title}</h1>`
587
+
588
+ const stream = await renderer.render(Component, { title: 'Test' })
589
+ const reader = stream.getReader()
590
+ const decoder = new TextDecoder()
591
+ let content = ''
592
+
593
+ while (true) {
594
+ const { done, value } = await reader.read()
595
+ if (done) break
596
+ content += decoder.decode(value, { stream: true })
597
+ }
598
+
599
+ expect(content).toContain('<script>')
600
+ expect(content).toContain('__HYDRATION_DATA__')
601
+ })
602
+ })
603
+ })
604
+
605
+ // ============================================================================
606
+ // 3. hono/jsx Component Integration Tests
607
+ // ============================================================================
608
+
609
+ describe('hono/jsx components', () => {
610
+ describe('component rendering', () => {
611
+ it('renders hono/jsx components with props', async () => {
612
+ // Define a simple functional component
613
+ const Greeting = ({ name, greeting = 'Hello' }: { name: string; greeting?: string }) => {
614
+ return `<div class="greeting">${greeting}, ${name}!</div>`
615
+ }
616
+
617
+ const stream = await renderToReadableStream(Greeting, {
618
+ name: 'World',
619
+ greeting: 'Welcome',
620
+ })
621
+
622
+ const reader = stream.getReader()
623
+ const decoder = new TextDecoder()
624
+ let content = ''
625
+
626
+ while (true) {
627
+ const { done, value } = await reader.read()
628
+ if (done) break
629
+ content += decoder.decode(value, { stream: true })
630
+ }
631
+
632
+ expect(content).toContain('Welcome, World!')
633
+ expect(content).toContain('class="greeting"')
634
+ })
635
+
636
+ it('supports functional components', async () => {
637
+ // Pure functional component
638
+ const Badge = ({ label, color }: { label: string; color: string }) =>
639
+ `<span class="badge" style="background: ${color}">${label}</span>`
640
+
641
+ const stream = await renderToReadableStream(Badge, {
642
+ label: 'New',
643
+ color: 'green',
644
+ })
645
+
646
+ const reader = stream.getReader()
647
+ const decoder = new TextDecoder()
648
+ let content = ''
649
+
650
+ while (true) {
651
+ const { done, value } = await reader.read()
652
+ if (done) break
653
+ content += decoder.decode(value, { stream: true })
654
+ }
655
+
656
+ expect(content).toContain('New')
657
+ expect(content).toContain('green')
658
+ })
659
+
660
+ it('handles component composition', async () => {
661
+ const Button = ({ onClick, children }: { onClick?: string; children: string }) =>
662
+ `<button onclick="${onClick || ''}">${children}</button>`
663
+
664
+ const Card = ({ title, body, actions }: { title: string; body: string; actions?: string }) =>
665
+ `<div class="card">
666
+ <h2>${title}</h2>
667
+ <p>${body}</p>
668
+ ${actions ? `<div class="actions">${actions}</div>` : ''}
669
+ </div>`
670
+
671
+ const App = () => {
672
+ return Card({
673
+ title: 'Welcome',
674
+ body: 'This is the card body',
675
+ actions: Button({ children: 'Click me' }),
676
+ })
677
+ }
678
+
679
+ const stream = await renderToReadableStream(App, {})
680
+
681
+ const reader = stream.getReader()
682
+ const decoder = new TextDecoder()
683
+ let content = ''
684
+
685
+ while (true) {
686
+ const { done, value } = await reader.read()
687
+ if (done) break
688
+ content += decoder.decode(value, { stream: true })
689
+ }
690
+
691
+ expect(content).toContain('Welcome')
692
+ expect(content).toContain('This is the card body')
693
+ expect(content).toContain('Click me')
694
+ })
695
+
696
+ it('passes context through tree', async () => {
697
+ // Simulate context passing through component tree
698
+ const ThemeContext = { theme: 'dark' }
699
+
700
+ const ThemedButton = ({ context, label }: { context: typeof ThemeContext; label: string }) =>
701
+ `<button class="btn-${context.theme}">${label}</button>`
702
+
703
+ const ThemedCard = ({ context, title }: { context: typeof ThemeContext; title: string }) =>
704
+ `<div class="card-${context.theme}">
705
+ <h2>${title}</h2>
706
+ ${ThemedButton({ context, label: 'Action' })}
707
+ </div>`
708
+
709
+ const App = () => {
710
+ return ThemedCard({ context: ThemeContext, title: 'Themed Card' })
711
+ }
712
+
713
+ const stream = await renderToReadableStream(App, {})
714
+
715
+ const reader = stream.getReader()
716
+ const decoder = new TextDecoder()
717
+ let content = ''
718
+
719
+ while (true) {
720
+ const { done, value } = await reader.read()
721
+ if (done) break
722
+ content += decoder.decode(value, { stream: true })
723
+ }
724
+
725
+ expect(content).toContain('card-dark')
726
+ expect(content).toContain('btn-dark')
727
+ })
728
+ })
729
+
730
+ describe('createAIComponent()', () => {
731
+ it('creates a component with AI-generated props', async () => {
732
+ const AIHero = createAIComponent({
733
+ name: 'Hero',
734
+ schema: {
735
+ title: 'Hero section title',
736
+ subtitle: 'Hero section subtitle',
737
+ ctaText: 'Call to action button text',
738
+ },
739
+ render: ({ title, subtitle, ctaText }) =>
740
+ `<section class="hero">
741
+ <h1>${title}</h1>
742
+ <p>${subtitle}</p>
743
+ <button>${ctaText}</button>
744
+ </section>`,
745
+ })
746
+
747
+ expect(AIHero).toBeDefined()
748
+ expect(typeof AIHero).toBe('function')
749
+ expect(AIHero.schema).toBeDefined()
750
+ })
751
+
752
+ it('generates props when not provided', async () => {
753
+ const AICard = createAIComponent({
754
+ name: 'Card',
755
+ schema: {
756
+ title: 'Card title',
757
+ description: 'Card description',
758
+ },
759
+ render: ({ title, description }) =>
760
+ `<div class="card"><h3>${title}</h3><p>${description}</p></div>`,
761
+ })
762
+
763
+ // Render without providing props - should generate via AI
764
+ const result = await AICard({})
765
+
766
+ expect(result).toBeDefined()
767
+ expect(typeof result).toBe('string')
768
+ })
769
+
770
+ it('uses provided props without generation', async () => {
771
+ const AICard = createAIComponent({
772
+ name: 'Card',
773
+ schema: {
774
+ title: 'Card title',
775
+ description: 'Card description',
776
+ },
777
+ render: ({ title, description }) =>
778
+ `<div class="card"><h3>${title}</h3><p>${description}</p></div>`,
779
+ })
780
+
781
+ // Render with provided props - should not call AI
782
+ const result = await AICard({
783
+ title: 'Explicit Title',
784
+ description: 'Explicit Description',
785
+ })
786
+
787
+ expect(result).toContain('Explicit Title')
788
+ expect(result).toContain('Explicit Description')
789
+ })
790
+
791
+ it('merges partial props with generated', async () => {
792
+ const AICard = createAIComponent({
793
+ name: 'Card',
794
+ schema: {
795
+ title: 'Card title',
796
+ description: 'Card description',
797
+ image: 'Card image URL',
798
+ },
799
+ render: ({ title, description, image }) =>
800
+ `<div class="card">
801
+ <img src="${image}" />
802
+ <h3>${title}</h3>
803
+ <p>${description}</p>
804
+ </div>`,
805
+ })
806
+
807
+ // Provide partial props
808
+ const result = await AICard({
809
+ title: 'My Title',
810
+ // description and image should be generated
811
+ })
812
+
813
+ expect(result).toContain('My Title')
814
+ expect(result).toBeDefined()
815
+ })
816
+ })
817
+
818
+ describe('withAIProps()', () => {
819
+ it('wraps existing component with AI props generation', async () => {
820
+ // Define a plain component
821
+ const PlainCard = ({ title, description }: { title: string; description: string }) =>
822
+ `<div class="card"><h3>${title}</h3><p>${description}</p></div>`
823
+
824
+ // Wrap with AI props
825
+ const AICard = withAIProps(PlainCard, {
826
+ schema: {
827
+ title: 'Card title text',
828
+ description: 'Card description text',
829
+ },
830
+ })
831
+
832
+ expect(AICard).toBeDefined()
833
+ expect(typeof AICard).toBe('function')
834
+ })
835
+
836
+ it('generates missing props on render', async () => {
837
+ const PlainHero = ({ headline, subheadline }: { headline: string; subheadline: string }) =>
838
+ `<section><h1>${headline}</h1><p>${subheadline}</p></section>`
839
+
840
+ const AIHero = withAIProps(PlainHero, {
841
+ schema: {
842
+ headline: 'Engaging headline',
843
+ subheadline: 'Supporting subheadline',
844
+ },
845
+ })
846
+
847
+ // Call without props - should generate
848
+ const result = await AIHero({})
849
+
850
+ expect(result).toBeDefined()
851
+ expect(typeof result).toBe('string')
852
+ })
853
+
854
+ it('preserves component displayName', () => {
855
+ const NamedComponent = ({ value }: { value: string }) => `<div>${value}</div>`
856
+ // @ts-expect-error - Adding displayName for testing
857
+ NamedComponent.displayName = 'NamedComponent'
858
+
859
+ const WrappedComponent = withAIProps(NamedComponent, {
860
+ schema: { value: 'Value text' },
861
+ })
862
+
863
+ // @ts-expect-error - Checking displayName
864
+ expect(WrappedComponent.displayName).toContain('NamedComponent')
865
+ })
866
+ })
867
+
868
+ describe('AIPropsProvider', () => {
869
+ it('provides AI props configuration to children', () => {
870
+ expect(AIPropsProvider).toBeDefined()
871
+ expect(typeof AIPropsProvider).toBe('function')
872
+ })
873
+
874
+ it('configures model for child components', async () => {
875
+ // AIPropsProvider should allow setting model configuration
876
+ const config = {
877
+ model: 'gpt-4',
878
+ cache: true,
879
+ }
880
+
881
+ const result = AIPropsProvider({ config, children: null })
882
+
883
+ expect(result).toBeDefined()
884
+ })
885
+
886
+ it('provides system prompt context', async () => {
887
+ const config = {
888
+ system: 'You are generating props for a marketing website.',
889
+ }
890
+
891
+ const result = AIPropsProvider({ config, children: null })
892
+
893
+ expect(result).toBeDefined()
894
+ })
895
+ })
896
+ })
897
+
898
+ // ============================================================================
899
+ // 4. AI Props with hono/jsx Tests
900
+ // ============================================================================
901
+
902
+ describe('AI props with hono/jsx', () => {
903
+ describe('props generation', () => {
904
+ it('generates props for hono/jsx components', async () => {
905
+ const schema = {
906
+ title: 'Hero section title',
907
+ subtitle: 'Hero section subtitle',
908
+ ctaText: 'Call to action button text',
909
+ }
910
+
911
+ const result = await generateProps({ schema })
912
+
913
+ expect(result.props).toBeDefined()
914
+ expect(result.props.title).toBeDefined()
915
+ expect(result.props.subtitle).toBeDefined()
916
+ expect(result.props.ctaText).toBeDefined()
917
+ })
918
+
919
+ it('uses real AI Gateway for generation', async () => {
920
+ const schema = {
921
+ productName: 'Name for a tech product',
922
+ tagline: 'Marketing tagline for the product',
923
+ }
924
+
925
+ const result = await generateProps({
926
+ schema,
927
+ context: { industry: 'technology', audience: 'developers' },
928
+ })
929
+
930
+ expect(result.props).toBeDefined()
931
+ expect(typeof result.props.productName).toBe('string')
932
+ expect((result.props.productName as string).length).toBeGreaterThan(0)
933
+ })
934
+
935
+ it('streams rendered output with props', async () => {
936
+ const AIHero = createAIComponent({
937
+ name: 'Hero',
938
+ schema: {
939
+ headline: 'Engaging headline',
940
+ subheadline: 'Supporting text',
941
+ },
942
+ render: ({ headline, subheadline }) =>
943
+ `<section class="hero"><h1>${headline}</h1><p>${subheadline}</p></section>`,
944
+ })
945
+
946
+ const stream = await renderToReadableStream(AIHero, {
947
+ context: { topic: 'Cloud Computing' },
948
+ })
949
+
950
+ expect(stream).toBeInstanceOf(ReadableStream)
951
+
952
+ const reader = stream.getReader()
953
+ const decoder = new TextDecoder()
954
+ let content = ''
955
+
956
+ while (true) {
957
+ const { done, value } = await reader.read()
958
+ if (done) break
959
+ content += decoder.decode(value, { stream: true })
960
+ }
961
+
962
+ expect(content).toContain('class="hero"')
963
+ expect(content.length).toBeGreaterThan(0)
964
+ })
965
+ })
966
+
967
+ describe('context-aware generation', () => {
968
+ it('uses frontmatter context for generation', async () => {
969
+ const schema = {
970
+ title: 'Page title relevant to the topic',
971
+ }
972
+
973
+ const context = {
974
+ topic: 'Machine Learning',
975
+ audience: 'data scientists',
976
+ }
977
+
978
+ const result = await generateProps({ schema, context })
979
+
980
+ expect(result.props.title).toBeDefined()
981
+ expect(typeof result.props.title).toBe('string')
982
+ })
983
+
984
+ it('generates different props for different contexts', async () => {
985
+ const schema = {
986
+ headline: 'Headline for the page topic',
987
+ }
988
+
989
+ const techContext = { topic: 'Artificial Intelligence' }
990
+ const artContext = { topic: 'Renaissance Painting' }
991
+
992
+ const techResult = await generateProps({ schema, context: techContext })
993
+ const artResult = await generateProps({ schema, context: artContext })
994
+
995
+ expect(techResult.props.headline).toBeDefined()
996
+ expect(artResult.props.headline).toBeDefined()
997
+ // Different contexts should ideally produce different headlines
998
+ })
999
+ })
1000
+
1001
+ describe('caching', () => {
1002
+ it('caches generated props for repeated renders', async () => {
1003
+ const schema = { title: 'Page title' }
1004
+ const context = { topic: 'Test' }
1005
+
1006
+ // First call
1007
+ const result1 = await generateProps({ schema, context })
1008
+
1009
+ // Second call with same schema and context
1010
+ const result2 = await generateProps({ schema, context })
1011
+
1012
+ // Second call should be cached
1013
+ expect(result2.cached).toBe(true)
1014
+ expect(result2.props.title).toBe(result1.props.title)
1015
+ })
1016
+
1017
+ it('invalidates cache when context changes', async () => {
1018
+ const schema = { title: 'Page title' }
1019
+
1020
+ const result1 = await generateProps({
1021
+ schema,
1022
+ context: { topic: 'First Topic' },
1023
+ })
1024
+
1025
+ const result2 = await generateProps({
1026
+ schema,
1027
+ context: { topic: 'Different Topic' },
1028
+ })
1029
+
1030
+ // Different context should not use cache
1031
+ // Note: this might still be cached in some implementations
1032
+ // but the cache key should be different
1033
+ expect(result1.props).toBeDefined()
1034
+ expect(result2.props).toBeDefined()
1035
+ })
1036
+ })
1037
+
1038
+ describe('error handling', () => {
1039
+ it('handles AI generation failures gracefully', async () => {
1040
+ const AIComponent = createAIComponent({
1041
+ name: 'FallbackTest',
1042
+ schema: { title: 'Title' },
1043
+ render: ({ title }) => `<h1>${title}</h1>`,
1044
+ fallback: { title: 'Default Title' },
1045
+ })
1046
+
1047
+ // Even if AI fails, component should render with fallback
1048
+ const result = await AIComponent({})
1049
+
1050
+ expect(result).toBeDefined()
1051
+ expect(typeof result).toBe('string')
1052
+ })
1053
+
1054
+ it('provides fallback props on error', async () => {
1055
+ const AICard = createAIComponent({
1056
+ name: 'Card',
1057
+ schema: {
1058
+ title: 'Card title',
1059
+ description: 'Card description',
1060
+ },
1061
+ render: ({ title, description }) => `<div><h3>${title}</h3><p>${description}</p></div>`,
1062
+ fallback: {
1063
+ title: 'Fallback Title',
1064
+ description: 'Fallback Description',
1065
+ },
1066
+ })
1067
+
1068
+ // Should use fallback if generation fails
1069
+ const result = await AICard({})
1070
+
1071
+ expect(result).toBeDefined()
1072
+ })
1073
+ })
1074
+ })
1075
+
1076
+ // ============================================================================
1077
+ // 5. Integration with PropsServiceCore (Worker RPC)
1078
+ // ============================================================================
1079
+
1080
+ describe('hono/jsx integration with PropsServiceCore', () => {
1081
+ let service: PropsServiceCore
1082
+
1083
+ beforeEach(() => {
1084
+ // Clear local cache to avoid stale responses
1085
+ clearCache()
1086
+ service = new PropsServiceCore()
1087
+ })
1088
+
1089
+ it('generates props for hono/jsx components via RPC', async () => {
1090
+ const schema = {
1091
+ title: 'Component title',
1092
+ description: 'Component description',
1093
+ }
1094
+
1095
+ // Add unique context to ensure fresh cache key at AI Gateway level
1096
+ const context = {
1097
+ testId: `rpc-test-${Date.now()}`,
1098
+ purpose: 'hono/jsx integration test',
1099
+ }
1100
+
1101
+ const result = await service.generate({ schema, context })
1102
+
1103
+ expect(result.props).toBeDefined()
1104
+ expect(result.props.title).toBeDefined()
1105
+ expect(result.props.description).toBeDefined()
1106
+ })
1107
+
1108
+ it('validates generated props for hono/jsx components', async () => {
1109
+ const schema = {
1110
+ headline: 'Hero headline',
1111
+ subheadline: 'Hero subheadline',
1112
+ }
1113
+
1114
+ const result = await service.generate({ schema })
1115
+ const validation = service.validate(result.props, schema)
1116
+
1117
+ expect(validation.valid).toBe(true)
1118
+ })
1119
+
1120
+ it('merges partial props with AI-generated via service', async () => {
1121
+ const schema = {
1122
+ title: 'Card title',
1123
+ description: 'Card description',
1124
+ image: 'Image URL',
1125
+ }
1126
+
1127
+ const partialProps = { title: 'Explicit Title' }
1128
+
1129
+ const merged = await service.mergeWithGenerated(schema, partialProps)
1130
+
1131
+ expect(merged.title).toBe('Explicit Title')
1132
+ expect(merged.description).toBeDefined()
1133
+ expect(merged.image).toBeDefined()
1134
+ })
1135
+ })
1136
+
1137
+ // ============================================================================
1138
+ // 6. End-to-End Streaming with AI Props Tests
1139
+ // ============================================================================
1140
+
1141
+ describe('end-to-end streaming with AI props', () => {
1142
+ it('streams hono/jsx page with AI-generated component props', async () => {
1143
+ // Define page components
1144
+ const Hero = createAIComponent({
1145
+ name: 'Hero',
1146
+ schema: {
1147
+ headline: 'Main page headline',
1148
+ subheadline: 'Supporting text',
1149
+ },
1150
+ render: ({ headline, subheadline }) =>
1151
+ `<section class="hero"><h1>${headline}</h1><p>${subheadline}</p></section>`,
1152
+ })
1153
+
1154
+ const Card = createAIComponent({
1155
+ name: 'Card',
1156
+ schema: {
1157
+ title: 'Card title',
1158
+ body: 'Card body text',
1159
+ },
1160
+ render: ({ title, body }) =>
1161
+ `<article class="card"><h2>${title}</h2><p>${body}</p></article>`,
1162
+ })
1163
+
1164
+ // Define page layout
1165
+ const Page = async () => {
1166
+ const heroContent = await Hero({ context: { topic: 'AI Applications' } })
1167
+ const cardContent = await Card({ context: { topic: 'Getting Started' } })
1168
+
1169
+ return `
1170
+ <!DOCTYPE html>
1171
+ <html>
1172
+ <head><title>AI Props Demo</title></head>
1173
+ <body>
1174
+ ${heroContent}
1175
+ ${cardContent}
1176
+ </body>
1177
+ </html>
1178
+ `
1179
+ }
1180
+
1181
+ const response = await streamJSXResponse(Page, {})
1182
+
1183
+ expect(response).toBeInstanceOf(Response)
1184
+ expect(response.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
1185
+
1186
+ const content = await response.text()
1187
+
1188
+ expect(content).toContain('<!DOCTYPE html>')
1189
+ expect(content).toContain('class="hero"')
1190
+ expect(content).toContain('class="card"')
1191
+ })
1192
+
1193
+ it('includes hydration data for client-side rehydration', async () => {
1194
+ const AIComponent = createAIComponent({
1195
+ name: 'Interactive',
1196
+ schema: { label: 'Button label' },
1197
+ render: ({ label }) => `<button data-hydrate="true">${label}</button>`,
1198
+ })
1199
+
1200
+ const renderer = createStreamingRenderer({
1201
+ shell: (content: string, hydration: string) =>
1202
+ `<html><body>${content}<script>window.__HYDRATION_DATA__=${hydration}</script></body></html>`,
1203
+ includeHydration: true,
1204
+ })
1205
+
1206
+ const stream = await renderer.render(AIComponent, {})
1207
+ const reader = stream.getReader()
1208
+ const decoder = new TextDecoder()
1209
+ let content = ''
1210
+
1211
+ while (true) {
1212
+ const { done, value } = await reader.read()
1213
+ if (done) break
1214
+ content += decoder.decode(value, { stream: true })
1215
+ }
1216
+
1217
+ expect(content).toContain('__HYDRATION_DATA__')
1218
+ expect(content).toContain('data-hydrate="true"')
1219
+ })
1220
+
1221
+ it('supports progressive enhancement with AI props', async () => {
1222
+ // First render returns basic HTML
1223
+ // Then AI props are generated and sent as updates
1224
+
1225
+ const BasicComponent = ({ title = 'Loading...' }: { title?: string }) =>
1226
+ `<h1 data-ai-prop="title">${title}</h1>`
1227
+
1228
+ const AIEnhancedComponent = createAIComponent({
1229
+ name: 'Enhanced',
1230
+ schema: { title: 'Dynamic title' },
1231
+ render: BasicComponent,
1232
+ progressive: true, // Enable progressive enhancement
1233
+ })
1234
+
1235
+ const stream = await renderToReadableStream(
1236
+ AIEnhancedComponent,
1237
+ {},
1238
+ {
1239
+ progressive: true,
1240
+ }
1241
+ )
1242
+
1243
+ const reader = stream.getReader()
1244
+ const chunks: string[] = []
1245
+ const decoder = new TextDecoder()
1246
+
1247
+ while (true) {
1248
+ const { done, value } = await reader.read()
1249
+ if (done) break
1250
+ chunks.push(decoder.decode(value, { stream: true }))
1251
+ }
1252
+
1253
+ // Should have received initial render then updates
1254
+ expect(chunks.length).toBeGreaterThan(0)
1255
+ const content = chunks.join('')
1256
+ expect(content).toContain('data-ai-prop="title"')
1257
+ })
1258
+ })