ai-props 2.1.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dev.vars +2 -0
- package/CHANGELOG.md +24 -0
- package/README.md +131 -118
- package/package.json +30 -4
- package/src/ai.ts +12 -31
- package/src/cascade.ts +795 -0
- package/src/client.ts +440 -0
- package/src/durable-cascade.ts +743 -0
- package/src/event-bridge.ts +478 -0
- package/src/generate.ts +14 -12
- package/src/hoc.ts +15 -19
- package/src/hono-jsx.ts +675 -0
- package/src/index.ts +30 -0
- package/src/mdx-types.ts +169 -0
- package/src/mdx-utils.ts +437 -0
- package/src/mdx.ts +1008 -0
- package/src/rpc.ts +614 -0
- package/src/streaming.ts +618 -0
- package/src/validate.ts +15 -29
- package/src/worker.ts +547 -0
- package/test/cascade.test.ts +338 -0
- package/test/durable-cascade.test.ts +319 -0
- package/test/event-bridge.test.ts +351 -0
- package/test/generate.test.ts +6 -16
- package/test/mdx.test.ts +817 -0
- package/test/worker/capnweb-rpc.test.ts +1084 -0
- package/test/worker/full-flow.integration.test.ts +1463 -0
- package/test/worker/hono-jsx.test.ts +1258 -0
- package/test/worker/mdx-parsing.test.ts +1148 -0
- package/test/worker/setup.ts +56 -0
- package/test/worker.test.ts +595 -0
- package/tsconfig.json +2 -1
- package/vitest.config.js +6 -0
- package/vitest.config.ts +15 -1
- package/vitest.workers.config.ts +58 -0
- package/wrangler.jsonc +27 -0
- package/.turbo/turbo-build.log +0 -5
- package/dist/ai.d.ts +0 -125
- package/dist/ai.d.ts.map +0 -1
- package/dist/ai.js +0 -199
- package/dist/ai.js.map +0 -1
- package/dist/cache.d.ts +0 -66
- package/dist/cache.d.ts.map +0 -1
- package/dist/cache.js +0 -183
- package/dist/cache.js.map +0 -1
- package/dist/generate.d.ts +0 -69
- package/dist/generate.d.ts.map +0 -1
- package/dist/generate.js +0 -221
- package/dist/generate.js.map +0 -1
- package/dist/hoc.d.ts +0 -164
- package/dist/hoc.d.ts.map +0 -1
- package/dist/hoc.js +0 -236
- package/dist/hoc.js.map +0 -1
- package/dist/index.d.ts +0 -15
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -21
- package/dist/index.js.map +0 -1
- package/dist/types.d.ts +0 -152
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -7
- package/dist/types.js.map +0 -1
- package/dist/validate.d.ts +0 -58
- package/dist/validate.d.ts.map +0 -1
- package/dist/validate.js +0 -253
- package/dist/validate.js.map +0 -1
- package/src/ai.js +0 -198
- package/src/cache.js +0 -182
- package/src/generate.js +0 -220
- package/src/hoc.js +0 -235
- package/src/index.js +0 -20
- package/src/types.js +0 -6
- package/src/validate.js +0 -252
|
@@ -0,0 +1,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
|
+
})
|