@actuate-media/cms-admin 0.11.0 → 0.12.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 (37) hide show
  1. package/LICENSE +21 -21
  2. package/dist/__tests__/fields/component-block-helpers.test.d.ts +7 -0
  3. package/dist/__tests__/fields/component-block-helpers.test.d.ts.map +1 -0
  4. package/dist/__tests__/fields/component-block-helpers.test.js +592 -0
  5. package/dist/__tests__/fields/component-block-helpers.test.js.map +1 -0
  6. package/dist/fields/ComponentBlockField.d.ts +25 -0
  7. package/dist/fields/ComponentBlockField.d.ts.map +1 -0
  8. package/dist/fields/ComponentBlockField.js +74 -0
  9. package/dist/fields/ComponentBlockField.js.map +1 -0
  10. package/dist/fields/FieldRenderer.d.ts +3 -0
  11. package/dist/fields/FieldRenderer.d.ts.map +1 -1
  12. package/dist/fields/FieldRenderer.js +3 -1
  13. package/dist/fields/FieldRenderer.js.map +1 -1
  14. package/dist/fields/PropInput.d.ts +14 -0
  15. package/dist/fields/PropInput.d.ts.map +1 -0
  16. package/dist/fields/PropInput.js +163 -0
  17. package/dist/fields/PropInput.js.map +1 -0
  18. package/dist/fields/component-block-helpers.d.ts +96 -0
  19. package/dist/fields/component-block-helpers.d.ts.map +1 -0
  20. package/dist/fields/component-block-helpers.js +323 -0
  21. package/dist/fields/component-block-helpers.js.map +1 -0
  22. package/dist/fields/index.d.ts +4 -0
  23. package/dist/fields/index.d.ts.map +1 -1
  24. package/dist/fields/index.js +2 -0
  25. package/dist/fields/index.js.map +1 -1
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -0
  29. package/dist/index.js.map +1 -1
  30. package/package.json +10 -3
  31. package/src/__tests__/fields/component-block-helpers.test.ts +674 -0
  32. package/src/fields/ComponentBlockField.tsx +179 -0
  33. package/src/fields/FieldRenderer.tsx +8 -0
  34. package/src/fields/PropInput.tsx +552 -0
  35. package/src/fields/component-block-helpers.ts +341 -0
  36. package/src/fields/index.ts +4 -0
  37. package/src/index.ts +7 -0
@@ -0,0 +1,674 @@
1
+ /**
2
+ * Tests for the pure helpers that drive the ComponentBlockField admin
3
+ * form. Splitting these out of the React component lets us run them in
4
+ * node without jsdom.
5
+ */
6
+
7
+ import type { ComponentSpec, Manifest } from '@actuate-media/component-blocks'
8
+ import { describe, expect, it } from 'vitest'
9
+
10
+ import {
11
+ buildClientValidator,
12
+ defaultForType,
13
+ getAllowedComponents,
14
+ parseEnumSelection,
15
+ parsePerPropErrors,
16
+ safeJsonStringify,
17
+ seedPropsForComponent,
18
+ switchUnionVariant,
19
+ } from '../../fields/component-block-helpers.js'
20
+ import { detectDiscriminator } from '@actuate-media/component-blocks/discriminated-union'
21
+ import type { PropType } from '@actuate-media/component-blocks'
22
+
23
+ const FIXTURE: Manifest = {
24
+ version: 1,
25
+ generatedAt: '2026-05-23T12:00:00.000Z',
26
+ rootDir: 'fixtures',
27
+ components: [
28
+ {
29
+ name: 'Hero',
30
+ displayName: 'Hero',
31
+ filePath: 'Hero.tsx',
32
+ props: [
33
+ { name: 'title', type: { kind: 'string' }, required: true },
34
+ {
35
+ name: 'alignment',
36
+ type: { kind: 'enum', values: ['left', 'center', 'right'] },
37
+ required: false,
38
+ defaultValue: 'center',
39
+ },
40
+ {
41
+ name: 'features',
42
+ type: {
43
+ kind: 'array',
44
+ itemType: {
45
+ kind: 'object',
46
+ fields: [
47
+ { name: 'label', type: { kind: 'string' }, required: true },
48
+ { name: 'icon', type: { kind: 'string' }, required: false },
49
+ ],
50
+ },
51
+ },
52
+ required: false,
53
+ },
54
+ ],
55
+ },
56
+ {
57
+ name: 'Banner',
58
+ displayName: 'Banner',
59
+ filePath: 'Banner.tsx',
60
+ props: [{ name: 'message', type: { kind: 'string' }, required: true }],
61
+ },
62
+ ],
63
+ }
64
+
65
+ describe('defaultForType', () => {
66
+ it('returns empty string for string', () => {
67
+ expect(defaultForType({ kind: 'string' })).toBe('')
68
+ })
69
+ it('returns 0 for number', () => {
70
+ expect(defaultForType({ kind: 'number' })).toBe(0)
71
+ })
72
+ it('returns false for boolean', () => {
73
+ expect(defaultForType({ kind: 'boolean' })).toBe(false)
74
+ })
75
+ it('returns the first enum value', () => {
76
+ expect(defaultForType({ kind: 'enum', values: ['a', 'b'] })).toBe('a')
77
+ })
78
+ it('returns the literal value', () => {
79
+ expect(defaultForType({ kind: 'literal', value: 'fixed' })).toBe('fixed')
80
+ })
81
+ it('returns [] for array', () => {
82
+ expect(defaultForType({ kind: 'array', itemType: { kind: 'string' } })).toEqual([])
83
+ })
84
+ it('returns an object seeded with required fields only', () => {
85
+ const result = defaultForType({
86
+ kind: 'object',
87
+ fields: [
88
+ { name: 'a', type: { kind: 'string' }, required: true },
89
+ { name: 'b', type: { kind: 'string' }, required: false },
90
+ ],
91
+ })
92
+ expect(result).toEqual({ a: '' })
93
+ })
94
+ it('returns null (not undefined) for non-discriminated union/reference/unknown so JSON round-trips are stable', () => {
95
+ // Regression for Bugbot finding: returning `undefined` from
96
+ // defaultForType inside an array contradicted the JSDoc
97
+ // ("the admin form generator never wants `undefined` in an
98
+ // array") and round-tripped through JSON.stringify as `null`
99
+ // anyway, surfacing as the literal string `null` in the
100
+ // JsonFallback textarea.
101
+ expect(defaultForType({ kind: 'union', types: [] })).toBeNull()
102
+ expect(defaultForType({ kind: 'reference', targetType: 'X' })).toBeNull()
103
+ expect(defaultForType({ kind: 'unknown' })).toBeNull()
104
+ })
105
+
106
+ it('seeds a structural default for a discriminated union (picks the first variant)', () => {
107
+ // PR #4: with a discriminated union, defaultForType picks the
108
+ // first variant and seeds its required fields. This is what lets
109
+ // the admin form render a fully-editable variant immediately
110
+ // instead of bouncing the editor through the JSON textarea.
111
+ const cta = defaultForType({
112
+ kind: 'union',
113
+ types: [
114
+ {
115
+ kind: 'object',
116
+ fields: [
117
+ { name: 'kind', type: { kind: 'literal', value: 'link' }, required: true },
118
+ { name: 'href', type: { kind: 'string' }, required: true },
119
+ { name: 'label', type: { kind: 'string' }, required: true },
120
+ ],
121
+ },
122
+ {
123
+ kind: 'object',
124
+ fields: [
125
+ { name: 'kind', type: { kind: 'literal', value: 'modal' }, required: true },
126
+ { name: 'modalId', type: { kind: 'string' }, required: true },
127
+ { name: 'label', type: { kind: 'string' }, required: true },
128
+ ],
129
+ },
130
+ ],
131
+ })
132
+ expect(cta).toEqual({ kind: 'link', href: '', label: '' })
133
+ })
134
+ })
135
+
136
+ describe('seedPropsForComponent', () => {
137
+ const hero: ComponentSpec = FIXTURE.components[0]!
138
+
139
+ it('uses each prop defaultValue when present', () => {
140
+ const seeded = seedPropsForComponent(hero)
141
+ expect(seeded.alignment).toBe('center')
142
+ })
143
+
144
+ it('seeds required props without a defaultValue using defaultForType', () => {
145
+ const seeded = seedPropsForComponent(hero)
146
+ expect(seeded.title).toBe('')
147
+ })
148
+
149
+ it('omits optional props without a defaultValue', () => {
150
+ const seeded = seedPropsForComponent(hero)
151
+ // `features` is optional and has no defaultValue — should be absent.
152
+ expect('features' in seeded).toBe(false)
153
+ })
154
+
155
+ it('returns an empty object when the component has zero props', () => {
156
+ const empty: ComponentSpec = {
157
+ name: 'Empty',
158
+ displayName: 'Empty',
159
+ filePath: 'Empty.tsx',
160
+ props: [],
161
+ }
162
+ expect(seedPropsForComponent(empty)).toEqual({})
163
+ })
164
+
165
+ it('does not seed required props of non-discriminated union/reference/unknown kinds (would trip validator immediately)', () => {
166
+ // Regression for Bugbot finding: after defaultForType started
167
+ // returning `null` for these kinds (so arrays survive JSON
168
+ // round-trip), seedPropsForComponent would seed required props
169
+ // with `null` — and buildClientValidator treats `null` as
170
+ // missing, so the editor would see "Missing required prop" the
171
+ // instant they picked the component. Leave those required props
172
+ // absent instead; the validator's message and the data state
173
+ // then agree. Discriminated unions are exempt because they DO
174
+ // have a sensible structural default (first variant).
175
+ const spec: ComponentSpec = {
176
+ name: 'Card',
177
+ displayName: 'Card',
178
+ filePath: 'Card.tsx',
179
+ props: [
180
+ { name: 'title', type: { kind: 'string' }, required: true },
181
+ { name: 'data', type: { kind: 'union', types: [] }, required: true },
182
+ {
183
+ name: 'ref',
184
+ type: { kind: 'reference', targetType: 'SomeType' },
185
+ required: true,
186
+ },
187
+ { name: 'meta', type: { kind: 'unknown' }, required: true },
188
+ ],
189
+ }
190
+ const seeded = seedPropsForComponent(spec)
191
+ expect(seeded.title).toBe('')
192
+ expect('data' in seeded).toBe(false)
193
+ expect('ref' in seeded).toBe(false)
194
+ expect('meta' in seeded).toBe(false)
195
+ })
196
+
197
+ it('seeds required discriminated-union props with the first variant', () => {
198
+ // PR #4: a required cta of `{ kind: 'link', ... } | { kind: 'modal', ... }`
199
+ // should arrive on screen as a fully-editable Link variant — not
200
+ // absent, not null, not a "Missing required prop" error.
201
+ const spec: ComponentSpec = {
202
+ name: 'CTAHero',
203
+ displayName: 'CTAHero',
204
+ filePath: 'CTAHero.tsx',
205
+ props: [
206
+ {
207
+ name: 'cta',
208
+ required: true,
209
+ type: {
210
+ kind: 'union',
211
+ types: [
212
+ {
213
+ kind: 'object',
214
+ fields: [
215
+ { name: 'kind', type: { kind: 'literal', value: 'link' }, required: true },
216
+ { name: 'href', type: { kind: 'string' }, required: true },
217
+ { name: 'label', type: { kind: 'string' }, required: true },
218
+ ],
219
+ },
220
+ {
221
+ kind: 'object',
222
+ fields: [
223
+ { name: 'kind', type: { kind: 'literal', value: 'modal' }, required: true },
224
+ { name: 'modalId', type: { kind: 'string' }, required: true },
225
+ ],
226
+ },
227
+ ],
228
+ },
229
+ },
230
+ ],
231
+ }
232
+ const seeded = seedPropsForComponent(spec)
233
+ expect(seeded.cta).toEqual({ kind: 'link', href: '', label: '' })
234
+ })
235
+ })
236
+
237
+ describe('switchUnionVariant', () => {
238
+ // PR #4 review: switching between discriminated-union variants
239
+ // needs to (a) honor each field's explicit defaultValue before
240
+ // falling back to defaultForType, and (b) leave required fields
241
+ // ABSENT instead of seeding `null` when defaultForType has no
242
+ // sensible default (reference / unknown / non-discriminated union),
243
+ // so the validator's "missing required prop" message and the
244
+ // stored value agree. The previous implementation called
245
+ // defaultForType blindly and silently dropped declared defaults.
246
+
247
+ const ctaType: PropType = {
248
+ kind: 'union',
249
+ types: [
250
+ {
251
+ kind: 'object',
252
+ fields: [
253
+ { name: 'kind', type: { kind: 'literal', value: 'link' }, required: true },
254
+ { name: 'href', type: { kind: 'string' }, required: true },
255
+ { name: 'label', type: { kind: 'string' }, required: true, defaultValue: 'Learn more' },
256
+ ],
257
+ },
258
+ {
259
+ kind: 'object',
260
+ fields: [
261
+ { name: 'kind', type: { kind: 'literal', value: 'modal' }, required: true },
262
+ { name: 'modalId', type: { kind: 'string' }, required: true },
263
+ { name: 'label', type: { kind: 'string' }, required: true, defaultValue: 'Open' },
264
+ ],
265
+ },
266
+ {
267
+ kind: 'object',
268
+ fields: [
269
+ { name: 'kind', type: { kind: 'literal', value: 'page' }, required: true },
270
+ // Reference: defaultForType returns null. Required fields
271
+ // of these kinds must be left absent on variant switch.
272
+ { name: 'target', type: { kind: 'reference', targetType: 'Page' }, required: true },
273
+ { name: 'label', type: { kind: 'string' }, required: true },
274
+ ],
275
+ },
276
+ ],
277
+ }
278
+
279
+ function union() {
280
+ const detected = detectDiscriminator(ctaType)
281
+ if (!detected) throw new Error('test fixture failed to detect discriminator')
282
+ return detected
283
+ }
284
+
285
+ function variantFor(value: string) {
286
+ const v = union().variants.find((v) => v.value === value)
287
+ if (!v) throw new Error(`test fixture missing variant '${value}'`)
288
+ return v
289
+ }
290
+
291
+ it('seeds required fields from PropSpec.defaultValue when present', () => {
292
+ const next = switchUnionVariant({}, union(), variantFor('link'))
293
+ // `label` has defaultValue 'Learn more' — should be respected.
294
+ expect(next).toEqual({ kind: 'link', href: '', label: 'Learn more' })
295
+ })
296
+
297
+ it('falls back to defaultForType when no PropSpec.defaultValue is declared', () => {
298
+ // `href` has no defaultValue — falls back to '' (string default).
299
+ const next = switchUnionVariant({}, union(), variantFor('link'))
300
+ expect(next.href).toBe('')
301
+ })
302
+
303
+ it('does NOT seed required fields whose defaultForType is null (reference / unknown)', () => {
304
+ // `target: reference` has no sensible structural default; the
305
+ // helper must leave it ABSENT (not literal null) so the validator
306
+ // and the data agree about the missing-required state.
307
+ const next = switchUnionVariant({}, union(), variantFor('page'))
308
+ expect('target' in next).toBe(false)
309
+ // But `kind` + `label` (with their structural defaults) are present.
310
+ expect(next.kind).toBe('page')
311
+ expect(next.label).toBe('')
312
+ })
313
+
314
+ it('carries an optional/shared field across the switch when both variants declare it', () => {
315
+ // Both `link` and `modal` declare `label`; an existing value
316
+ // should survive the switch instead of being overwritten by the
317
+ // new variant's default.
318
+ const next = switchUnionVariant(
319
+ { kind: 'link', href: '/x', label: 'Custom label' },
320
+ union(),
321
+ variantFor('modal'),
322
+ )
323
+ expect(next).toEqual({ kind: 'modal', label: 'Custom label', modalId: '' })
324
+ })
325
+
326
+ it('drops fields exclusive to the previous variant', () => {
327
+ const next = switchUnionVariant(
328
+ { kind: 'link', href: '/x', label: 'Custom label' },
329
+ union(),
330
+ variantFor('modal'),
331
+ )
332
+ expect('href' in next).toBe(false)
333
+ })
334
+
335
+ it('treats a non-object input as empty when computing the next value', () => {
336
+ const next = switchUnionVariant(null, union(), variantFor('link'))
337
+ expect(next).toEqual({ kind: 'link', href: '', label: 'Learn more' })
338
+ })
339
+
340
+ it('does not override a carried defaultValue field that was edited to empty string', () => {
341
+ // The carry path runs before the seed path, so a previously-
342
+ // edited `label = ''` should win over the new variant's default.
343
+ const next = switchUnionVariant(
344
+ { kind: 'link', href: '/x', label: '' },
345
+ union(),
346
+ variantFor('modal'),
347
+ )
348
+ expect(next.label).toBe('')
349
+ })
350
+ })
351
+
352
+ describe('getAllowedComponents', () => {
353
+ it('returns all components when no allow list is given', () => {
354
+ expect(getAllowedComponents(FIXTURE, undefined)).toHaveLength(2)
355
+ })
356
+ it('returns all components when allow list is empty', () => {
357
+ expect(getAllowedComponents(FIXTURE, [])).toHaveLength(2)
358
+ })
359
+ it('filters by component name', () => {
360
+ const allowed = getAllowedComponents(FIXTURE, ['Hero'])
361
+ expect(allowed).toHaveLength(1)
362
+ expect(allowed[0]!.name).toBe('Hero')
363
+ })
364
+ it('returns empty array when allow list filters everything out', () => {
365
+ expect(getAllowedComponents(FIXTURE, ['Nonexistent'])).toEqual([])
366
+ })
367
+
368
+ it('treats an empty allow array as "no filter" (matches validator semantics)', () => {
369
+ // Regression for Bugbot finding: UI used to show every component
370
+ // when allow=[] but the validator rejected every selection,
371
+ // creating a deadlock state.
372
+ expect(getAllowedComponents(FIXTURE, [])).toHaveLength(2)
373
+ })
374
+ })
375
+
376
+ describe('buildClientValidator', () => {
377
+ const validate = buildClientValidator(FIXTURE, undefined)
378
+
379
+ it('accepts a valid Hero with required props', () => {
380
+ expect(validate({ component: 'Hero', props: { title: 'Welcome' } })).toBe(true)
381
+ })
382
+
383
+ it('rejects unknown components', () => {
384
+ const res = validate({ component: 'Nope', props: {} })
385
+ expect(res).not.toBe(true)
386
+ expect(res).toMatch(/Unknown component 'Nope'/)
387
+ })
388
+
389
+ it('rejects missing required props', () => {
390
+ const res = validate({ component: 'Hero', props: {} })
391
+ expect(res).not.toBe(true)
392
+ expect(res).toMatch(/Missing required prop 'title'/)
393
+ })
394
+
395
+ it('rejects number where string expected', () => {
396
+ const res = validate({ component: 'Hero', props: { title: 42 } })
397
+ expect(res).toMatch(/Prop 'title' must be a string/)
398
+ })
399
+
400
+ it('rejects enum values not in the allowed set', () => {
401
+ const res = validate({
402
+ component: 'Hero',
403
+ props: { title: 'ok', alignment: 'diagonal' },
404
+ })
405
+ expect(res).toMatch(/Prop 'alignment' must be one of: left, center, right/)
406
+ })
407
+
408
+ it('rejects when value is not in allow-list', () => {
409
+ const restrictive = buildClientValidator(FIXTURE, ['Hero'])
410
+ const res = restrictive({ component: 'Banner', props: { message: 'hi' } })
411
+ expect(res).toMatch(/not in the allow-list/)
412
+ })
413
+
414
+ it('skips optional props that are absent', () => {
415
+ // Hero.features is optional and unset — should still pass.
416
+ const res = validate({ component: 'Hero', props: { title: 'ok' } })
417
+ expect(res).toBe(true)
418
+ })
419
+
420
+ it('accepts any component when allow=[] (empty filter matches UI)', () => {
421
+ // Regression for Bugbot finding: with the old `allow ? new Set(allow) : null`
422
+ // logic, allow=[] produced an empty Set that rejected every name.
423
+ // The UI showed every component, so saving was impossible.
424
+ const permissive = buildClientValidator(FIXTURE, [])
425
+ expect(permissive({ component: 'Hero', props: { title: 'ok' } })).toBe(true)
426
+ expect(permissive({ component: 'Banner', props: { message: 'hi' } })).toBe(true)
427
+ })
428
+
429
+ it('reports an empty-known-components message when manifest is empty', () => {
430
+ const empty = buildClientValidator(
431
+ { version: 1, generatedAt: '', rootDir: '', components: [] },
432
+ undefined,
433
+ )
434
+ const res = empty({ component: 'Foo', props: {} })
435
+ expect(res).toMatch(/Manifest knows: <none>/)
436
+ })
437
+
438
+ describe('nested object validation', () => {
439
+ // Regression for Bugbot finding: clientShapeError used to only
440
+ // check whether the top-level value was an object, never
441
+ // recursing into nested required children. Passing `{}` for an
442
+ // object prop with required children sailed through the client
443
+ // validator while the server-side validateComponentBlockValue
444
+ // would reject it — silently broken "live structural validation".
445
+ const nested: Manifest = {
446
+ version: 1,
447
+ generatedAt: '',
448
+ rootDir: '',
449
+ components: [
450
+ {
451
+ name: 'Card',
452
+ displayName: 'Card',
453
+ filePath: 'Card.tsx',
454
+ props: [
455
+ {
456
+ name: 'cta',
457
+ type: {
458
+ kind: 'object',
459
+ fields: [
460
+ { name: 'label', type: { kind: 'string' }, required: true },
461
+ { name: 'href', type: { kind: 'string' }, required: true },
462
+ { name: 'newTab', type: { kind: 'boolean' }, required: false },
463
+ ],
464
+ },
465
+ required: true,
466
+ },
467
+ ],
468
+ },
469
+ ],
470
+ }
471
+ const validateNested = buildClientValidator(nested, undefined)
472
+
473
+ it('rejects an empty object for an object prop with required children', () => {
474
+ const res = validateNested({ component: 'Card', props: { cta: {} } })
475
+ expect(res).not.toBe(true)
476
+ expect(res).toMatch(/cta\.label/)
477
+ expect(res).toMatch(/required but missing/i)
478
+ })
479
+
480
+ it('rejects a nested field whose value has the wrong type', () => {
481
+ const res = validateNested({
482
+ component: 'Card',
483
+ props: { cta: { label: 'Click me', href: 123 } },
484
+ })
485
+ expect(res).not.toBe(true)
486
+ expect(res).toMatch(/cta\.href.*must be a string/i)
487
+ })
488
+
489
+ it('accepts a fully-populated nested object', () => {
490
+ const res = validateNested({
491
+ component: 'Card',
492
+ props: { cta: { label: 'Click me', href: '/x' } },
493
+ })
494
+ expect(res).toBe(true)
495
+ })
496
+
497
+ it('allows omitting an optional nested field', () => {
498
+ const res = validateNested({
499
+ component: 'Card',
500
+ props: { cta: { label: 'Click me', href: '/x', newTab: undefined } },
501
+ })
502
+ expect(res).toBe(true)
503
+ })
504
+ })
505
+
506
+ describe('discriminated-union validation', () => {
507
+ // PR #4: with a discriminated union, the validator should route
508
+ // the value into the matching variant's shape check rather than
509
+ // accepting any object. Mirrors the picker UX one-for-one.
510
+ const dunion: Manifest = {
511
+ version: 1,
512
+ generatedAt: '',
513
+ rootDir: '',
514
+ components: [
515
+ {
516
+ name: 'CTAHero',
517
+ displayName: 'CTAHero',
518
+ filePath: 'CTAHero.tsx',
519
+ props: [
520
+ {
521
+ name: 'cta',
522
+ required: true,
523
+ type: {
524
+ kind: 'union',
525
+ types: [
526
+ {
527
+ kind: 'object',
528
+ fields: [
529
+ { name: 'kind', type: { kind: 'literal', value: 'link' }, required: true },
530
+ { name: 'href', type: { kind: 'string' }, required: true },
531
+ { name: 'label', type: { kind: 'string' }, required: true },
532
+ ],
533
+ },
534
+ {
535
+ kind: 'object',
536
+ fields: [
537
+ { name: 'kind', type: { kind: 'literal', value: 'modal' }, required: true },
538
+ { name: 'modalId', type: { kind: 'string' }, required: true },
539
+ ],
540
+ },
541
+ ],
542
+ },
543
+ },
544
+ ],
545
+ },
546
+ ],
547
+ }
548
+ const validateDiscrim = buildClientValidator(dunion, undefined)
549
+
550
+ it('accepts a fully-populated valid variant', () => {
551
+ expect(
552
+ validateDiscrim({
553
+ component: 'CTAHero',
554
+ props: { cta: { kind: 'link', href: '/x', label: 'Go' } },
555
+ }),
556
+ ).toBe(true)
557
+ })
558
+
559
+ it('rejects an unknown discriminator value', () => {
560
+ const res = validateDiscrim({
561
+ component: 'CTAHero',
562
+ props: { cta: { kind: 'mystery' } },
563
+ })
564
+ expect(res).not.toBe(true)
565
+ expect(res).toMatch(/cta\.kind/)
566
+ expect(res).toMatch(/"link", "modal"/)
567
+ })
568
+
569
+ it("rejects when the picked variant's required field is missing", () => {
570
+ const res = validateDiscrim({
571
+ component: 'CTAHero',
572
+ props: { cta: { kind: 'modal' } },
573
+ })
574
+ expect(res).not.toBe(true)
575
+ expect(res).toMatch(/cta\.modalId/)
576
+ })
577
+
578
+ it('rejects when the picked variant has a wrong-type required field', () => {
579
+ const res = validateDiscrim({
580
+ component: 'CTAHero',
581
+ props: { cta: { kind: 'link', href: 42, label: 'Go' } },
582
+ })
583
+ expect(res).not.toBe(true)
584
+ expect(res).toMatch(/cta\.href.*must be a string/i)
585
+ })
586
+
587
+ it('rejects when the discriminated-union value is not an object', () => {
588
+ const res = validateDiscrim({
589
+ component: 'CTAHero',
590
+ props: { cta: 'not-an-object' },
591
+ })
592
+ expect(res).not.toBe(true)
593
+ expect(res).toMatch(/must be an object with a 'kind' discriminator/)
594
+ })
595
+ })
596
+ })
597
+
598
+ describe('safeJsonStringify', () => {
599
+ // Regression for Bugbot finding: the previous implementation
600
+ // short-circuited for strings and returned the raw value without
601
+ // quotes, which made the JsonFallback textarea round-trip lossy.
602
+ it('quotes string values so the textarea round-trips through JSON.parse', () => {
603
+ expect(safeJsonStringify('hello')).toBe('"hello"')
604
+ expect(safeJsonStringify('')).toBe('""')
605
+ })
606
+
607
+ it('encodes objects and arrays as pretty-printed JSON', () => {
608
+ expect(safeJsonStringify({ a: 1 })).toBe('{\n "a": 1\n}')
609
+ expect(safeJsonStringify([1, 2])).toBe('[\n 1,\n 2\n]')
610
+ })
611
+
612
+ it('encodes primitives correctly', () => {
613
+ expect(safeJsonStringify(42)).toBe('42')
614
+ expect(safeJsonStringify(true)).toBe('true')
615
+ expect(safeJsonStringify(null)).toBe('null')
616
+ })
617
+
618
+ it('falls back to String() for values JSON cannot encode', () => {
619
+ const circular: Record<string, unknown> = {}
620
+ circular.self = circular
621
+ // The exact fallback isn't load-bearing — the contract is just
622
+ // "returns *some* string so the field remains editable".
623
+ expect(typeof safeJsonStringify(circular)).toBe('string')
624
+ })
625
+ })
626
+
627
+ describe('parseEnumSelection', () => {
628
+ it('treats the empty-string placeholder as a clear (undefined)', () => {
629
+ // Regression for Bugbot finding: the original handler ran
630
+ // `Number("")` which evaluates to 0 and silently picked the first
631
+ // option, making optional enums un-clearable.
632
+ expect(parseEnumSelection('', ['left', 'center', 'right'])).toBeUndefined()
633
+ })
634
+
635
+ it('returns the option at the parsed index', () => {
636
+ expect(parseEnumSelection('0', ['left', 'center', 'right'])).toBe('left')
637
+ expect(parseEnumSelection('2', ['left', 'center', 'right'])).toBe('right')
638
+ })
639
+
640
+ it('returns undefined for malformed or out-of-range values', () => {
641
+ expect(parseEnumSelection('not-a-number', ['a', 'b'])).toBeUndefined()
642
+ expect(parseEnumSelection('5', ['a', 'b'])).toBeUndefined()
643
+ expect(parseEnumSelection('-1', ['a', 'b'])).toBeUndefined()
644
+ expect(parseEnumSelection('1.5', ['a', 'b'])).toBeUndefined()
645
+ })
646
+ })
647
+
648
+ describe('parsePerPropErrors', () => {
649
+ it('returns empty for null/empty input', () => {
650
+ expect(parsePerPropErrors(null)).toEqual({})
651
+ expect(parsePerPropErrors('')).toEqual({})
652
+ })
653
+
654
+ it('extracts prop name from a shape error', () => {
655
+ const err = "Prop 'title' must be a string."
656
+ expect(parsePerPropErrors(err)).toEqual({ title: err })
657
+ })
658
+
659
+ it('extracts prop name from a missing-required error', () => {
660
+ const err = "Missing required prop 'title' for component 'Hero'."
661
+ // 'Prop' matches first since the regex sees `prop 'title'` — but the
662
+ // missing-required path is also distinct. Either keying is fine as
663
+ // long as the editor sees the message under SOMETHING. Assert at
664
+ // least one of the property keys is `title`.
665
+ const parsed = parsePerPropErrors(err)
666
+ expect(Object.keys(parsed)).toContain('title')
667
+ expect(parsed.title).toBe(err)
668
+ })
669
+
670
+ it('returns empty for whole-form errors without a prop name', () => {
671
+ const err = "Unknown component 'Nope'. Manifest knows: Hero."
672
+ expect(parsePerPropErrors(err)).toEqual({})
673
+ })
674
+ })