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.
Files changed (72) hide show
  1. package/.dev.vars +2 -0
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +131 -118
  4. package/package.json +30 -4
  5. package/src/ai.ts +12 -31
  6. package/src/cascade.ts +795 -0
  7. package/src/client.ts +440 -0
  8. package/src/durable-cascade.ts +743 -0
  9. package/src/event-bridge.ts +478 -0
  10. package/src/generate.ts +14 -12
  11. package/src/hoc.ts +15 -19
  12. package/src/hono-jsx.ts +675 -0
  13. package/src/index.ts +30 -0
  14. package/src/mdx-types.ts +169 -0
  15. package/src/mdx-utils.ts +437 -0
  16. package/src/mdx.ts +1008 -0
  17. package/src/rpc.ts +614 -0
  18. package/src/streaming.ts +618 -0
  19. package/src/validate.ts +15 -29
  20. package/src/worker.ts +547 -0
  21. package/test/cascade.test.ts +338 -0
  22. package/test/durable-cascade.test.ts +319 -0
  23. package/test/event-bridge.test.ts +351 -0
  24. package/test/generate.test.ts +6 -16
  25. package/test/mdx.test.ts +817 -0
  26. package/test/worker/capnweb-rpc.test.ts +1084 -0
  27. package/test/worker/full-flow.integration.test.ts +1463 -0
  28. package/test/worker/hono-jsx.test.ts +1258 -0
  29. package/test/worker/mdx-parsing.test.ts +1148 -0
  30. package/test/worker/setup.ts +56 -0
  31. package/test/worker.test.ts +595 -0
  32. package/tsconfig.json +2 -1
  33. package/vitest.config.js +6 -0
  34. package/vitest.config.ts +15 -1
  35. package/vitest.workers.config.ts +58 -0
  36. package/wrangler.jsonc +27 -0
  37. package/.turbo/turbo-build.log +0 -5
  38. package/dist/ai.d.ts +0 -125
  39. package/dist/ai.d.ts.map +0 -1
  40. package/dist/ai.js +0 -199
  41. package/dist/ai.js.map +0 -1
  42. package/dist/cache.d.ts +0 -66
  43. package/dist/cache.d.ts.map +0 -1
  44. package/dist/cache.js +0 -183
  45. package/dist/cache.js.map +0 -1
  46. package/dist/generate.d.ts +0 -69
  47. package/dist/generate.d.ts.map +0 -1
  48. package/dist/generate.js +0 -221
  49. package/dist/generate.js.map +0 -1
  50. package/dist/hoc.d.ts +0 -164
  51. package/dist/hoc.d.ts.map +0 -1
  52. package/dist/hoc.js +0 -236
  53. package/dist/hoc.js.map +0 -1
  54. package/dist/index.d.ts +0 -15
  55. package/dist/index.d.ts.map +0 -1
  56. package/dist/index.js +0 -21
  57. package/dist/index.js.map +0 -1
  58. package/dist/types.d.ts +0 -152
  59. package/dist/types.d.ts.map +0 -1
  60. package/dist/types.js +0 -7
  61. package/dist/types.js.map +0 -1
  62. package/dist/validate.d.ts +0 -58
  63. package/dist/validate.d.ts.map +0 -1
  64. package/dist/validate.js +0 -253
  65. package/dist/validate.js.map +0 -1
  66. package/src/ai.js +0 -198
  67. package/src/cache.js +0 -182
  68. package/src/generate.js +0 -220
  69. package/src/hoc.js +0 -235
  70. package/src/index.js +0 -20
  71. package/src/types.js +0 -6
  72. package/src/validate.js +0 -252
@@ -0,0 +1,1148 @@
1
+ /**
2
+ * Tests for MDX parsing and rendering with AI-generated props
3
+ *
4
+ * RED phase: These tests define the expected behavior for MDX 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 MDX integration should:
10
+ * 1. Parse MDX content strings (with frontmatter)
11
+ * 2. Extract component prop schemas from MDX
12
+ * 3. Generate AI props for components found in MDX
13
+ * 4. Render MDX with injected props
14
+ * 5. Support custom component renderers
15
+ * 6. Support streaming MDX rendering
16
+ * 7. Handle errors gracefully
17
+ *
18
+ * RED phase tests:
19
+ * - Some tests PASS because basic mdx.ts implementation exists
20
+ * - AI generation tests FAIL because API keys are not configured in test env
21
+ * - Some parsing tests FAIL because edge cases need implementation
22
+ * - Streaming tests may need implementation refinement
23
+ *
24
+ * Bead: aip-nw0g
25
+ *
26
+ * @packageDocumentation
27
+ */
28
+
29
+ import { describe, it, expect, beforeEach } from 'vitest'
30
+ import { env } from 'cloudflare:test'
31
+
32
+ // ============================================================================
33
+ // MDX integration imports
34
+ // Some functions exist, but tests may fail due to:
35
+ // 1. Missing AI Gateway bindings/API keys
36
+ // 2. Edge cases in parsing that need implementation
37
+ // 3. Streaming implementation refinements needed
38
+ // ============================================================================
39
+ import {
40
+ parseMDX,
41
+ extractComponentSchemas,
42
+ renderMDXWithProps,
43
+ createMDXPropsGenerator,
44
+ compileMDX,
45
+ streamMDXWithProps,
46
+ } from '../../src/mdx.js'
47
+
48
+ // Import existing ai-props modules to verify integration
49
+ import { PropsServiceCore } from '../../src/worker.js'
50
+
51
+ // ============================================================================
52
+ // Type definitions for expected MDX integration interfaces
53
+ // ============================================================================
54
+
55
+ /**
56
+ * Result of parsing an MDX string
57
+ */
58
+ interface ParsedMDX {
59
+ /** Raw MDX body content (without frontmatter) */
60
+ body: string
61
+ /** Full content including frontmatter */
62
+ content: string
63
+ /** Parsed frontmatter key-value pairs */
64
+ frontmatter: Record<string, unknown>
65
+ /** List of JSX component names found in the MDX */
66
+ components: string[]
67
+ /** Map of component names to their detected props */
68
+ componentProps: Record<string, Record<string, unknown>>
69
+ }
70
+
71
+ /**
72
+ * Component schema map extracted from MDX
73
+ */
74
+ type ComponentSchemaMap = Record<string, Record<string, unknown>>
75
+
76
+ /**
77
+ * Options for rendering MDX with props
78
+ */
79
+ interface RenderMDXOptions {
80
+ /** Custom component renderers */
81
+ components?: Record<string, (props: Record<string, unknown>) => string>
82
+ /** Whether to return a ReadableStream instead of a string */
83
+ stream?: boolean
84
+ }
85
+
86
+ /**
87
+ * Options for creating an MDX props generator
88
+ */
89
+ interface MDXPropsGeneratorOptions {
90
+ /** Map of component names to their prop schemas */
91
+ schemas: ComponentSchemaMap
92
+ /** Whether to cache generated props */
93
+ cache?: boolean
94
+ /** AI model to use for generation */
95
+ model?: string
96
+ }
97
+
98
+ // ============================================================================
99
+ // 1. MDX Parsing Tests
100
+ // ============================================================================
101
+
102
+ describe('MDX parsing', () => {
103
+ describe('parseMDX()', () => {
104
+ it('parses simple MDX content string', () => {
105
+ const mdx = `# Hello World
106
+
107
+ This is a simple MDX document.`
108
+
109
+ const result = parseMDX(mdx)
110
+
111
+ expect(result).toBeDefined()
112
+ expect(result.content).toBeDefined()
113
+ expect(result.frontmatter).toEqual({})
114
+ })
115
+
116
+ it('parses MDX content with frontmatter', () => {
117
+ const mdx = `---
118
+ title: My Page
119
+ description: A test page
120
+ author: Test Author
121
+ ---
122
+
123
+ # {title}
124
+
125
+ {description}`
126
+
127
+ const result = parseMDX(mdx)
128
+
129
+ expect(result.frontmatter).toBeDefined()
130
+ expect(result.frontmatter.title).toBe('My Page')
131
+ expect(result.frontmatter.description).toBe('A test page')
132
+ expect(result.frontmatter.author).toBe('Test Author')
133
+ })
134
+
135
+ it('parses MDX content with YAML frontmatter types', () => {
136
+ const mdx = `---
137
+ title: My Page
138
+ count: 5
139
+ published: true
140
+ tags:
141
+ - ai
142
+ - props
143
+ ---
144
+
145
+ # Content`
146
+
147
+ const result = parseMDX(mdx)
148
+
149
+ expect(result.frontmatter.title).toBe('My Page')
150
+ expect(result.frontmatter.count).toBe(5)
151
+ expect(result.frontmatter.published).toBe(true)
152
+ expect(result.frontmatter.tags).toEqual(['ai', 'props'])
153
+ })
154
+
155
+ it('separates frontmatter from body content', () => {
156
+ const mdx = `---
157
+ title: Hello
158
+ ---
159
+
160
+ # Body Content
161
+
162
+ Some paragraph text.`
163
+
164
+ const result = parseMDX(mdx)
165
+
166
+ expect(result.body).not.toContain('---')
167
+ expect(result.body).not.toContain('title: Hello')
168
+ expect(result.body).toContain('# Body Content')
169
+ expect(result.body).toContain('Some paragraph text.')
170
+ })
171
+
172
+ it('handles MDX with no frontmatter', () => {
173
+ const mdx = `# No Frontmatter
174
+
175
+ Just regular content.`
176
+
177
+ const result = parseMDX(mdx)
178
+
179
+ expect(result.frontmatter).toEqual({})
180
+ expect(result.body).toContain('# No Frontmatter')
181
+ })
182
+
183
+ it('handles empty MDX string', () => {
184
+ const result = parseMDX('')
185
+
186
+ expect(result).toBeDefined()
187
+ expect(result.frontmatter).toEqual({})
188
+ expect(result.body).toBe('')
189
+ })
190
+
191
+ it('identifies JSX components in MDX', () => {
192
+ const mdx = `---
193
+ title: Page
194
+ ---
195
+
196
+ # Hello
197
+
198
+ <Hero title="Welcome" />
199
+
200
+ <Card>
201
+ <CardBody>Some content</CardBody>
202
+ </Card>
203
+
204
+ <Footer />`
205
+
206
+ const result = parseMDX(mdx)
207
+
208
+ expect(result.components).toBeDefined()
209
+ expect(result.components).toContain('Hero')
210
+ expect(result.components).toContain('Card')
211
+ expect(result.components).toContain('CardBody')
212
+ expect(result.components).toContain('Footer')
213
+ })
214
+
215
+ it('identifies components with props in MDX', () => {
216
+ const mdx = `<Button variant="primary" size="lg" disabled>Click Me</Button>
217
+ <Input placeholder="Enter text" type="email" />`
218
+
219
+ const result = parseMDX(mdx)
220
+
221
+ expect(result.componentProps).toBeDefined()
222
+ expect(result.componentProps.Button).toEqual({
223
+ variant: 'primary',
224
+ size: 'lg',
225
+ disabled: true,
226
+ })
227
+ expect(result.componentProps.Input).toEqual({
228
+ placeholder: 'Enter text',
229
+ type: 'email',
230
+ })
231
+ })
232
+
233
+ it('does not include lowercase HTML elements as components', () => {
234
+ const mdx = `<div class="wrapper">
235
+ <p>Paragraph text</p>
236
+ <CustomComponent title="test" />
237
+ </div>`
238
+
239
+ const result = parseMDX(mdx)
240
+
241
+ expect(result.components).toContain('CustomComponent')
242
+ expect(result.components).not.toContain('div')
243
+ expect(result.components).not.toContain('p')
244
+ })
245
+
246
+ it('handles frontmatter with prop schemas defined', () => {
247
+ const mdx = `---
248
+ title: Product Page
249
+ $schema:
250
+ Hero:
251
+ title: Hero heading text
252
+ subtitle: Hero subheading
253
+ Card:
254
+ title: Card title
255
+ description: Card description
256
+ ---
257
+
258
+ <Hero />
259
+ <Card />`
260
+
261
+ const result = parseMDX(mdx)
262
+
263
+ expect(result.frontmatter.$schema).toBeDefined()
264
+ const schema = result.frontmatter.$schema as Record<string, unknown>
265
+ expect(schema.Hero).toBeDefined()
266
+ expect(schema.Card).toBeDefined()
267
+ })
268
+ })
269
+
270
+ describe('extractComponentSchemas()', () => {
271
+ it('extracts prop schemas from MDX component usage', () => {
272
+ const mdx = `<UserCard name="John" bio="A developer" avatar="/img.png" />`
273
+
274
+ const schemas = extractComponentSchemas(mdx)
275
+
276
+ expect(schemas).toBeDefined()
277
+ expect(schemas.UserCard).toBeDefined()
278
+ expect(Object.keys(schemas.UserCard)).toContain('name')
279
+ expect(Object.keys(schemas.UserCard)).toContain('bio')
280
+ expect(Object.keys(schemas.UserCard)).toContain('avatar')
281
+ })
282
+
283
+ it('extracts schemas from multiple component instances', () => {
284
+ const mdx = `<Card title="First" />
285
+ <Card title="Second" description="With desc" />`
286
+
287
+ const schemas = extractComponentSchemas(mdx)
288
+
289
+ // Should merge schemas from multiple instances
290
+ expect(schemas.Card).toBeDefined()
291
+ expect(Object.keys(schemas.Card)).toContain('title')
292
+ expect(Object.keys(schemas.Card)).toContain('description')
293
+ })
294
+
295
+ it('identifies components that need AI-generated props', () => {
296
+ const mdx = `<Hero />
297
+ <Card title="Provided" />
298
+ <Footer />`
299
+
300
+ const schemas = extractComponentSchemas(mdx)
301
+
302
+ // Components with no props should be flagged as needing generation
303
+ expect(schemas.Hero).toBeDefined()
304
+ expect(Object.keys(schemas.Hero)).toHaveLength(0)
305
+ expect(schemas.Footer).toBeDefined()
306
+ expect(Object.keys(schemas.Footer)).toHaveLength(0)
307
+ })
308
+
309
+ it('handles components with expression props', () => {
310
+ const mdx = `<Widget count={42} active={true} data={{ key: 'value' }} />`
311
+
312
+ const schemas = extractComponentSchemas(mdx)
313
+
314
+ expect(schemas.Widget).toBeDefined()
315
+ expect(Object.keys(schemas.Widget)).toContain('count')
316
+ expect(Object.keys(schemas.Widget)).toContain('active')
317
+ expect(Object.keys(schemas.Widget)).toContain('data')
318
+ })
319
+
320
+ it('returns empty object for MDX with no components', () => {
321
+ const mdx = `# Just Markdown
322
+
323
+ Regular paragraph text with **bold** and *italic*.`
324
+
325
+ const schemas = extractComponentSchemas(mdx)
326
+
327
+ expect(schemas).toEqual({})
328
+ })
329
+
330
+ it('excludes lowercase HTML elements from schemas', () => {
331
+ const mdx = `<div><span>text</span></div>
332
+ <MyComponent prop="value" />`
333
+
334
+ const schemas = extractComponentSchemas(mdx)
335
+
336
+ expect(schemas).not.toHaveProperty('div')
337
+ expect(schemas).not.toHaveProperty('span')
338
+ expect(schemas.MyComponent).toBeDefined()
339
+ })
340
+ })
341
+ })
342
+
343
+ // ============================================================================
344
+ // 2. MDX Props Generation Tests
345
+ // ============================================================================
346
+
347
+ describe('MDX props generation', () => {
348
+ describe('createMDXPropsGenerator()', () => {
349
+ it('creates a props generator for MDX content', () => {
350
+ const generator = createMDXPropsGenerator({
351
+ schemas: {
352
+ Hero: {
353
+ title: 'Hero section title',
354
+ subtitle: 'Hero section subtitle',
355
+ },
356
+ Card: {
357
+ title: 'Card title',
358
+ description: 'Card description',
359
+ },
360
+ },
361
+ })
362
+
363
+ expect(generator).toBeDefined()
364
+ expect(typeof generator.generate).toBe('function')
365
+ })
366
+
367
+ it('generates props for components in MDX using real AI', async () => {
368
+ const generator = createMDXPropsGenerator({
369
+ schemas: {
370
+ Hero: {
371
+ title: 'Hero section title',
372
+ subtitle: 'Hero section subtitle',
373
+ },
374
+ },
375
+ })
376
+
377
+ const mdx = `---
378
+ topic: AI Applications
379
+ ---
380
+
381
+ <Hero />`
382
+
383
+ const result = await generator.generate(mdx)
384
+
385
+ expect(result).toBeDefined()
386
+ expect(result.Hero).toBeDefined()
387
+ expect(result.Hero.title).toBeDefined()
388
+ expect(typeof result.Hero.title).toBe('string')
389
+ expect(result.Hero.subtitle).toBeDefined()
390
+ expect(typeof result.Hero.subtitle).toBe('string')
391
+ })
392
+
393
+ it('uses frontmatter context for generation', async () => {
394
+ const generator = createMDXPropsGenerator({
395
+ schemas: {
396
+ Hero: {
397
+ title: 'A title relevant to the page topic',
398
+ },
399
+ },
400
+ })
401
+
402
+ const mdx = `---
403
+ topic: Machine Learning
404
+ audience: developers
405
+ ---
406
+
407
+ <Hero />`
408
+
409
+ const result = await generator.generate(mdx)
410
+
411
+ expect(result.Hero).toBeDefined()
412
+ expect(result.Hero.title).toBeDefined()
413
+ expect(typeof result.Hero.title).toBe('string')
414
+ // The generated title should be contextually relevant
415
+ // (we cannot assert exact content, but it should be non-empty)
416
+ expect((result.Hero.title as string).length).toBeGreaterThan(0)
417
+ })
418
+
419
+ it('preserves explicitly provided props', async () => {
420
+ const generator = createMDXPropsGenerator({
421
+ schemas: {
422
+ Card: {
423
+ title: 'Card title',
424
+ description: 'Card description',
425
+ image: 'Image URL',
426
+ },
427
+ },
428
+ })
429
+
430
+ const mdx = `<Card title="My Explicit Title" />`
431
+
432
+ const result = await generator.generate(mdx)
433
+
434
+ // Explicit props should be preserved
435
+ expect(result.Card.title).toBe('My Explicit Title')
436
+ // Missing props should be generated
437
+ expect(result.Card.description).toBeDefined()
438
+ expect(result.Card.image).toBeDefined()
439
+ })
440
+
441
+ it('only generates props for schemas that were provided', async () => {
442
+ const generator = createMDXPropsGenerator({
443
+ schemas: {
444
+ DynamicRecommendation: {
445
+ product: 'Recommended product name',
446
+ reason: 'Why this product is recommended',
447
+ },
448
+ },
449
+ })
450
+
451
+ const mdx = `<StaticBanner text="Sale!" />
452
+ <DynamicRecommendation />
453
+ <StaticFooter year={2026} />`
454
+
455
+ const result = await generator.generate(mdx)
456
+
457
+ // Only DynamicRecommendation should have generated props
458
+ expect(result.DynamicRecommendation).toBeDefined()
459
+ expect(result.DynamicRecommendation.product).toBeDefined()
460
+ expect(result.DynamicRecommendation.reason).toBeDefined()
461
+ // Static components should not be in generated props
462
+ expect(result.StaticBanner).toBeUndefined()
463
+ expect(result.StaticFooter).toBeUndefined()
464
+ })
465
+
466
+ it('caches generated props per component', async () => {
467
+ const generator = createMDXPropsGenerator({
468
+ schemas: {
469
+ Hero: {
470
+ title: 'Hero title',
471
+ },
472
+ },
473
+ cache: true,
474
+ })
475
+
476
+ const mdx = `<Hero />`
477
+
478
+ // First generation
479
+ const result1 = await generator.generate(mdx)
480
+
481
+ // Second generation (same content) should use cache
482
+ const result2 = await generator.generate(mdx)
483
+
484
+ expect(result1.Hero.title).toEqual(result2.Hero.title)
485
+ })
486
+
487
+ it('handles async prop generation', async () => {
488
+ const generator = createMDXPropsGenerator({
489
+ schemas: {
490
+ AsyncWidget: {
491
+ data: 'Complex data structure description',
492
+ status: 'Widget status text',
493
+ },
494
+ },
495
+ })
496
+
497
+ const mdx = `<AsyncWidget />`
498
+
499
+ const result = await generator.generate(mdx)
500
+
501
+ expect(result.AsyncWidget).toBeDefined()
502
+ expect(result.AsyncWidget.data).toBeDefined()
503
+ expect(result.AsyncWidget.status).toBeDefined()
504
+ })
505
+
506
+ it('passes custom model configuration', async () => {
507
+ const generator = createMDXPropsGenerator({
508
+ schemas: {
509
+ Hero: { title: 'Title' },
510
+ },
511
+ // Use full model ID to avoid alias resolution issues in bundled environments
512
+ model: 'openai/gpt-4o',
513
+ })
514
+
515
+ const mdx = `<Hero />`
516
+
517
+ const result = await generator.generate(mdx)
518
+
519
+ expect(result.Hero).toBeDefined()
520
+ expect(result.Hero.title).toBeDefined()
521
+ })
522
+
523
+ it('handles missing component schemas gracefully', async () => {
524
+ const generator = createMDXPropsGenerator({
525
+ schemas: {},
526
+ })
527
+
528
+ const mdx = `<UnknownComponent />`
529
+
530
+ // Should not throw, but should return empty or skip unknown components
531
+ const result = await generator.generate(mdx)
532
+
533
+ expect(result).toBeDefined()
534
+ expect(result.UnknownComponent).toBeUndefined()
535
+ })
536
+ })
537
+ })
538
+
539
+ // ============================================================================
540
+ // 3. MDX Rendering with Props Tests
541
+ // ============================================================================
542
+
543
+ describe('MDX rendering with props', () => {
544
+ describe('renderMDXWithProps()', () => {
545
+ it('renders MDX string with injected props', async () => {
546
+ const mdx = `# Hello
547
+
548
+ <Hero title="Welcome" subtitle="To the future" />`
549
+
550
+ const result = await renderMDXWithProps(mdx, {
551
+ Hero: {
552
+ title: 'Welcome',
553
+ subtitle: 'To the future',
554
+ },
555
+ })
556
+
557
+ expect(result).toBeDefined()
558
+ expect(typeof result).toBe('string')
559
+ expect(result).toContain('Welcome')
560
+ expect(result).toContain('To the future')
561
+ })
562
+
563
+ it('renders MDX with generated props for components missing props', async () => {
564
+ const mdx = `<Hero />`
565
+
566
+ const result = await renderMDXWithProps(mdx, {
567
+ Hero: {
568
+ title: 'Generated Title',
569
+ subtitle: 'Generated Subtitle',
570
+ },
571
+ })
572
+
573
+ expect(result).toContain('Generated Title')
574
+ })
575
+
576
+ it('passes props to custom component renderers', async () => {
577
+ const mdx = `<Card title="Test" description="A card" />`
578
+
579
+ const components = {
580
+ Card: (props: Record<string, unknown>) => {
581
+ return `<div class="card"><h2>${props.title}</h2><p>${props.description}</p></div>`
582
+ },
583
+ }
584
+
585
+ const result = await renderMDXWithProps(
586
+ mdx,
587
+ { Card: { title: 'Test', description: 'A card' } },
588
+ { components }
589
+ )
590
+
591
+ expect(result).toContain('Test')
592
+ expect(result).toContain('A card')
593
+ })
594
+
595
+ it('preserves component tree structure', async () => {
596
+ const mdx = `<Layout>
597
+ <Header title="Page Title" />
598
+ <Main>
599
+ <Card title="Card 1" />
600
+ <Card title="Card 2" />
601
+ </Main>
602
+ <Footer />
603
+ </Layout>`
604
+
605
+ const props = {
606
+ Layout: {},
607
+ Header: { title: 'Page Title' },
608
+ Main: {},
609
+ Card: { title: 'Card Title' },
610
+ Footer: { copyright: '2026' },
611
+ }
612
+
613
+ const result = await renderMDXWithProps(mdx, props)
614
+
615
+ expect(result).toBeDefined()
616
+ expect(typeof result).toBe('string')
617
+ })
618
+
619
+ it('renders MDX with frontmatter variables interpolated', async () => {
620
+ const mdx = `---
621
+ title: Dynamic Page
622
+ author: AI
623
+ ---
624
+
625
+ # {title}
626
+
627
+ Written by {author}.`
628
+
629
+ const result = await renderMDXWithProps(mdx, {})
630
+
631
+ expect(result).toContain('Dynamic Page')
632
+ expect(result).toContain('AI')
633
+ })
634
+
635
+ it('merges provided props with generated props', async () => {
636
+ const mdx = `<ProductCard name="Widget" />`
637
+
638
+ const result = await renderMDXWithProps(mdx, {
639
+ ProductCard: {
640
+ name: 'Widget',
641
+ price: 29.99,
642
+ description: 'A useful widget',
643
+ },
644
+ })
645
+
646
+ expect(result).toContain('Widget')
647
+ })
648
+
649
+ it('handles render errors for invalid props', async () => {
650
+ const mdx = `<StrictComponent />`
651
+
652
+ // Rendering with null props should throw
653
+ await expect(
654
+ renderMDXWithProps(mdx, {
655
+ StrictComponent: null as unknown as Record<string, unknown>,
656
+ })
657
+ ).rejects.toThrow()
658
+ })
659
+ })
660
+
661
+ describe('streamMDXWithProps()', () => {
662
+ it('returns a ReadableStream for MDX content', async () => {
663
+ const mdx = `# Hello
664
+
665
+ <Hero title="Welcome" />
666
+
667
+ Some content after.`
668
+
669
+ const stream = await streamMDXWithProps(mdx, {
670
+ Hero: { title: 'Welcome' },
671
+ })
672
+
673
+ expect(stream).toBeDefined()
674
+ expect(stream).toBeInstanceOf(ReadableStream)
675
+ })
676
+
677
+ it('stream contains the rendered content', async () => {
678
+ const mdx = `# Streaming Test
679
+
680
+ <Card title="Stream Card" description="Streamed content" />`
681
+
682
+ const stream = await streamMDXWithProps(mdx, {
683
+ Card: { title: 'Stream Card', description: 'Streamed content' },
684
+ })
685
+
686
+ const reader = stream.getReader()
687
+ const decoder = new TextDecoder()
688
+ let content = ''
689
+
690
+ while (true) {
691
+ const { done, value } = await reader.read()
692
+ if (done) break
693
+ content += decoder.decode(value, { stream: true })
694
+ }
695
+
696
+ expect(content.length).toBeGreaterThan(0)
697
+ expect(content).toContain('Stream Card')
698
+ })
699
+
700
+ it('streams chunks progressively', async () => {
701
+ const mdx = `# Part 1
702
+
703
+ <Section1 title="First Section" />
704
+
705
+ # Part 2
706
+
707
+ <Section2 title="Second Section" />
708
+
709
+ # Part 3
710
+
711
+ <Section3 title="Third Section" />`
712
+
713
+ const stream = await streamMDXWithProps(mdx, {
714
+ Section1: { title: 'First Section' },
715
+ Section2: { title: 'Second Section' },
716
+ Section3: { title: 'Third Section' },
717
+ })
718
+
719
+ const reader = stream.getReader()
720
+ const chunks: Uint8Array[] = []
721
+
722
+ while (true) {
723
+ const { done, value } = await reader.read()
724
+ if (done) break
725
+ chunks.push(value)
726
+ }
727
+
728
+ // Should have received multiple chunks
729
+ expect(chunks.length).toBeGreaterThan(0)
730
+ })
731
+
732
+ it('supports custom component renderers in streaming mode', async () => {
733
+ const mdx = `<Badge label="New" color="green" />`
734
+
735
+ const stream = await streamMDXWithProps(
736
+ mdx,
737
+ { Badge: { label: 'New', color: 'green' } },
738
+ {
739
+ components: {
740
+ Badge: (props: Record<string, unknown>) =>
741
+ `<span class="badge badge-${props.color}">${props.label}</span>`,
742
+ },
743
+ }
744
+ )
745
+
746
+ const reader = stream.getReader()
747
+ const decoder = new TextDecoder()
748
+ let content = ''
749
+
750
+ while (true) {
751
+ const { done, value } = await reader.read()
752
+ if (done) break
753
+ content += decoder.decode(value, { stream: true })
754
+ }
755
+
756
+ expect(content).toContain('New')
757
+ })
758
+ })
759
+
760
+ describe('compileMDX()', () => {
761
+ it('compiles MDX string to executable function', async () => {
762
+ const mdx = `# Hello World
763
+
764
+ This is content.`
765
+
766
+ const compiled = await compileMDX(mdx)
767
+
768
+ expect(compiled).toBeDefined()
769
+ expect(typeof compiled).toBe('function')
770
+ })
771
+
772
+ it('compiled function accepts props argument', async () => {
773
+ const mdx = `<Greeting name="World" />`
774
+
775
+ const compiled = await compileMDX(mdx)
776
+ const result = compiled({
777
+ Greeting: { name: 'World' },
778
+ })
779
+
780
+ expect(result).toBeDefined()
781
+ })
782
+
783
+ it('compiled function accepts component map', async () => {
784
+ const mdx = `<Custom value="test" />`
785
+
786
+ const compiled = await compileMDX(mdx, {
787
+ components: {
788
+ Custom: (props: Record<string, unknown>) => `Custom: ${props.value}`,
789
+ },
790
+ })
791
+
792
+ const result = compiled({ Custom: { value: 'test' } })
793
+
794
+ expect(result).toContain('test')
795
+ })
796
+
797
+ it('handles MDX with import statements', async () => {
798
+ const mdx = `import { Button } from './components'
799
+
800
+ # Page
801
+
802
+ <Button variant="primary">Click</Button>`
803
+
804
+ // Should handle import statements without throwing
805
+ const compiled = await compileMDX(mdx)
806
+
807
+ expect(compiled).toBeDefined()
808
+ })
809
+
810
+ it('handles MDX with export statements', async () => {
811
+ const mdx = `export const metadata = { title: 'Test' }
812
+
813
+ # Page Content`
814
+
815
+ const compiled = await compileMDX(mdx)
816
+
817
+ expect(compiled).toBeDefined()
818
+ expect(compiled.metadata).toBeDefined()
819
+ expect(compiled.metadata.title).toBe('Test')
820
+ })
821
+ })
822
+ })
823
+
824
+ // ============================================================================
825
+ // 4. MDX Error Handling Tests
826
+ // ============================================================================
827
+
828
+ describe('MDX error handling', () => {
829
+ it('throws descriptive error for invalid MDX syntax', () => {
830
+ const invalidMDX = `<Unclosed
831
+
832
+ This has unclosed JSX.`
833
+
834
+ expect(() => parseMDX(invalidMDX)).toThrow()
835
+ })
836
+
837
+ it('throws for malformed frontmatter', () => {
838
+ const invalidMDX = `---
839
+ title: [invalid yaml
840
+ broken: {
841
+ ---
842
+
843
+ # Content`
844
+
845
+ expect(() => parseMDX(invalidMDX)).toThrow()
846
+ })
847
+
848
+ it('provides error location info for invalid MDX', () => {
849
+ const invalidMDX = `# Valid
850
+ <Valid />
851
+ <Invalid prop=>`
852
+
853
+ try {
854
+ parseMDX(invalidMDX)
855
+ // Should not reach here
856
+ expect(true).toBe(false)
857
+ } catch (error) {
858
+ expect(error).toBeInstanceOf(Error)
859
+ const err = error as Error & { line?: number; column?: number }
860
+ // Error should contain location information
861
+ expect(err.message).toBeDefined()
862
+ expect(err.message.length).toBeGreaterThan(0)
863
+ }
864
+ })
865
+
866
+ it('handles MDX compilation errors gracefully', async () => {
867
+ const invalidMDX = `<>{(() => { throw new Error("runtime error") })()}</>`
868
+
869
+ await expect(compileMDX(invalidMDX)).rejects.toThrow()
870
+ })
871
+
872
+ it('handles render errors for null component props', async () => {
873
+ const mdx = `<BrokenComponent />`
874
+
875
+ await expect(
876
+ renderMDXWithProps(mdx, {
877
+ BrokenComponent: null as unknown as Record<string, unknown>,
878
+ })
879
+ ).rejects.toThrow()
880
+ })
881
+ })
882
+
883
+ // ============================================================================
884
+ // 5. MDX with AI Props End-to-End Tests (Real AI Gateway)
885
+ // ============================================================================
886
+
887
+ describe('MDX with AI props end-to-end', () => {
888
+ it('parses MDX, generates props, and renders in one pipeline', async () => {
889
+ const mdx = `---
890
+ topic: Artificial Intelligence
891
+ audience: developers
892
+ ---
893
+
894
+ # {topic} Guide
895
+
896
+ <Hero />
897
+
898
+ <Card />
899
+
900
+ <Footer />`
901
+
902
+ // Step 1: Parse
903
+ const parsed = parseMDX(mdx)
904
+
905
+ expect(parsed.frontmatter.topic).toBe('Artificial Intelligence')
906
+ expect(parsed.components).toContain('Hero')
907
+ expect(parsed.components).toContain('Card')
908
+ expect(parsed.components).toContain('Footer')
909
+
910
+ // Step 2: Create generator with schemas
911
+ const generator = createMDXPropsGenerator({
912
+ schemas: {
913
+ Hero: {
914
+ title: 'Hero title for the page topic',
915
+ subtitle: 'Hero subtitle',
916
+ },
917
+ Card: {
918
+ title: 'Card title',
919
+ description: 'Card description',
920
+ },
921
+ Footer: {
922
+ copyright: 'Copyright notice',
923
+ },
924
+ },
925
+ })
926
+
927
+ // Step 3: Generate props using real AI
928
+ const generatedProps = await generator.generate(mdx)
929
+
930
+ expect(generatedProps.Hero).toBeDefined()
931
+ expect(generatedProps.Hero.title).toBeDefined()
932
+ expect(typeof generatedProps.Hero.title).toBe('string')
933
+ expect(generatedProps.Card).toBeDefined()
934
+ expect(generatedProps.Card.title).toBeDefined()
935
+ expect(generatedProps.Footer).toBeDefined()
936
+ expect(generatedProps.Footer.copyright).toBeDefined()
937
+
938
+ // Step 4: Render with generated props
939
+ const rendered = await renderMDXWithProps(mdx, generatedProps)
940
+
941
+ expect(rendered).toBeDefined()
942
+ expect(typeof rendered).toBe('string')
943
+ expect(rendered.length).toBeGreaterThan(0)
944
+ })
945
+
946
+ it('supports schema-less generation from component usage', async () => {
947
+ const mdx = `<ProductCard
948
+ name="AI Widget"
949
+ price={29.99}
950
+ category="technology"
951
+ />`
952
+
953
+ // Extract schemas from actual component usage
954
+ const schemas = extractComponentSchemas(mdx)
955
+
956
+ expect(schemas.ProductCard).toBeDefined()
957
+ expect(Object.keys(schemas.ProductCard)).toContain('name')
958
+ expect(Object.keys(schemas.ProductCard)).toContain('price')
959
+ expect(Object.keys(schemas.ProductCard)).toContain('category')
960
+
961
+ // Use extracted schemas for generation
962
+ const generator = createMDXPropsGenerator({ schemas })
963
+ const props = await generator.generate(mdx)
964
+
965
+ expect(props.ProductCard).toBeDefined()
966
+ })
967
+
968
+ it('handles MDX with mixed static and AI-generated content', async () => {
969
+ const mdx = `---
970
+ title: Product Page
971
+ ---
972
+
973
+ # {title}
974
+
975
+ <StaticBanner text="Sale!" />
976
+
977
+ <DynamicRecommendation />
978
+
979
+ <StaticFooter year={2026} />`
980
+
981
+ const generator = createMDXPropsGenerator({
982
+ schemas: {
983
+ DynamicRecommendation: {
984
+ product: 'Recommended product name',
985
+ reason: 'Why this product is recommended',
986
+ },
987
+ },
988
+ })
989
+
990
+ const props = await generator.generate(mdx)
991
+
992
+ // Only DynamicRecommendation should have generated props
993
+ expect(props.DynamicRecommendation).toBeDefined()
994
+ expect(props.DynamicRecommendation.product).toBeDefined()
995
+ expect(typeof props.DynamicRecommendation.product).toBe('string')
996
+ expect(props.DynamicRecommendation.reason).toBeDefined()
997
+ expect(typeof props.DynamicRecommendation.reason).toBe('string')
998
+ // Static components should not be in generated props
999
+ expect(props.StaticBanner).toBeUndefined()
1000
+ expect(props.StaticFooter).toBeUndefined()
1001
+ })
1002
+ })
1003
+
1004
+ // ============================================================================
1005
+ // 6. Integration with PropsServiceCore (Worker RPC)
1006
+ // ============================================================================
1007
+
1008
+ describe('MDX integration with PropsServiceCore', () => {
1009
+ it('PropsServiceCore can generate props for MDX component schemas', async () => {
1010
+ const service = new PropsServiceCore()
1011
+
1012
+ // Parse MDX to get component schemas
1013
+ const mdx = `<UserProfile />
1014
+ <ActivityFeed />`
1015
+
1016
+ const schemas = extractComponentSchemas(mdx)
1017
+
1018
+ // Use PropsServiceCore to generate props for each component
1019
+ for (const [componentName, schema] of Object.entries(schemas)) {
1020
+ if (Object.keys(schema).length > 0) {
1021
+ const result = await service.generate({
1022
+ schema: schema as Record<string, string>,
1023
+ })
1024
+ expect(result.props).toBeDefined()
1025
+ }
1026
+ }
1027
+ })
1028
+
1029
+ it('validates generated MDX props against component schemas', async () => {
1030
+ const service = new PropsServiceCore()
1031
+
1032
+ const schema = {
1033
+ title: 'Hero title text',
1034
+ subtitle: 'Hero subtitle text',
1035
+ ctaText: 'Call to action button text',
1036
+ }
1037
+
1038
+ const result = await service.generate({ schema })
1039
+
1040
+ // Validate the generated props
1041
+ const validation = service.validate(result.props, schema)
1042
+ expect(validation.valid).toBe(true)
1043
+ })
1044
+
1045
+ it('merges partial MDX props with AI-generated ones via service', async () => {
1046
+ const service = new PropsServiceCore()
1047
+
1048
+ const schema = {
1049
+ title: 'Card title',
1050
+ description: 'Card description',
1051
+ image: 'Image URL',
1052
+ }
1053
+
1054
+ // Simulate partial props from MDX attribute parsing
1055
+ const partialProps = { title: 'Explicit Title' }
1056
+
1057
+ const merged = await service.mergeWithGenerated(schema, partialProps)
1058
+
1059
+ expect(merged.title).toBe('Explicit Title')
1060
+ expect(merged.description).toBeDefined()
1061
+ expect(merged.image).toBeDefined()
1062
+ })
1063
+ })
1064
+
1065
+ // ============================================================================
1066
+ // 7. Real AI Gateway Integration Tests
1067
+ // ============================================================================
1068
+
1069
+ describe('Real AI Gateway integration for MDX', () => {
1070
+ it('generates contextual props from MDX content using AI', async () => {
1071
+ const generator = createMDXPropsGenerator({
1072
+ schemas: {
1073
+ Hero: {
1074
+ headline: 'An engaging headline about the topic',
1075
+ subheadline: 'A supporting subheadline',
1076
+ },
1077
+ },
1078
+ })
1079
+
1080
+ const mdx = `---
1081
+ topic: Cloud Computing
1082
+ industry: Technology
1083
+ ---
1084
+
1085
+ <Hero />`
1086
+
1087
+ const result = await generator.generate(mdx)
1088
+
1089
+ expect(result.Hero).toBeDefined()
1090
+ expect(result.Hero.headline).toBeDefined()
1091
+ expect(typeof result.Hero.headline).toBe('string')
1092
+ expect((result.Hero.headline as string).length).toBeGreaterThan(0)
1093
+ expect(result.Hero.subheadline).toBeDefined()
1094
+ expect(typeof result.Hero.subheadline).toBe('string')
1095
+ expect((result.Hero.subheadline as string).length).toBeGreaterThan(0)
1096
+ })
1097
+
1098
+ it('uses cached responses for repeated renders', async () => {
1099
+ const generator = createMDXPropsGenerator({
1100
+ schemas: {
1101
+ Card: {
1102
+ title: 'Card title',
1103
+ body: 'Card body text',
1104
+ },
1105
+ },
1106
+ cache: true,
1107
+ })
1108
+
1109
+ const mdx = `<Card />`
1110
+
1111
+ // First call - real AI
1112
+ const first = await generator.generate(mdx)
1113
+ expect(first.Card.title).toBeDefined()
1114
+
1115
+ // Second call - should be cached
1116
+ const second = await generator.generate(mdx)
1117
+ expect(second.Card.title).toBe(first.Card.title)
1118
+ expect(second.Card.body).toBe(first.Card.body)
1119
+ })
1120
+
1121
+ it('generates different props for different frontmatter contexts', async () => {
1122
+ const generator = createMDXPropsGenerator({
1123
+ schemas: {
1124
+ Hero: {
1125
+ title: 'A title matching the page topic',
1126
+ },
1127
+ },
1128
+ })
1129
+
1130
+ const mdxTech = `---
1131
+ topic: Machine Learning
1132
+ ---
1133
+ <Hero />`
1134
+
1135
+ const mdxArt = `---
1136
+ topic: Renaissance Art
1137
+ ---
1138
+ <Hero />`
1139
+
1140
+ const techResult = await generator.generate(mdxTech)
1141
+ const artResult = await generator.generate(mdxArt)
1142
+
1143
+ expect(techResult.Hero.title).toBeDefined()
1144
+ expect(artResult.Hero.title).toBeDefined()
1145
+ // Different topics should produce different titles
1146
+ // (we cannot guarantee this 100%, but it's the expected behavior)
1147
+ })
1148
+ })