@seed-ship/mcp-ui-solid 6.8.1 → 6.9.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 (46) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/components/ChartJSRenderer.cjs +27 -13
  3. package/dist/components/ChartJSRenderer.cjs.map +1 -1
  4. package/dist/components/ChartJSRenderer.d.ts.map +1 -1
  5. package/dist/components/ChartJSRenderer.js +28 -14
  6. package/dist/components/ChartJSRenderer.js.map +1 -1
  7. package/dist/components/DegradedFallback.cjs +73 -0
  8. package/dist/components/DegradedFallback.cjs.map +1 -0
  9. package/dist/components/DegradedFallback.d.ts +37 -0
  10. package/dist/components/DegradedFallback.d.ts.map +1 -0
  11. package/dist/components/DegradedFallback.js +73 -0
  12. package/dist/components/DegradedFallback.js.map +1 -0
  13. package/dist/components/GraphRenderer.cjs +30 -15
  14. package/dist/components/GraphRenderer.cjs.map +1 -1
  15. package/dist/components/GraphRenderer.d.ts.map +1 -1
  16. package/dist/components/GraphRenderer.js +31 -16
  17. package/dist/components/GraphRenderer.js.map +1 -1
  18. package/dist/components/MapRenderer.cjs +128 -107
  19. package/dist/components/MapRenderer.cjs.map +1 -1
  20. package/dist/components/MapRenderer.d.ts.map +1 -1
  21. package/dist/components/MapRenderer.js +129 -108
  22. package/dist/components/MapRenderer.js.map +1 -1
  23. package/dist/index.cjs +4 -4
  24. package/dist/index.js +1 -1
  25. package/dist/services/validation.cjs +43 -9
  26. package/dist/services/validation.cjs.map +1 -1
  27. package/dist/services/validation.d.ts.map +1 -1
  28. package/dist/services/validation.js +43 -9
  29. package/dist/services/validation.js.map +1 -1
  30. package/dist/utils/degraded-projections.cjs +87 -0
  31. package/dist/utils/degraded-projections.cjs.map +1 -0
  32. package/dist/utils/degraded-projections.d.ts +64 -0
  33. package/dist/utils/degraded-projections.d.ts.map +1 -0
  34. package/dist/utils/degraded-projections.js +87 -0
  35. package/dist/utils/degraded-projections.js.map +1 -0
  36. package/package.json +1 -1
  37. package/src/components/ChartJSRenderer.tsx +94 -85
  38. package/src/components/DegradedFallback.test.tsx +61 -0
  39. package/src/components/DegradedFallback.tsx +93 -0
  40. package/src/components/GraphRenderer.tsx +26 -4
  41. package/src/components/MapRenderer.tsx +446 -392
  42. package/src/services/validation.test.ts +298 -232
  43. package/src/services/validation.ts +210 -136
  44. package/src/utils/degraded-projections.test.ts +113 -0
  45. package/src/utils/degraded-projections.ts +149 -0
  46. package/tsconfig.tsbuildinfo +1 -1
@@ -5,7 +5,7 @@
5
5
  * without UNKNOWN_COMPONENT_TYPE errors.
6
6
  */
7
7
 
8
- import { describe, it, expect, vi } from 'vitest'
8
+ import { describe, it, expect, vi } from 'vitest';
9
9
  import {
10
10
  validateComponent,
11
11
  validateChartComponent,
@@ -13,8 +13,8 @@ import {
13
13
  getIframeSandbox,
14
14
  validateIframeDomain,
15
15
  DEFAULT_RESOURCE_LIMITS,
16
- } from './validation'
17
- import type { UIComponent, ComponentType } from '../types'
16
+ } from './validation';
17
+ import type { UIComponent, ComponentType } from '../types';
18
18
 
19
19
  /** Helper to create a minimal valid UIComponent for testing */
20
20
  function makeComponent(type: ComponentType, params: Record<string, any> = {}): UIComponent {
@@ -23,180 +23,224 @@ function makeComponent(type: ComponentType, params: Record<string, any> = {}): U
23
23
  type,
24
24
  position: { colStart: 1, colSpan: 12 },
25
25
  params: params as any,
26
- }
26
+ };
27
27
  }
28
28
 
29
29
  /** Types that have explicit validation cases in validateComponent */
30
30
  const VALIDATED_TYPES: ComponentType[] = [
31
- 'chart', 'table', 'metric', 'text', 'iframe', 'image', 'link', 'action',
31
+ 'chart',
32
+ 'table',
33
+ 'metric',
34
+ 'text',
35
+ 'iframe',
36
+ 'image',
37
+ 'link',
38
+ 'action',
32
39
  'artifact',
33
- ]
40
+ ];
34
41
 
35
42
  /** Types that hit the default case (no specific validation) */
36
43
  const PASSTHROUGH_TYPES: ComponentType[] = [
37
- 'code', 'map', 'form', 'modal', 'action-group',
38
- 'image-gallery', 'video', 'grid', 'carousel',
44
+ 'code',
45
+ 'map',
46
+ 'form',
47
+ 'modal',
48
+ 'action-group',
49
+ 'image-gallery',
50
+ 'video',
51
+ 'grid',
52
+ 'carousel',
39
53
  'footer',
40
- ]
54
+ ];
41
55
 
42
56
  describe('validateComponent', () => {
43
57
  describe('passthrough types (no specific validation case)', () => {
44
58
  it.each(PASSTHROUGH_TYPES)('"%s" does NOT produce UNKNOWN_COMPONENT_TYPE error', (type) => {
45
- const component = makeComponent(type)
46
- const result = validateComponent(component)
59
+ const component = makeComponent(type);
60
+ const result = validateComponent(component);
47
61
 
48
- const unknownTypeError = result.errors?.find(
49
- (e) => e.code === 'UNKNOWN_COMPONENT_TYPE'
50
- )
51
- expect(unknownTypeError).toBeUndefined()
52
- })
53
- })
62
+ const unknownTypeError = result.errors?.find((e) => e.code === 'UNKNOWN_COMPONENT_TYPE');
63
+ expect(unknownTypeError).toBeUndefined();
64
+ });
65
+ });
54
66
 
55
67
  describe('validated types still work', () => {
56
68
  it('validates a valid chart component', () => {
57
69
  const component = makeComponent('chart', {
58
70
  type: 'bar',
59
71
  data: { labels: ['A'], datasets: [{ data: [1] }] },
60
- })
61
- const result = validateComponent(component)
62
- expect(result.valid).toBe(true)
63
- })
72
+ });
73
+ const result = validateComponent(component);
74
+ expect(result.valid).toBe(true);
75
+ });
64
76
 
65
77
  it('validates a valid table component', () => {
66
78
  const component = makeComponent('table', {
67
79
  columns: [{ key: 'name', label: 'Name' }],
68
80
  rows: [{ name: 'test' }],
69
- })
70
- const result = validateComponent(component)
71
- expect(result.valid).toBe(true)
72
- })
81
+ });
82
+ const result = validateComponent(component);
83
+ expect(result.valid).toBe(true);
84
+ });
73
85
 
74
86
  it('validates a valid text component', () => {
75
87
  const component = makeComponent('text', {
76
88
  content: 'Hello world',
77
- })
78
- const result = validateComponent(component)
79
- expect(result.valid).toBe(true)
80
- })
89
+ });
90
+ const result = validateComponent(component);
91
+ expect(result.valid).toBe(true);
92
+ });
81
93
 
82
94
  it('validates a valid metric component', () => {
83
95
  const component = makeComponent('metric', {
84
96
  value: 42,
85
97
  title: 'Count',
86
- })
87
- const result = validateComponent(component)
88
- expect(result.valid).toBe(true)
89
- })
90
- })
98
+ });
99
+ const result = validateComponent(component);
100
+ expect(result.valid).toBe(true);
101
+ });
102
+ });
91
103
 
92
104
  describe('regression: code type renders without validation error', () => {
93
105
  it('type "code" passes validation', () => {
94
106
  const component = makeComponent('code', {
95
107
  code: 'console.log("hello")',
96
108
  language: 'typescript',
97
- })
98
- const result = validateComponent(component)
99
- const unknownTypeError = result.errors?.find(
100
- (e) => e.code === 'UNKNOWN_COMPONENT_TYPE'
101
- )
102
- expect(unknownTypeError).toBeUndefined()
103
- })
109
+ });
110
+ const result = validateComponent(component);
111
+ const unknownTypeError = result.errors?.find((e) => e.code === 'UNKNOWN_COMPONENT_TYPE');
112
+ expect(unknownTypeError).toBeUndefined();
113
+ });
104
114
 
105
115
  it('type "map" passes validation', () => {
106
116
  const component = makeComponent('map', {
107
117
  center: { lat: 48.8566, lng: 2.3522 },
108
118
  zoom: 13,
109
- })
110
- const result = validateComponent(component)
111
- const unknownTypeError = result.errors?.find(
112
- (e) => e.code === 'UNKNOWN_COMPONENT_TYPE'
113
- )
114
- expect(unknownTypeError).toBeUndefined()
115
- })
116
- })
119
+ });
120
+ const result = validateComponent(component);
121
+ const unknownTypeError = result.errors?.find((e) => e.code === 'UNKNOWN_COMPONENT_TYPE');
122
+ expect(unknownTypeError).toBeUndefined();
123
+ });
124
+ });
117
125
 
118
126
  describe('truly unknown types are rejected', () => {
119
127
  it('rejects a typo like "chrt" with UNKNOWN_COMPONENT_TYPE', () => {
120
- const component = makeComponent('chrt' as any)
121
- const result = validateComponent(component)
122
- const unknownTypeError = result.errors?.find(
123
- (e) => e.code === 'UNKNOWN_COMPONENT_TYPE'
124
- )
125
- expect(unknownTypeError).toBeDefined()
126
- expect(result.valid).toBe(false)
127
- })
128
+ const component = makeComponent('chrt' as any);
129
+ const result = validateComponent(component);
130
+ const unknownTypeError = result.errors?.find((e) => e.code === 'UNKNOWN_COMPONENT_TYPE');
131
+ expect(unknownTypeError).toBeDefined();
132
+ expect(result.valid).toBe(false);
133
+ });
128
134
 
129
135
  it('rejects garbage type "foobar"', () => {
130
- const component = makeComponent('foobar' as any)
131
- const result = validateComponent(component)
132
- expect(result.valid).toBe(false)
133
- expect(result.errors?.some((e) => e.code === 'UNKNOWN_COMPONENT_TYPE')).toBe(true)
134
- })
136
+ const component = makeComponent('foobar' as any);
137
+ const result = validateComponent(component);
138
+ expect(result.valid).toBe(false);
139
+ expect(result.errors?.some((e) => e.code === 'UNKNOWN_COMPONENT_TYPE')).toBe(true);
140
+ });
135
141
 
136
142
  it('rejects empty string type', () => {
137
- const component = makeComponent('' as any)
138
- const result = validateComponent(component)
139
- expect(result.valid).toBe(false)
140
- })
141
- })
143
+ const component = makeComponent('' as any);
144
+ const result = validateComponent(component);
145
+ expect(result.valid).toBe(false);
146
+ });
147
+ });
142
148
 
143
149
  describe('H2: missing component.params guard', () => {
144
150
  it('rejects component with undefined params', () => {
145
- const component = { id: 'test', type: 'chart' as any, position: { colStart: 1, colSpan: 12 }, params: undefined as any }
146
- const result = validateComponent(component)
147
- expect(result.valid).toBe(false)
148
- expect(result.errors?.some((e) => e.code === 'MISSING_PARAMS')).toBe(true)
149
- })
150
- })
151
- })
151
+ const component = {
152
+ id: 'test',
153
+ type: 'chart' as any,
154
+ position: { colStart: 1, colSpan: 12 },
155
+ params: undefined as any,
156
+ };
157
+ const result = validateComponent(component);
158
+ expect(result.valid).toBe(false);
159
+ expect(result.errors?.some((e) => e.code === 'MISSING_PARAMS')).toBe(true);
160
+ });
161
+ });
162
+ });
152
163
 
153
164
  describe('component-specific validation', () => {
154
165
  it('rejects video without url', () => {
155
- const result = validateComponent(makeComponent('video'))
156
- expect(result.errors?.some((e) => e.code === 'INVALID_VIDEO')).toBe(true)
157
- })
166
+ const result = validateComponent(makeComponent('video'));
167
+ expect(result.errors?.some((e) => e.code === 'INVALID_VIDEO')).toBe(true);
168
+ });
158
169
 
159
170
  it('rejects carousel with empty items', () => {
160
- const result = validateComponent(makeComponent('carousel', { items: [] }))
161
- expect(result.errors?.some((e) => e.code === 'EMPTY_CAROUSEL')).toBe(true)
162
- })
171
+ const result = validateComponent(makeComponent('carousel', { items: [] }));
172
+ expect(result.errors?.some((e) => e.code === 'EMPTY_CAROUSEL')).toBe(true);
173
+ });
163
174
 
164
175
  it('rejects image-gallery with empty images', () => {
165
- const result = validateComponent(makeComponent('image-gallery', { images: [] }))
166
- expect(result.errors?.some((e) => e.code === 'EMPTY_GALLERY')).toBe(true)
167
- })
176
+ const result = validateComponent(makeComponent('image-gallery', { images: [] }));
177
+ expect(result.errors?.some((e) => e.code === 'EMPTY_GALLERY')).toBe(true);
178
+ });
168
179
 
169
180
  it('rejects form with empty fields', () => {
170
- const result = validateComponent(makeComponent('form', { fields: [] }))
171
- expect(result.errors?.some((e) => e.code === 'EMPTY_FORM')).toBe(true)
172
- })
181
+ const result = validateComponent(makeComponent('form', { fields: [] }));
182
+ expect(result.errors?.some((e) => e.code === 'EMPTY_FORM')).toBe(true);
183
+ });
173
184
 
174
185
  it('rejects action-group with empty actions', () => {
175
- const result = validateComponent(makeComponent('action-group', { actions: [] }))
176
- expect(result.errors?.some((e) => e.code === 'EMPTY_ACTION_GROUP')).toBe(true)
177
- })
186
+ const result = validateComponent(makeComponent('action-group', { actions: [] }));
187
+ expect(result.errors?.some((e) => e.code === 'EMPTY_ACTION_GROUP')).toBe(true);
188
+ });
178
189
 
179
190
  it('rejects code without code content', () => {
180
- const result = validateComponent(makeComponent('code'))
181
- expect(result.errors?.some((e) => e.code === 'INVALID_CODE')).toBe(true)
182
- })
191
+ const result = validateComponent(makeComponent('code'));
192
+ expect(result.errors?.some((e) => e.code === 'INVALID_CODE')).toBe(true);
193
+ });
183
194
 
184
195
  it('rejects map without center or markers', () => {
185
- const result = validateComponent(makeComponent('map'))
186
- expect(result.errors?.some((e) => e.code === 'INVALID_MAP')).toBe(true)
187
- })
196
+ const result = validateComponent(makeComponent('map'));
197
+ expect(result.errors?.some((e) => e.code === 'INVALID_MAP')).toBe(true);
198
+ });
188
199
 
189
200
  it('accepts map with markers but no center', () => {
190
- const result = validateComponent(makeComponent('map', { markers: [{ position: [48, 2] }] }))
191
- const mapError = result.errors?.find((e) => e.code === 'INVALID_MAP')
192
- expect(mapError).toBeUndefined()
193
- })
201
+ const result = validateComponent(makeComponent('map', { markers: [{ position: [48, 2] }] }));
202
+ const mapError = result.errors?.find((e) => e.code === 'INVALID_MAP');
203
+ expect(mapError).toBeUndefined();
204
+ });
205
+
206
+ // v6.8.2 — a map may render purely from geojson / layers / pmtiles
207
+ // (spec@5.2.0 contract). These must NOT be rejected as INVALID_MAP.
208
+ it('accepts map with geojson but no center/markers', () => {
209
+ const result = validateComponent(
210
+ makeComponent('map', {
211
+ geojson: { type: 'FeatureCollection', features: [] },
212
+ fitBounds: true,
213
+ })
214
+ );
215
+ expect(result.errors?.some((e) => e.code === 'INVALID_MAP')).toBeFalsy();
216
+ });
217
+
218
+ it('accepts map with named layers but no center/markers', () => {
219
+ const result = validateComponent(
220
+ makeComponent('map', {
221
+ layers: [{ name: 'Communes', geojson: { type: 'FeatureCollection', features: [] } }],
222
+ })
223
+ );
224
+ expect(result.errors?.some((e) => e.code === 'INVALID_MAP')).toBeFalsy();
225
+ });
226
+
227
+ it('accepts map with a pmtiles source but no center/markers', () => {
228
+ const result = validateComponent(
229
+ makeComponent('map', { pmtiles: { url: 'https://cdn.example.com/x.pmtiles' } })
230
+ );
231
+ expect(result.errors?.some((e) => e.code === 'INVALID_MAP')).toBeFalsy();
232
+ });
233
+
234
+ it('still rejects an empty map (no center/markers/geojson/layers/pmtiles)', () => {
235
+ const result = validateComponent(makeComponent('map', {}));
236
+ expect(result.errors?.some((e) => e.code === 'INVALID_MAP')).toBe(true);
237
+ });
194
238
 
195
239
  it('accepts modal with no params beyond type', () => {
196
- const result = validateComponent(makeComponent('modal', { title: 'Test' }))
197
- expect(result.valid).toBe(true)
198
- })
199
- })
240
+ const result = validateComponent(makeComponent('modal', { title: 'Test' }));
241
+ expect(result.valid).toBe(true);
242
+ });
243
+ });
200
244
 
201
245
  describe('validatePayloadSize — payload size guard (v6.8.0: 50KB → 512KB)', () => {
202
246
  /**
@@ -209,8 +253,8 @@ describe('validatePayloadSize — payload size guard (v6.8.0: 50KB → 512KB)',
209
253
  center: { lat: 48.8566, lng: 2.3522 },
210
254
  zoom: 12,
211
255
  geojson: { type: 'FeatureCollection', features: [] as unknown[] },
212
- })
213
- const features = (component.params as any).geojson.features as unknown[]
256
+ });
257
+ const features = (component.params as any).geojson.features as unknown[];
214
258
  while (JSON.stringify(component).length < targetBytes) {
215
259
  // Grow in batches to keep the size loop cheap.
216
260
  for (let i = 0; i < 200; i++) {
@@ -218,194 +262,216 @@ describe('validatePayloadSize — payload size guard (v6.8.0: 50KB → 512KB)',
218
262
  type: 'Feature',
219
263
  geometry: { type: 'Point', coordinates: [2.3522, 48.8566] },
220
264
  properties: { i: features.length },
221
- })
265
+ });
222
266
  }
223
267
  }
224
- return component
268
+ return component;
225
269
  }
226
270
 
227
271
  it('the default ceiling is 512KB', () => {
228
- expect(DEFAULT_RESOURCE_LIMITS.maxPayloadSize).toBe(512 * 1024)
229
- })
272
+ expect(DEFAULT_RESOURCE_LIMITS.maxPayloadSize).toBe(512 * 1024);
273
+ });
230
274
 
231
275
  it('accepts a map with valid GeoJSON ~350KB (rejected under the old 50KB/256KB limits)', () => {
232
- const component = mapComponentOfSize(350 * 1024)
233
- const size = JSON.stringify(component).length
234
- expect(size).toBeGreaterThan(256 * 1024) // exceeds the interim 256KB ceiling
235
- expect(size).toBeLessThan(512 * 1024) // under the NEW 512KB limit
276
+ const component = mapComponentOfSize(350 * 1024);
277
+ const size = JSON.stringify(component).length;
278
+ expect(size).toBeGreaterThan(256 * 1024); // exceeds the interim 256KB ceiling
279
+ expect(size).toBeLessThan(512 * 1024); // under the NEW 512KB limit
236
280
 
237
- expect(validatePayloadSize(component).valid).toBe(true)
281
+ expect(validatePayloadSize(component).valid).toBe(true);
238
282
  // Full component validation also passes — no PAYLOAD_TOO_LARGE.
239
- const result = validateComponent(component)
240
- expect(result.errors?.some((e) => e.code === 'PAYLOAD_TOO_LARGE')).toBeFalsy()
241
- expect(result.valid).toBe(true)
242
- })
283
+ const result = validateComponent(component);
284
+ expect(result.errors?.some((e) => e.code === 'PAYLOAD_TOO_LARGE')).toBeFalsy();
285
+ expect(result.valid).toBe(true);
286
+ });
243
287
 
244
288
  it('still rejects a map payload over 512KB', () => {
245
- const component = mapComponentOfSize(600 * 1024)
246
- expect(JSON.stringify(component).length).toBeGreaterThan(512 * 1024)
289
+ const component = mapComponentOfSize(600 * 1024);
290
+ expect(JSON.stringify(component).length).toBeGreaterThan(512 * 1024);
247
291
 
248
- const sizeResult = validatePayloadSize(component)
249
- expect(sizeResult.valid).toBe(false)
250
- expect(sizeResult.errors?.[0].code).toBe('PAYLOAD_TOO_LARGE')
292
+ const sizeResult = validatePayloadSize(component);
293
+ expect(sizeResult.valid).toBe(false);
294
+ expect(sizeResult.errors?.[0].code).toBe('PAYLOAD_TOO_LARGE');
251
295
 
252
296
  expect(validateComponent(component).errors?.some((e) => e.code === 'PAYLOAD_TOO_LARGE')).toBe(
253
297
  true
254
- )
255
- })
298
+ );
299
+ });
256
300
 
257
301
  it('still rejects an oversized NON-map payload (guard-rail intact for every type)', () => {
258
- const component = makeComponent('text', { content: 'x'.repeat(600 * 1024) })
259
- const result = validatePayloadSize(component)
260
- expect(result.valid).toBe(false)
261
- expect(result.errors?.[0].code).toBe('PAYLOAD_TOO_LARGE')
262
- })
302
+ const component = makeComponent('text', { content: 'x'.repeat(600 * 1024) });
303
+ const result = validatePayloadSize(component);
304
+ expect(result.valid).toBe(false);
305
+ expect(result.errors?.[0].code).toBe('PAYLOAD_TOO_LARGE');
306
+ });
263
307
 
264
308
  it('honours a caller-supplied lower limit (validation is not disabled)', () => {
265
309
  // A consumer passing stricter limits keeps full control.
266
- const component = mapComponentOfSize(60 * 1024)
267
- const strict = { ...DEFAULT_RESOURCE_LIMITS, maxPayloadSize: 50 * 1024 }
268
- expect(validatePayloadSize(component, strict).valid).toBe(false)
269
- })
270
- })
310
+ const component = mapComponentOfSize(60 * 1024);
311
+ const strict = { ...DEFAULT_RESOURCE_LIMITS, maxPayloadSize: 50 * 1024 };
312
+ expect(validatePayloadSize(component, strict).valid).toBe(false);
313
+ });
314
+ });
271
315
 
272
316
  describe('validateChartComponent — scatter/bubble/time-series', () => {
273
317
  it('validates scatter chart without labels', () => {
274
318
  const result = validateChartComponent({
275
319
  type: 'scatter',
276
- data: { datasets: [{ label: 'Test', data: [{ x: 1, y: 2 }, { x: 3, y: 4 }] }] },
277
- } as any)
278
- expect(result.valid).toBe(true)
279
- })
320
+ data: {
321
+ datasets: [
322
+ {
323
+ label: 'Test',
324
+ data: [
325
+ { x: 1, y: 2 },
326
+ { x: 3, y: 4 },
327
+ ],
328
+ },
329
+ ],
330
+ },
331
+ } as any);
332
+ expect(result.valid).toBe(true);
333
+ });
280
334
 
281
335
  it('validates bubble chart without labels', () => {
282
336
  const result = validateChartComponent({
283
337
  type: 'bubble',
284
338
  data: { datasets: [{ label: 'Test', data: [{ x: 1, y: 2, r: 5 }] }] },
285
- } as any)
286
- expect(result.valid).toBe(true)
287
- })
339
+ } as any);
340
+ expect(result.valid).toBe(true);
341
+ });
288
342
 
289
343
  it('validates line chart with time-series object data', () => {
290
344
  const result = validateChartComponent({
291
345
  type: 'line',
292
- data: { datasets: [{ label: 'Prix', data: [{ x: '2024-01-01', y: 42 }, { x: '2024-02-01', y: 45 }] }] },
293
- } as any)
294
- expect(result.valid).toBe(true)
295
- })
346
+ data: {
347
+ datasets: [
348
+ {
349
+ label: 'Prix',
350
+ data: [
351
+ { x: '2024-01-01', y: 42 },
352
+ { x: '2024-02-01', y: 45 },
353
+ ],
354
+ },
355
+ ],
356
+ },
357
+ } as any);
358
+ expect(result.valid).toBe(true);
359
+ });
296
360
 
297
361
  it('rejects scatter with number data instead of {x,y}', () => {
298
362
  const result = validateChartComponent({
299
363
  type: 'scatter',
300
364
  data: { datasets: [{ label: 'Test', data: [1, 2, 3] }] },
301
- } as any)
302
- expect(result.valid).toBe(false)
303
- expect(result.errors?.some((e) => e.code === 'INVALID_POINT_DATA')).toBe(true)
304
- })
365
+ } as any);
366
+ expect(result.valid).toBe(false);
367
+ expect(result.errors?.some((e) => e.code === 'INVALID_POINT_DATA')).toBe(true);
368
+ });
305
369
 
306
370
  it('rejects bar chart without labels', () => {
307
371
  const result = validateChartComponent({
308
372
  type: 'bar',
309
373
  data: { datasets: [{ label: 'Test', data: [1, 2, 3] }] },
310
- } as any)
311
- expect(result.valid).toBe(false)
312
- expect(result.errors?.some((e) => e.code === 'MISSING_LABELS')).toBe(true)
313
- })
374
+ } as any);
375
+ expect(result.valid).toBe(false);
376
+ expect(result.errors?.some((e) => e.code === 'MISSING_LABELS')).toBe(true);
377
+ });
314
378
 
315
379
  it('accepts empty dataset without length mismatch', () => {
316
380
  const result = validateChartComponent({
317
381
  type: 'bar',
318
382
  data: { labels: ['A', 'B'], datasets: [{ label: 'Test', data: [] }] },
319
- } as any)
383
+ } as any);
320
384
  // Empty dataset should not trigger DATA_LENGTH_MISMATCH
321
- const mismatch = result.errors?.find((e) => e.code === 'DATA_LENGTH_MISMATCH')
322
- expect(mismatch).toBeUndefined()
323
- })
324
- })
385
+ const mismatch = result.errors?.find((e) => e.code === 'DATA_LENGTH_MISMATCH');
386
+ expect(mismatch).toBeUndefined();
387
+ });
388
+ });
325
389
 
326
390
  describe('validateChartComponent — H1 null guards', () => {
327
-
328
391
  it('rejects chart with undefined data', () => {
329
- const result = validateChartComponent({ type: 'bar', data: undefined as any } as any)
330
- expect(result.valid).toBe(false)
331
- expect(result.errors?.[0].code).toBe('MISSING_DATA')
332
- })
392
+ const result = validateChartComponent({ type: 'bar', data: undefined as any } as any);
393
+ expect(result.valid).toBe(false);
394
+ expect(result.errors?.[0].code).toBe('MISSING_DATA');
395
+ });
333
396
 
334
397
  it('rejects chart with missing datasets', () => {
335
- const result = validateChartComponent({ type: 'bar', data: { labels: ['A'] } } as any)
336
- expect(result.valid).toBe(false)
337
- expect(result.errors?.[0].code).toBe('MISSING_DATASETS')
338
- })
398
+ const result = validateChartComponent({ type: 'bar', data: { labels: ['A'] } } as any);
399
+ expect(result.valid).toBe(false);
400
+ expect(result.errors?.[0].code).toBe('MISSING_DATASETS');
401
+ });
339
402
 
340
403
  it('rejects chart with missing labels', () => {
341
- const result = validateChartComponent({ type: 'bar', data: { datasets: [{ label: 'X', data: [1] }] } } as any)
342
- expect(result.valid).toBe(false)
343
- expect(result.errors?.[0].code).toBe('MISSING_LABELS')
344
- })
404
+ const result = validateChartComponent({
405
+ type: 'bar',
406
+ data: { datasets: [{ label: 'X', data: [1] }] },
407
+ } as any);
408
+ expect(result.valid).toBe(false);
409
+ expect(result.errors?.[0].code).toBe('MISSING_LABELS');
410
+ });
345
411
 
346
412
  it('validates chart with proper data', () => {
347
413
  const result = validateChartComponent({
348
414
  type: 'bar',
349
415
  data: { labels: ['A', 'B'], datasets: [{ label: 'X', data: [1, 2] }] },
350
- } as any)
351
- expect(result.valid).toBe(true)
352
- })
353
- })
416
+ } as any);
417
+ expect(result.valid).toBe(true);
418
+ });
419
+ });
354
420
 
355
421
  describe('getIframeSandbox — tiered sandbox', () => {
356
422
  it('gives full sandbox to trusted domains (Google)', () => {
357
- const sandbox = getIframeSandbox('https://docs.google.com/spreadsheets/d/123')
358
- expect(sandbox).toContain('allow-same-origin')
359
- expect(sandbox).toContain('allow-scripts')
360
- expect(sandbox).toContain('allow-forms')
361
- })
423
+ const sandbox = getIframeSandbox('https://docs.google.com/spreadsheets/d/123');
424
+ expect(sandbox).toContain('allow-same-origin');
425
+ expect(sandbox).toContain('allow-scripts');
426
+ expect(sandbox).toContain('allow-forms');
427
+ });
362
428
 
363
429
  it('gives full sandbox to Deposium domains', () => {
364
- const sandbox = getIframeSandbox('https://deposium.com/embed/123')
365
- expect(sandbox).toContain('allow-same-origin')
366
- })
430
+ const sandbox = getIframeSandbox('https://deposium.com/embed/123');
431
+ expect(sandbox).toContain('allow-same-origin');
432
+ });
367
433
 
368
434
  it('gives full sandbox to payment domains (Stripe)', () => {
369
- const sandbox = getIframeSandbox('https://checkout.stripe.com/c/pay_123')
370
- expect(sandbox).toContain('allow-same-origin')
371
- expect(sandbox).toContain('allow-forms')
372
- })
435
+ const sandbox = getIframeSandbox('https://checkout.stripe.com/c/pay_123');
436
+ expect(sandbox).toContain('allow-same-origin');
437
+ expect(sandbox).toContain('allow-forms');
438
+ });
373
439
 
374
440
  it('gives full sandbox to Polar.sh', () => {
375
- const sandbox = getIframeSandbox('https://polar.sh/checkout/123')
376
- expect(sandbox).toContain('allow-same-origin')
377
- })
441
+ const sandbox = getIframeSandbox('https://polar.sh/checkout/123');
442
+ expect(sandbox).toContain('allow-same-origin');
443
+ });
378
444
 
379
445
  it('gives restrictive sandbox to untrusted whitelisted domains (quickchart)', () => {
380
- const sandbox = getIframeSandbox('https://quickchart.io/chart?c={}')
381
- expect(sandbox).toContain('allow-scripts')
382
- expect(sandbox).toContain('allow-popups')
383
- expect(sandbox).not.toContain('allow-same-origin')
384
- expect(sandbox).not.toContain('allow-forms')
385
- })
446
+ const sandbox = getIframeSandbox('https://quickchart.io/chart?c={}');
447
+ expect(sandbox).toContain('allow-scripts');
448
+ expect(sandbox).toContain('allow-popups');
449
+ expect(sandbox).not.toContain('allow-same-origin');
450
+ expect(sandbox).not.toContain('allow-forms');
451
+ });
386
452
 
387
453
  it('gives restrictive sandbox to YouTube', () => {
388
- const sandbox = getIframeSandbox('https://www.youtube.com/embed/abc123')
389
- expect(sandbox).not.toContain('allow-same-origin')
390
- })
454
+ const sandbox = getIframeSandbox('https://www.youtube.com/embed/abc123');
455
+ expect(sandbox).not.toContain('allow-same-origin');
456
+ });
391
457
 
392
458
  it('gives restrictive sandbox to unknown domains', () => {
393
- const sandbox = getIframeSandbox('https://evil.example.com/page')
394
- expect(sandbox).not.toContain('allow-same-origin')
395
- })
459
+ const sandbox = getIframeSandbox('https://evil.example.com/page');
460
+ expect(sandbox).not.toContain('allow-same-origin');
461
+ });
396
462
 
397
463
  it('handles invalid URLs gracefully', () => {
398
- const sandbox = getIframeSandbox('not-a-url')
399
- expect(sandbox).toBe('allow-scripts allow-popups')
400
- })
464
+ const sandbox = getIframeSandbox('not-a-url');
465
+ expect(sandbox).toBe('allow-scripts allow-popups');
466
+ });
401
467
 
402
468
  it('supports custom trusted domains', () => {
403
469
  const sandbox = getIframeSandbox('https://my-internal-tool.corp.com/dash', {
404
470
  customTrustedDomains: ['my-internal-tool.corp.com'],
405
- })
406
- expect(sandbox).toContain('allow-same-origin')
407
- })
408
- })
471
+ });
472
+ expect(sandbox).toContain('allow-same-origin');
473
+ });
474
+ });
409
475
 
410
476
  describe('validateIframeDomain — security regression (v5.5.1)', () => {
411
477
  // Pre-v5.5.1 bug: the predicate was
@@ -415,43 +481,43 @@ describe('validateIframeDomain — security regression (v5.5.1)', () => {
415
481
  // every URL was accepted. These tests lock the fixed behavior in place.
416
482
 
417
483
  it('REJECTS a non-whitelisted external domain (this used to silently pass)', () => {
418
- const result = validateIframeDomain('https://evil.example.com/x')
419
- expect(result.valid).toBe(false)
420
- expect(result.errors?.[0]?.code).toBe('DOMAIN_NOT_WHITELISTED')
421
- })
484
+ const result = validateIframeDomain('https://evil.example.com/x');
485
+ expect(result.valid).toBe(false);
486
+ expect(result.errors?.[0]?.code).toBe('DOMAIN_NOT_WHITELISTED');
487
+ });
422
488
 
423
489
  it('REJECTS a typo-squat that is NOT a subdomain of any whitelisted entry', () => {
424
490
  // youtube-evil.com is not youtube.com nor a subdomain of it
425
- const result = validateIframeDomain('https://youtube-evil.com/embed/x')
426
- expect(result.valid).toBe(false)
427
- expect(result.errors?.[0]?.code).toBe('DOMAIN_NOT_WHITELISTED')
428
- })
491
+ const result = validateIframeDomain('https://youtube-evil.com/embed/x');
492
+ expect(result.valid).toBe(false);
493
+ expect(result.errors?.[0]?.code).toBe('DOMAIN_NOT_WHITELISTED');
494
+ });
429
495
 
430
496
  it('still accepts a whitelisted domain (quickchart.io)', () => {
431
- expect(validateIframeDomain('https://quickchart.io/chart?c={}').valid).toBe(true)
432
- })
497
+ expect(validateIframeDomain('https://quickchart.io/chart?c={}').valid).toBe(true);
498
+ });
433
499
 
434
500
  it('still accepts subdomains of whitelisted entries (player.vimeo.com)', () => {
435
- expect(validateIframeDomain('https://player.vimeo.com/video/123').valid).toBe(true)
436
- })
501
+ expect(validateIframeDomain('https://player.vimeo.com/video/123').valid).toBe(true);
502
+ });
437
503
 
438
504
  it('still accepts localhost (dev convenience)', () => {
439
- expect(validateIframeDomain('http://localhost:3000/x').valid).toBe(true)
440
- })
505
+ expect(validateIframeDomain('http://localhost:3000/x').valid).toBe(true);
506
+ });
441
507
 
442
508
  it('still accepts 127.0.0.1 (loopback equivalent of localhost)', () => {
443
- expect(validateIframeDomain('http://127.0.0.1:8080/x').valid).toBe(true)
444
- })
509
+ expect(validateIframeDomain('http://127.0.0.1:8080/x').valid).toBe(true);
510
+ });
445
511
 
446
512
  it('respects allow-all policy bypass', () => {
447
- expect(validateIframeDomain('https://anything.com', { policy: 'allow-all' }).valid).toBe(true)
448
- })
513
+ expect(validateIframeDomain('https://anything.com', { policy: 'allow-all' }).valid).toBe(true);
514
+ });
449
515
 
450
516
  it('extend policy adds custom domains', () => {
451
517
  const result = validateIframeDomain('https://my-internal-tool.corp.com/x', {
452
518
  policy: 'extend',
453
519
  customDomains: ['my-internal-tool.corp.com'],
454
- })
455
- expect(result.valid).toBe(true)
456
- })
457
- })
520
+ });
521
+ expect(result.valid).toBe(true);
522
+ });
523
+ });