@seed-ship/mcp-ui-solid 5.3.1 → 5.5.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 (81) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/dist/components/StreamingUIRenderer.cjs +106 -90
  3. package/dist/components/StreamingUIRenderer.cjs.map +1 -1
  4. package/dist/components/StreamingUIRenderer.d.ts +7 -0
  5. package/dist/components/StreamingUIRenderer.d.ts.map +1 -1
  6. package/dist/components/StreamingUIRenderer.js +107 -91
  7. package/dist/components/StreamingUIRenderer.js.map +1 -1
  8. package/dist/components/UIResourceRenderer.cjs +101 -82
  9. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  10. package/dist/components/UIResourceRenderer.d.ts +23 -0
  11. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  12. package/dist/components/UIResourceRenderer.js +102 -83
  13. package/dist/components/UIResourceRenderer.js.map +1 -1
  14. package/dist/index.cjs +7 -0
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +3 -0
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +7 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/mcp-ui-spec/dist/schemas.cjs +493 -0
  22. package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -0
  23. package/dist/mcp-ui-spec/dist/schemas.js +493 -0
  24. package/dist/mcp-ui-spec/dist/schemas.js.map +1 -0
  25. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/ZodError.cjs +118 -0
  26. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/ZodError.cjs.map +1 -0
  27. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/ZodError.js +118 -0
  28. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/ZodError.js.map +1 -0
  29. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/errors.cjs +10 -0
  30. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/errors.cjs.map +1 -0
  31. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/errors.js +10 -0
  32. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/errors.js.map +1 -0
  33. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/errorUtil.cjs +8 -0
  34. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/errorUtil.cjs.map +1 -0
  35. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/errorUtil.js +9 -0
  36. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/errorUtil.js.map +1 -0
  37. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/parseUtil.cjs +122 -0
  38. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/parseUtil.cjs.map +1 -0
  39. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/parseUtil.js +122 -0
  40. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/parseUtil.js.map +1 -0
  41. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/util.cjs +137 -0
  42. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/util.cjs.map +1 -0
  43. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/util.js +139 -0
  44. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/util.js.map +1 -0
  45. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/locales/en.cjs +105 -0
  46. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/locales/en.cjs.map +1 -0
  47. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/locales/en.js +106 -0
  48. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/locales/en.js.map +1 -0
  49. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.cjs +3229 -0
  50. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.cjs.map +1 -0
  51. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.js +3230 -0
  52. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.js.map +1 -0
  53. package/dist/services/validation.cjs +70 -152
  54. package/dist/services/validation.cjs.map +1 -1
  55. package/dist/services/validation.d.ts.map +1 -1
  56. package/dist/services/validation.js +70 -152
  57. package/dist/services/validation.js.map +1 -1
  58. package/dist/utils/logger.cjs +26 -4
  59. package/dist/utils/logger.cjs.map +1 -1
  60. package/dist/utils/logger.d.ts +30 -3
  61. package/dist/utils/logger.d.ts.map +1 -1
  62. package/dist/utils/logger.js +27 -5
  63. package/dist/utils/logger.js.map +1 -1
  64. package/dist/utils/perf.cjs +34 -0
  65. package/dist/utils/perf.cjs.map +1 -0
  66. package/dist/utils/perf.d.ts +19 -0
  67. package/dist/utils/perf.d.ts.map +1 -0
  68. package/dist/utils/perf.js +34 -0
  69. package/dist/utils/perf.js.map +1 -0
  70. package/package.json +3 -2
  71. package/src/components/StreamingUIRenderer.tsx +54 -2
  72. package/src/components/UIResourceRenderer.errorMode.test.tsx +95 -0
  73. package/src/components/UIResourceRenderer.tsx +72 -4
  74. package/src/index.ts +7 -0
  75. package/src/services/validation.spec-migration.test.ts +207 -0
  76. package/src/services/validation.ts +132 -178
  77. package/src/utils/logger.test.ts +130 -0
  78. package/src/utils/logger.ts +60 -7
  79. package/src/utils/perf.test.ts +59 -0
  80. package/src/utils/perf.ts +50 -0
  81. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Tests focused on the v5.5.0 spec-driven validation refactor (B.1 PR2).
3
+ *
4
+ * Complement to `validation.test.ts` (which is preserved untouched and
5
+ * verifies legacy behavior + codes are unchanged) — this file covers what's
6
+ * NEW in v5.5.0:
7
+ * 1. ZodIssue → ValidationError mapper preserves legacy `code` per type
8
+ * 2. Path translation: ZodIssue path → `params.<joined>`
9
+ * 3. Artifact validation drift FIX (url + filename + mimeType, not `content`)
10
+ * 4. Iframe + video: spec parse first, then chained domain whitelist
11
+ * 5. Imperative passthrough types (chart, table, form, map, modal) still
12
+ * use the legacy validators with their rich codes
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest'
16
+ import { validateComponent } from './validation'
17
+ import type { UIComponent, ComponentType } from '../types'
18
+
19
+ function makeComponent(type: ComponentType, params: Record<string, unknown> = {}): UIComponent {
20
+ return {
21
+ id: `test-${type}`,
22
+ type,
23
+ position: { colStart: 1, colSpan: 12 },
24
+ params: params as any,
25
+ }
26
+ }
27
+
28
+ describe('v5.5.0 — ZodIssue → ValidationError mapper', () => {
29
+ it('emits the legacy code per ComponentType when shape parsing fails', () => {
30
+ const cases: Array<{ type: ComponentType; legacyCode: string }> = [
31
+ { type: 'metric', legacyCode: 'INVALID_METRIC' },
32
+ { type: 'text', legacyCode: 'INVALID_TEXT' },
33
+ { type: 'iframe', legacyCode: 'INVALID_IFRAME' },
34
+ { type: 'image', legacyCode: 'INVALID_IMAGE' },
35
+ { type: 'link', legacyCode: 'INVALID_LINK' },
36
+ { type: 'action', legacyCode: 'INVALID_ACTION' },
37
+ { type: 'video', legacyCode: 'INVALID_VIDEO' },
38
+ { type: 'carousel', legacyCode: 'EMPTY_CAROUSEL' },
39
+ { type: 'image-gallery', legacyCode: 'EMPTY_GALLERY' },
40
+ { type: 'action-group', legacyCode: 'EMPTY_ACTION_GROUP' },
41
+ { type: 'code', legacyCode: 'INVALID_CODE' },
42
+ { type: 'artifact', legacyCode: 'INVALID_ARTIFACT' },
43
+ ]
44
+
45
+ for (const { type, legacyCode } of cases) {
46
+ const result = validateComponent(makeComponent(type, {}))
47
+ expect(result.valid, `${type} should fail validation with empty params`).toBe(false)
48
+ const codes = result.errors?.map((e) => e.code) ?? []
49
+ expect(codes, `${type} errors should include ${legacyCode}`).toContain(legacyCode)
50
+ }
51
+ })
52
+
53
+ it('maps ZodIssue.path to `params.<joined>` shape', () => {
54
+ // metric is missing both `title` and `value` → 2 errors with paths
55
+ // `params.title` and `params.value`
56
+ const result = validateComponent(makeComponent('metric', {}))
57
+ expect(result.valid).toBe(false)
58
+
59
+ const paths = (result.errors ?? []).map((e) => e.path)
60
+ expect(paths).toContain('params.title')
61
+ expect(paths).toContain('params.value')
62
+ })
63
+
64
+ it('emits a top-level `params` path when the failure is at the root (e.g. params is wrong type)', () => {
65
+ // value=null fails the union(string, number); path is `params.value`
66
+ const result = validateComponent(makeComponent('metric', { title: 'X', value: null }))
67
+ expect(result.valid).toBe(false)
68
+ const paths = (result.errors ?? []).map((e) => e.path)
69
+ expect(paths.some((p) => p.startsWith('params.'))).toBe(true)
70
+ })
71
+ })
72
+
73
+ describe('v5.5.0 — Artifact validation drift fix', () => {
74
+ // Pre-v5.5.0 BUG: validation.ts checked `params.content` but ArtifactRenderer
75
+ // expects `url + filename + mimeType`. Any valid artifact (per renderer)
76
+ // would fail validation; any "valid" artifact (per old check) couldn't
77
+ // render. Fixed as a side-effect of the spec migration.
78
+
79
+ it('accepts a valid artifact with url + filename + mimeType', () => {
80
+ const result = validateComponent(
81
+ makeComponent('artifact', {
82
+ url: 'https://artifacts.example.com/file.csv',
83
+ filename: 'export.csv',
84
+ mimeType: 'text/csv',
85
+ })
86
+ )
87
+ expect(result.valid).toBe(true)
88
+ })
89
+
90
+ it('rejects an artifact with only `content` (the pre-v5.5.0 expected shape — was a bug)', () => {
91
+ const result = validateComponent(makeComponent('artifact', { content: 'data' }))
92
+ expect(result.valid).toBe(false)
93
+ expect(result.errors?.some((e) => e.code === 'INVALID_ARTIFACT')).toBe(true)
94
+ })
95
+
96
+ it('rejects an artifact missing filename', () => {
97
+ const result = validateComponent(
98
+ makeComponent('artifact', { url: 'https://x', mimeType: 'application/pdf' })
99
+ )
100
+ expect(result.valid).toBe(false)
101
+ const filenameError = result.errors?.find((e) => e.path === 'params.filename')
102
+ expect(filenameError).toBeDefined()
103
+ })
104
+ })
105
+
106
+ describe('v5.5.0 — Iframe + video chained domain whitelist', () => {
107
+ it('iframe with whitelisted domain (quickchart.io) passes validation', () => {
108
+ const result = validateComponent(
109
+ makeComponent('iframe', { url: 'https://quickchart.io/chart?c={}' })
110
+ )
111
+ expect(result.valid).toBe(true)
112
+ })
113
+
114
+ it('iframe domain check IS chained after spec parse (URL with malformed bracket throws → INVALID_URL)', () => {
115
+ // Spec parse accepts any non-empty string; we feed it a URL the WHATWG
116
+ // parser refuses (unbalanced bracket), which proves the chained
117
+ // validateIframeDomain ran and surfaced its INVALID_URL code.
118
+ const result = validateComponent(
119
+ makeComponent('iframe', { url: 'http://[bad' })
120
+ )
121
+ expect(result.valid).toBe(false)
122
+ const codes = result.errors?.map((e) => e.code) ?? []
123
+ expect(codes).toContain('INVALID_URL')
124
+ })
125
+
126
+ it('iframe with missing url emits INVALID_IFRAME and SKIPS the domain check (no cascading error)', () => {
127
+ const result = validateComponent(makeComponent('iframe', {}))
128
+ expect(result.valid).toBe(false)
129
+ const codes = result.errors?.map((e) => e.code) ?? []
130
+ expect(codes).toContain('INVALID_IFRAME')
131
+ // No domain whitelist error since url was absent — avoid noise
132
+ expect(codes.every((c) => c === 'INVALID_IFRAME')).toBe(true)
133
+ })
134
+
135
+ it('video with whitelisted domain (youtube.com) passes domain check (only spec strict-url applies)', () => {
136
+ // VideoComponentParamsSchema uses z.string().url() — strict
137
+ const result = validateComponent(
138
+ makeComponent('video', { url: 'https://www.youtube.com/embed/abc' })
139
+ )
140
+ expect(result.valid).toBe(true)
141
+ })
142
+ })
143
+
144
+ describe('v5.5.0 — Imperative passthrough types preserve their rich codes', () => {
145
+ it('chart still emits MISSING_DATA / MISSING_DATASETS / MISSING_LABELS via validateChartComponent', () => {
146
+ const result = validateComponent(makeComponent('chart', {}))
147
+ expect(result.valid).toBe(false)
148
+ expect(result.errors?.[0].code).toBe('MISSING_DATA')
149
+ })
150
+
151
+ it('table still emits EMPTY_COLUMNS via validateTableComponent', () => {
152
+ const result = validateComponent(makeComponent('table', { columns: [], rows: [] }))
153
+ expect(result.valid).toBe(false)
154
+ expect(result.errors?.some((e) => e.code === 'EMPTY_COLUMNS')).toBe(true)
155
+ })
156
+
157
+ it('form still emits EMPTY_FORM via the imperative path (spec form schema is too strict for LLM payloads)', () => {
158
+ const result = validateComponent(makeComponent('form', { fields: [] }))
159
+ expect(result.valid).toBe(false)
160
+ expect(result.errors?.some((e) => e.code === 'EMPTY_FORM')).toBe(true)
161
+ })
162
+
163
+ it('map with object center {lat, lng} still passes (spec tuple shape would have rejected — kept imperative for compat)', () => {
164
+ const result = validateComponent(
165
+ makeComponent('map', { center: { lat: 48.8566, lng: 2.3522 }, zoom: 13 })
166
+ )
167
+ // Pre-v5.5.0 behavior preserved: object-shaped center is accepted.
168
+ expect(result.valid).toBe(true)
169
+ })
170
+
171
+ it('modal with arbitrary minimal params still passes (no validation)', () => {
172
+ const result = validateComponent(makeComponent('modal', { title: 'OK' }))
173
+ expect(result.valid).toBe(true)
174
+ })
175
+ })
176
+
177
+ describe('v5.5.0 — Invariants preserved (regression guard)', () => {
178
+ it('truly unknown types still rejected with UNKNOWN_COMPONENT_TYPE', () => {
179
+ const result = validateComponent(makeComponent('zzz-not-a-type' as ComponentType))
180
+ expect(result.valid).toBe(false)
181
+ expect(result.errors?.some((e) => e.code === 'UNKNOWN_COMPONENT_TYPE')).toBe(true)
182
+ })
183
+
184
+ it('missing component.params still emits MISSING_PARAMS (early return, before spec dispatch)', () => {
185
+ const component = {
186
+ id: 'x',
187
+ type: 'metric' as ComponentType,
188
+ position: { colStart: 1, colSpan: 12 },
189
+ params: undefined as any,
190
+ }
191
+ const result = validateComponent(component)
192
+ expect(result.valid).toBe(false)
193
+ expect(result.errors?.some((e) => e.code === 'MISSING_PARAMS')).toBe(true)
194
+ })
195
+
196
+ it('grid position errors still emit (validateGridPosition runs before spec dispatch)', () => {
197
+ const broken: UIComponent = {
198
+ id: 'x',
199
+ type: 'metric',
200
+ position: { colStart: 99, colSpan: 1 },
201
+ params: { title: 'X', value: 1 },
202
+ }
203
+ const result = validateComponent(broken)
204
+ expect(result.valid).toBe(false)
205
+ expect(result.errors?.some((e) => e.code === 'INVALID_GRID_COL_START')).toBe(true)
206
+ })
207
+ })
@@ -8,6 +8,21 @@
8
8
  * - Security constraints (domain whitelist, XSS prevention)
9
9
  */
10
10
 
11
+ import type { ZodIssue, ZodSchema } from 'zod'
12
+ import {
13
+ MetricComponentParamsSchema,
14
+ TextComponentParamsSchema,
15
+ IframeComponentParamsSchema,
16
+ ImageComponentParamsSchema,
17
+ LinkComponentParamsSchema,
18
+ CarouselComponentParamsSchema,
19
+ ArtifactComponentParamsSchema,
20
+ ActionParamsSchema,
21
+ VideoComponentParamsSchema,
22
+ ImageGalleryParamsSchema,
23
+ ActionGroupParamsSchema,
24
+ CodeComponentParamsSchema,
25
+ } from '@seed-ship/mcp-ui-spec'
11
26
  import type {
12
27
  UIComponent,
13
28
  UILayout,
@@ -31,6 +46,60 @@ const KNOWN_COMPONENT_TYPES: Set<string> = new Set<ComponentType>([
31
46
  'action-group', 'image-gallery', 'video', 'code', 'map',
32
47
  ])
33
48
 
49
+ /**
50
+ * Spec-driven validation dispatch table (B.1 — v5.5.0).
51
+ *
52
+ * For each ComponentType where we delegate shape validation to a Zod schema
53
+ * from `@seed-ship/mcp-ui-spec`, this table maps:
54
+ * - the schema to safeParse against
55
+ * - the legacy error code to emit when shape parsing fails (preserves the
56
+ * pre-v5.5.0 `errors[].code` API contract — see MCP-UI-AUDIT-2026-04-26.md
57
+ * §I.3.a + §J.1)
58
+ *
59
+ * Types deliberately omitted (kept on the imperative path):
60
+ * - `chart`, `table` — have rich imperative validators with their own
61
+ * codes (MISSING_DATA, DATA_LENGTH_MISMATCH, RESOURCE_LIMIT_EXCEEDED, …)
62
+ * - `form` — spec FormFieldSchema has strict regex on field
63
+ * names that could reject LLM-generated payloads. Conservative.
64
+ * - `map` — spec center is `tuple([number, number])`; production
65
+ * payloads use `{lat, lng}` objects. Avoid backward-compat regression.
66
+ * - `modal` — all params are optional; nothing to enforce.
67
+ * - `grid`, `footer`, `composite` — pass-through, validated elsewhere.
68
+ */
69
+ const SPEC_VALIDATORS: Partial<Record<ComponentType, { schema: ZodSchema; legacyCode: string }>> = {
70
+ metric: { schema: MetricComponentParamsSchema, legacyCode: 'INVALID_METRIC' },
71
+ text: { schema: TextComponentParamsSchema, legacyCode: 'INVALID_TEXT' },
72
+ iframe: { schema: IframeComponentParamsSchema, legacyCode: 'INVALID_IFRAME' },
73
+ image: { schema: ImageComponentParamsSchema, legacyCode: 'INVALID_IMAGE' },
74
+ link: { schema: LinkComponentParamsSchema, legacyCode: 'INVALID_LINK' },
75
+ action: { schema: ActionParamsSchema, legacyCode: 'INVALID_ACTION' },
76
+ video: { schema: VideoComponentParamsSchema, legacyCode: 'INVALID_VIDEO' },
77
+ carousel: { schema: CarouselComponentParamsSchema, legacyCode: 'EMPTY_CAROUSEL' },
78
+ 'image-gallery': { schema: ImageGalleryParamsSchema, legacyCode: 'EMPTY_GALLERY' },
79
+ 'action-group': { schema: ActionGroupParamsSchema, legacyCode: 'EMPTY_ACTION_GROUP' },
80
+ code: { schema: CodeComponentParamsSchema, legacyCode: 'INVALID_CODE' },
81
+ artifact: { schema: ArtifactComponentParamsSchema, legacyCode: 'INVALID_ARTIFACT' },
82
+ }
83
+
84
+ /**
85
+ * Map a Zod issue list to the legacy `ValidationError[]` shape.
86
+ *
87
+ * Preserves the pre-v5.5.0 contract: `path` always begins with `params`,
88
+ * `code` is the per-type legacy code (so consumers that filtered by
89
+ * `errors[].code === 'EMPTY_CAROUSEL'` keep working), `message` is Zod's
90
+ * native human-readable message.
91
+ */
92
+ function mapZodIssuesToErrors(
93
+ issues: readonly ZodIssue[],
94
+ legacyCode: string
95
+ ): NonNullable<ValidationResult['errors']> {
96
+ return issues.map((issue) => ({
97
+ path: issue.path.length > 0 ? `params.${issue.path.join('.')}` : 'params',
98
+ message: issue.message,
99
+ code: legacyCode,
100
+ }))
101
+ }
102
+
34
103
  /**
35
104
  * Default resource limits (configurable via env)
36
105
  */
@@ -600,201 +669,86 @@ export function validateComponent(
600
669
  errors.push(...(sizeResult.errors || []))
601
670
  }
602
671
 
603
- // Type-specific validation
604
- switch (component.type) {
605
- case 'chart': {
606
- const chartResult = validateChartComponent(component.params as ChartComponentParams, limits)
607
- if (!chartResult.valid) {
608
- errors.push(...(chartResult.errors || []))
609
- }
610
- break
611
- }
612
-
613
- case 'table': {
614
- const tableResult = validateTableComponent(component.params as TableComponentParams, limits)
615
- if (!tableResult.valid) {
616
- errors.push(...(tableResult.errors || []))
617
- }
618
- break
619
- }
620
-
621
- case 'metric': {
622
- // Basic validation for metrics
623
- const metricParams = component.params as any
624
- if (!metricParams.title || !metricParams.value) {
625
- errors.push({
626
- path: 'params',
627
- message: 'Metric must have title and value',
628
- code: 'INVALID_METRIC',
629
- })
630
- }
631
- break
632
- }
633
-
634
- case 'text': {
635
- // Basic validation for text
636
- const textParams = component.params as any
637
- if (!textParams.content) {
638
- errors.push({
639
- path: 'params',
640
- message: 'Text component must have content',
641
- code: 'INVALID_TEXT',
642
- })
643
- }
644
- break
645
- }
646
-
647
- case 'iframe': {
648
- // Basic validation for iframe
649
- const iframeParams = component.params as any
650
- if (!iframeParams.url) {
651
- errors.push({
652
- path: 'params',
653
- message: 'Iframe component must have url',
654
- code: 'INVALID_IFRAME',
655
- })
656
- } else {
657
- // Validate iframe domain against whitelist
658
- const iframeResult = validateIframeDomain(iframeParams.url, {
672
+ // Type-specific validation (B.1 — v5.5.0).
673
+ //
674
+ // 12 types delegate shape validation to Zod schemas in `mcp-ui-spec` via
675
+ // SPEC_VALIDATORS. The 5 remaining types stay imperative because they
676
+ // need cross-field consistency, resource limits, or backward-compat logic
677
+ // that pure Zod can't express without `.refine()` (see SPEC_VALIDATORS docstring).
678
+ const specValidator = SPEC_VALIDATORS[component.type]
679
+ if (specValidator) {
680
+ const result = specValidator.schema.safeParse(component.params)
681
+ if (!result.success) {
682
+ errors.push(...mapZodIssuesToErrors(result.error.issues, specValidator.legacyCode))
683
+ }
684
+ // Iframe + video: chain the domain whitelist post-check ONLY when the
685
+ // shape parse succeeded (i.e. url is present and a string). Skipping the
686
+ // domain check when shape failed avoids cascading errors on the same field.
687
+ if (result.success && (component.type === 'iframe' || component.type === 'video')) {
688
+ const url = (component.params as { url?: string })?.url
689
+ if (typeof url === 'string') {
690
+ const domainResult = validateIframeDomain(url, {
659
691
  policy: options?.iframePolicy,
660
692
  customDomains: options?.customIframeDomains,
661
693
  })
662
- if (!iframeResult.valid) {
663
- errors.push(...(iframeResult.errors || []))
694
+ if (!domainResult.valid) {
695
+ errors.push(...(domainResult.errors || []))
664
696
  }
665
697
  }
666
- break
667
- }
668
-
669
- case 'image': {
670
- // Basic validation for image
671
- const imageParams = component.params as any
672
- if (!imageParams.url) {
673
- errors.push({
674
- path: 'params',
675
- message: 'Image component must have url',
676
- code: 'INVALID_IMAGE',
677
- })
678
- }
679
- break
680
- }
681
-
682
- case 'link': {
683
- // Basic validation for link
684
- const linkParams = component.params as any
685
- if (!linkParams.url) {
686
- errors.push({
687
- path: 'params',
688
- message: 'Link component must have url',
689
- code: 'INVALID_LINK',
690
- })
691
- }
692
- break
693
- }
694
-
695
- case 'action': {
696
- // Basic validation for action
697
- const actionParams = component.params as any
698
- if (!actionParams.label) {
699
- errors.push({
700
- path: 'params',
701
- message: 'Action component must have label',
702
- code: 'INVALID_ACTION',
703
- })
704
- }
705
- break
706
698
  }
707
-
708
- case 'video': {
709
- const videoParams = component.params as any
710
- if (!videoParams.url) {
711
- errors.push({ path: 'params', message: 'Video component must have url', code: 'INVALID_VIDEO' })
712
- } else {
713
- // Reuse iframe domain validation for video URLs
714
- const videoResult = validateIframeDomain(videoParams.url, {
715
- policy: options?.iframePolicy,
716
- customDomains: options?.customIframeDomains,
717
- })
718
- if (!videoResult.valid) {
719
- errors.push(...(videoResult.errors || []))
699
+ } else {
700
+ // Imperative path for chart/table/form/map/modal/grid/footer/composite.
701
+ switch (component.type) {
702
+ case 'chart': {
703
+ const chartResult = validateChartComponent(component.params as ChartComponentParams, limits)
704
+ if (!chartResult.valid) {
705
+ errors.push(...(chartResult.errors || []))
720
706
  }
707
+ break
721
708
  }
722
- break
723
- }
724
-
725
- case 'carousel': {
726
- const carouselParams = component.params as any
727
- if (!Array.isArray(carouselParams.items) || carouselParams.items.length === 0) {
728
- errors.push({ path: 'params.items', message: 'Carousel must have non-empty items array', code: 'EMPTY_CAROUSEL' })
729
- }
730
- break
731
- }
732
709
 
733
- case 'image-gallery': {
734
- const galleryParams = component.params as any
735
- if (!Array.isArray(galleryParams.images) || galleryParams.images.length === 0) {
736
- errors.push({ path: 'params.images', message: 'Gallery must have non-empty images array', code: 'EMPTY_GALLERY' })
737
- }
738
- break
739
- }
740
-
741
- case 'form': {
742
- const formParams = component.params as any
743
- if (!Array.isArray(formParams.fields) || formParams.fields.length === 0) {
744
- errors.push({ path: 'params.fields', message: 'Form must have non-empty fields array', code: 'EMPTY_FORM' })
745
- }
746
- break
747
- }
748
-
749
- case 'action-group': {
750
- const agParams = component.params as any
751
- if (!Array.isArray(agParams.actions) || agParams.actions.length === 0) {
752
- errors.push({ path: 'params.actions', message: 'Action group must have non-empty actions array', code: 'EMPTY_ACTION_GROUP' })
710
+ case 'table': {
711
+ const tableResult = validateTableComponent(component.params as TableComponentParams, limits)
712
+ if (!tableResult.valid) {
713
+ errors.push(...(tableResult.errors || []))
714
+ }
715
+ break
753
716
  }
754
- break
755
- }
756
717
 
757
- case 'code': {
758
- const codeParams = component.params as any
759
- if (!codeParams.code) {
760
- errors.push({ path: 'params.code', message: 'Code component must have code content', code: 'INVALID_CODE' })
718
+ case 'form': {
719
+ const formParams = component.params as { fields?: unknown[] }
720
+ if (!Array.isArray(formParams.fields) || formParams.fields.length === 0) {
721
+ errors.push({ path: 'params.fields', message: 'Form must have non-empty fields array', code: 'EMPTY_FORM' })
722
+ }
723
+ break
761
724
  }
762
- break
763
- }
764
725
 
765
- case 'map': {
766
- // Map can auto-detect center from markers, so center is not strictly required
767
- const mapParams = component.params as any
768
- if (!mapParams.center && (!Array.isArray(mapParams.markers) || mapParams.markers.length === 0)) {
769
- errors.push({ path: 'params', message: 'Map must have center or markers', code: 'INVALID_MAP' })
726
+ case 'map': {
727
+ // Map can auto-detect center from markers, so center is not strictly required.
728
+ // Spec MapComponentParamsSchema would be too strict (tuple-only center) — kept imperative.
729
+ const mapParams = component.params as { center?: unknown; markers?: unknown[] }
730
+ if (!mapParams.center && (!Array.isArray(mapParams.markers) || mapParams.markers.length === 0)) {
731
+ errors.push({ path: 'params', message: 'Map must have center or markers', code: 'INVALID_MAP' })
732
+ }
733
+ break
770
734
  }
771
- break
772
- }
773
735
 
774
- case 'modal': {
775
- // Modal is valid with minimal params (title optional, content can be children)
776
- break
777
- }
736
+ case 'modal':
737
+ // Modal is valid with minimal params (title optional, content can be children).
738
+ break
778
739
 
779
- case 'artifact': {
780
- const artifactParams = component.params as any
781
- if (!artifactParams.content) {
782
- errors.push({ path: 'params.content', message: 'Artifact must have content', code: 'INVALID_ARTIFACT' })
783
- }
784
- break
740
+ default:
741
+ // Known types without specific validation pass through — renderer handles errors.
742
+ // Truly unknown types (e.g. typos in streamed JSON) are rejected.
743
+ if (!KNOWN_COMPONENT_TYPES.has(component.type)) {
744
+ errors.push({
745
+ path: 'type',
746
+ message: `Unknown component type: ${component.type}`,
747
+ code: 'UNKNOWN_COMPONENT_TYPE',
748
+ })
749
+ }
750
+ break
785
751
  }
786
-
787
- default:
788
- // Known types without specific validation pass through — renderer handles errors
789
- // Truly unknown types (e.g. typos in streamed JSON) are rejected
790
- if (!KNOWN_COMPONENT_TYPES.has(component.type)) {
791
- errors.push({
792
- path: 'type',
793
- message: `Unknown component type: ${component.type}`,
794
- code: 'UNKNOWN_COMPONENT_TYPE',
795
- })
796
- }
797
- break
798
752
  }
799
753
 
800
754
  return {
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Tests for logger debug-mode controls — v5.4.0 (B.2)
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
6
+ import { createLogger, setDebugMode, isDebugEnabled } from './logger'
7
+
8
+ describe('setDebugMode + isDebugEnabled (v5.4.0 — B.2)', () => {
9
+ let originalNodeEnv: string | undefined
10
+ let originalDebugEnv: string | undefined
11
+
12
+ beforeEach(() => {
13
+ originalNodeEnv = process.env.NODE_ENV
14
+ originalDebugEnv = process.env.MCP_UI_DEBUG
15
+ setDebugMode(null) // reset override
16
+ delete (globalThis as any).__MCP_UI_DEBUG__
17
+ })
18
+
19
+ afterEach(() => {
20
+ process.env.NODE_ENV = originalNodeEnv
21
+ process.env.MCP_UI_DEBUG = originalDebugEnv
22
+ setDebugMode(null)
23
+ delete (globalThis as any).__MCP_UI_DEBUG__
24
+ })
25
+
26
+ it('default (NODE_ENV=test, no override): isDebugEnabled returns true', () => {
27
+ // Vitest sets NODE_ENV='test' by default — that's !== 'production' so dev mode is on
28
+ expect(isDebugEnabled()).toBe(true)
29
+ })
30
+
31
+ it('NODE_ENV=production silences debug by default', () => {
32
+ process.env.NODE_ENV = 'production'
33
+ delete process.env.MCP_UI_DEBUG
34
+ expect(isDebugEnabled()).toBe(false)
35
+ })
36
+
37
+ it('MCP_UI_DEBUG=true re-enables debug in production', () => {
38
+ process.env.NODE_ENV = 'production'
39
+ process.env.MCP_UI_DEBUG = 'true'
40
+ expect(isDebugEnabled()).toBe(true)
41
+ })
42
+
43
+ it('globalThis.__MCP_UI_DEBUG__=true re-enables debug in production', () => {
44
+ process.env.NODE_ENV = 'production'
45
+ delete process.env.MCP_UI_DEBUG
46
+ ;(globalThis as any).__MCP_UI_DEBUG__ = true
47
+ expect(isDebugEnabled()).toBe(true)
48
+ })
49
+
50
+ it('setDebugMode(true) overrides NODE_ENV=production', () => {
51
+ process.env.NODE_ENV = 'production'
52
+ setDebugMode(true)
53
+ expect(isDebugEnabled()).toBe(true)
54
+ })
55
+
56
+ it('setDebugMode(false) overrides NODE_ENV=development', () => {
57
+ process.env.NODE_ENV = 'development'
58
+ setDebugMode(false)
59
+ expect(isDebugEnabled()).toBe(false)
60
+ })
61
+
62
+ it('setDebugMode(null) restores env-based detection', () => {
63
+ process.env.NODE_ENV = 'production'
64
+ setDebugMode(true)
65
+ expect(isDebugEnabled()).toBe(true)
66
+ setDebugMode(null)
67
+ expect(isDebugEnabled()).toBe(false)
68
+ })
69
+ })
70
+
71
+ describe('createLogger respects debug mode (v5.4.0)', () => {
72
+ let originalNodeEnv: string | undefined
73
+
74
+ beforeEach(() => {
75
+ originalNodeEnv = process.env.NODE_ENV
76
+ setDebugMode(null)
77
+ })
78
+
79
+ afterEach(() => {
80
+ process.env.NODE_ENV = originalNodeEnv
81
+ setDebugMode(null)
82
+ vi.restoreAllMocks()
83
+ })
84
+
85
+ it('info/warn/debug are silent when debug is off', () => {
86
+ process.env.NODE_ENV = 'production'
87
+ setDebugMode(false)
88
+
89
+ const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
90
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
91
+ const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
92
+
93
+ const logger = createLogger('test')
94
+ logger.info('a')
95
+ logger.warn('b')
96
+ logger.debug('c')
97
+
98
+ expect(infoSpy).not.toHaveBeenCalled()
99
+ expect(warnSpy).not.toHaveBeenCalled()
100
+ expect(debugSpy).not.toHaveBeenCalled()
101
+ })
102
+
103
+ it('error always logs even when debug is off', () => {
104
+ process.env.NODE_ENV = 'production'
105
+ setDebugMode(false)
106
+
107
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
108
+
109
+ const logger = createLogger('test')
110
+ logger.error('boom', { id: 1 })
111
+
112
+ expect(errorSpy).toHaveBeenCalledOnce()
113
+ expect(errorSpy.mock.calls[0][0]).toContain('[@seed-ship/mcp-ui-solid:test]')
114
+ expect(errorSpy.mock.calls[0][0]).toContain('boom')
115
+ expect(errorSpy.mock.calls[0][0]).toContain('"id":1')
116
+ })
117
+
118
+ it('toggling setDebugMode at runtime affects subsequent calls', () => {
119
+ process.env.NODE_ENV = 'production'
120
+ const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
121
+
122
+ const logger = createLogger('toggle')
123
+ logger.info('off')
124
+ expect(infoSpy).not.toHaveBeenCalled()
125
+
126
+ setDebugMode(true)
127
+ logger.info('on')
128
+ expect(infoSpy).toHaveBeenCalledOnce()
129
+ })
130
+ })