@kopai/ui 0.0.5 → 0.1.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 (125) hide show
  1. package/README.md +137 -0
  2. package/dist/index.cjs +5069 -3
  3. package/dist/index.d.cts +301 -3
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +302 -3
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +5010 -3
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +25 -7
  10. package/src/components/KeyboardShortcuts/KeyboardShortcutsProvider.tsx +113 -0
  11. package/src/components/KeyboardShortcuts/ShortcutsHelpDialog.tsx +82 -0
  12. package/src/components/KeyboardShortcuts/context.ts +23 -0
  13. package/src/components/KeyboardShortcuts/index.ts +8 -0
  14. package/src/components/KeyboardShortcuts/types.ts +11 -0
  15. package/src/components/dashboard/Badge/Badge.stories.tsx +29 -0
  16. package/src/components/dashboard/Badge/index.tsx +32 -0
  17. package/src/components/dashboard/Button/Button.stories.tsx +107 -0
  18. package/src/components/dashboard/Button/index.tsx +63 -0
  19. package/src/components/dashboard/Card/Card.stories.tsx +81 -0
  20. package/src/components/dashboard/Card/index.tsx +58 -0
  21. package/src/components/dashboard/Chart/Chart.stories.tsx +48 -0
  22. package/src/components/dashboard/Chart/index.tsx +74 -0
  23. package/src/components/dashboard/DatePicker/DatePicker.stories.tsx +33 -0
  24. package/src/components/dashboard/DatePicker/index.tsx +41 -0
  25. package/src/components/dashboard/Divider/Divider.stories.tsx +17 -0
  26. package/src/components/dashboard/Divider/index.tsx +49 -0
  27. package/src/components/dashboard/Empty/Empty.stories.tsx +48 -0
  28. package/src/components/dashboard/Empty/index.tsx +46 -0
  29. package/src/components/dashboard/Grid/Grid.stories.tsx +52 -0
  30. package/src/components/dashboard/Grid/index.tsx +26 -0
  31. package/src/components/dashboard/Heading/Heading.stories.tsx +25 -0
  32. package/src/components/dashboard/Heading/index.tsx +27 -0
  33. package/src/components/dashboard/List/List.stories.tsx +37 -0
  34. package/src/components/dashboard/List/index.tsx +24 -0
  35. package/src/components/dashboard/Metric/Metric.stories.tsx +65 -0
  36. package/src/components/dashboard/Metric/index.tsx +36 -0
  37. package/src/components/dashboard/Stack/Stack.stories.tsx +61 -0
  38. package/src/components/dashboard/Stack/index.tsx +33 -0
  39. package/src/components/dashboard/Table/Table.stories.tsx +38 -0
  40. package/src/components/dashboard/Table/index.tsx +104 -0
  41. package/src/components/dashboard/Text/Text.stories.tsx +53 -0
  42. package/src/components/dashboard/Text/index.tsx +18 -0
  43. package/src/components/dashboard/index.ts +46 -0
  44. package/src/components/index.ts +17 -0
  45. package/src/components/observability/LogTimeline/LogDetailPane/AttributesTab.tsx +56 -0
  46. package/src/components/observability/LogTimeline/LogDetailPane/JsonTreeView.tsx +139 -0
  47. package/src/components/observability/LogTimeline/LogDetailPane/index.tsx +271 -0
  48. package/src/components/observability/LogTimeline/LogFilter.stories.tsx +66 -0
  49. package/src/components/observability/LogTimeline/LogFilter.test.tsx +696 -0
  50. package/src/components/observability/LogTimeline/LogFilter.tsx +674 -0
  51. package/src/components/observability/LogTimeline/LogRow.tsx +174 -0
  52. package/src/components/observability/LogTimeline/LogTimeline.stories.tsx +154 -0
  53. package/src/components/observability/LogTimeline/index.tsx +542 -0
  54. package/src/components/observability/LogTimeline/shortcuts.ts +18 -0
  55. package/src/components/observability/MetricHistogram/MetricHistogram.stories.tsx +20 -0
  56. package/src/components/observability/MetricHistogram/index.tsx +303 -0
  57. package/src/components/observability/MetricStat/MetricStat.stories.tsx +30 -0
  58. package/src/components/observability/MetricStat/index.tsx +281 -0
  59. package/src/components/observability/MetricTable/MetricTable.stories.tsx +20 -0
  60. package/src/components/observability/MetricTable/index.tsx +194 -0
  61. package/src/components/observability/MetricTimeSeries/MetricTimeSeries.stories.tsx +28 -0
  62. package/src/components/observability/MetricTimeSeries/index.tsx +462 -0
  63. package/src/components/observability/RawDataTable/RawDataTable.stories.tsx +27 -0
  64. package/src/components/observability/RawDataTable/index.tsx +131 -0
  65. package/src/components/observability/ServiceList/ServiceList.stories.tsx +20 -0
  66. package/src/components/observability/ServiceList/index.tsx +60 -0
  67. package/src/components/observability/ServiceList/shortcuts.ts +6 -0
  68. package/src/components/observability/TabBar/TabBar.stories.tsx +34 -0
  69. package/src/components/observability/TabBar/index.tsx +46 -0
  70. package/src/components/observability/TraceDetail/TraceDetail.stories.tsx +51 -0
  71. package/src/components/observability/TraceDetail/index.tsx +53 -0
  72. package/src/components/observability/TraceSearch/TraceSearch.stories.tsx +49 -0
  73. package/src/components/observability/TraceSearch/index.tsx +292 -0
  74. package/src/components/observability/TraceTimeline/DetailPane/AttributesTab.tsx +152 -0
  75. package/src/components/observability/TraceTimeline/DetailPane/EventsTab.tsx +128 -0
  76. package/src/components/observability/TraceTimeline/DetailPane/LinksTab.tsx +210 -0
  77. package/src/components/observability/TraceTimeline/DetailPane/index.tsx +174 -0
  78. package/src/components/observability/TraceTimeline/SpanRow.tsx +173 -0
  79. package/src/components/observability/TraceTimeline/TimelineBar.tsx +41 -0
  80. package/src/components/observability/TraceTimeline/Tooltip.tsx +42 -0
  81. package/src/components/observability/TraceTimeline/TraceHeader.tsx +88 -0
  82. package/src/components/observability/TraceTimeline/TraceTimeline.stories.tsx +25 -0
  83. package/src/components/observability/TraceTimeline/index.tsx +478 -0
  84. package/src/components/observability/TraceTimeline/shortcuts.ts +16 -0
  85. package/src/components/observability/__fixtures__/logs.ts +476 -0
  86. package/src/components/observability/__fixtures__/metrics.ts +216 -0
  87. package/src/components/observability/__fixtures__/raw-table.ts +204 -0
  88. package/src/components/observability/__fixtures__/services.ts +8 -0
  89. package/src/components/observability/__fixtures__/trace-summaries.ts +81 -0
  90. package/src/components/observability/__fixtures__/traces.ts +396 -0
  91. package/src/components/observability/index.ts +66 -0
  92. package/src/components/observability/renderers/OtelMetricDiscovery.tsx +77 -0
  93. package/src/components/observability/renderers/OtelMetricHistogram.tsx +29 -0
  94. package/src/components/observability/renderers/OtelMetricStat.tsx +44 -0
  95. package/src/components/observability/renderers/OtelMetricTable.tsx +29 -0
  96. package/src/components/observability/renderers/OtelMetricTimeSeries.tsx +30 -0
  97. package/src/components/observability/renderers/index.ts +5 -0
  98. package/src/components/observability/shared/LoadingSkeleton.tsx +43 -0
  99. package/src/components/observability/types.ts +113 -0
  100. package/src/components/observability/utils/attributes.ts +17 -0
  101. package/src/components/observability/utils/colors.ts +29 -0
  102. package/src/components/observability/utils/flatten-tree.ts +53 -0
  103. package/src/components/observability/utils/lttb.ts +121 -0
  104. package/src/components/observability/utils/time.ts +46 -0
  105. package/src/hooks/use-kopai-data.test.ts +296 -0
  106. package/src/hooks/use-kopai-data.ts +64 -0
  107. package/src/hooks/use-live-logs.test.ts +193 -0
  108. package/src/hooks/use-live-logs.ts +113 -0
  109. package/src/index.ts +15 -0
  110. package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +33 -0
  111. package/src/lib/catalog.ts +165 -0
  112. package/src/lib/component-catalog.test.ts +357 -0
  113. package/src/lib/component-catalog.ts +171 -0
  114. package/src/lib/dashboard-datasource.ts +76 -0
  115. package/src/lib/generate-prompt-instructions.test.ts +27 -0
  116. package/src/lib/generate-prompt-instructions.ts +185 -0
  117. package/src/lib/log-buffer.test.ts +88 -0
  118. package/src/lib/log-buffer.ts +62 -0
  119. package/src/lib/observability-catalog.ts +143 -0
  120. package/src/lib/renderer.test.tsx +693 -0
  121. package/src/lib/renderer.tsx +276 -0
  122. package/src/pages/observability.tsx +828 -0
  123. package/src/providers/kopai-provider.tsx +51 -0
  124. package/src/styles/globals.css +46 -0
  125. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,693 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { describe, it, expect, vi, beforeEach } from "vitest";
5
+ import { createElement, type ReactNode } from "react";
6
+ import { renderToStaticMarkup } from "react-dom/server";
7
+ import { render, screen, waitFor, act } from "@testing-library/react";
8
+ import {
9
+ createRendererFromCatalog,
10
+ type RendererComponentProps,
11
+ } from "./renderer.js";
12
+ import { KopaiSDKProvider, queryClient } from "../providers/kopai-provider.js";
13
+ import { createCatalog } from "./component-catalog.js";
14
+ import z from "zod";
15
+ import type { KopaiClient } from "@kopai/sdk";
16
+
17
+ // Create a simple catalog and derive UITree type
18
+ const _testCatalog = createCatalog({
19
+ name: "test",
20
+ components: {
21
+ Box: {
22
+ hasChildren: true,
23
+ description: "A box",
24
+ props: z.object({}),
25
+ },
26
+ Text: {
27
+ hasChildren: false,
28
+ description: "Text",
29
+ props: z.object({ content: z.string() }),
30
+ },
31
+ Capture: {
32
+ hasChildren: false,
33
+ description: "Captures props",
34
+ props: z.object({ content: z.string() }),
35
+ },
36
+ DataComponent: {
37
+ hasChildren: false,
38
+ description: "Data test component",
39
+ props: z.object({}),
40
+ },
41
+ RefetchComponent: {
42
+ hasChildren: false,
43
+ description: "Refetch test component",
44
+ props: z.object({}),
45
+ },
46
+ },
47
+ });
48
+
49
+ type UITree = z.infer<typeof _testCatalog.uiTreeSchema>;
50
+
51
+ type MockClient = {
52
+ searchTracesPage: ReturnType<typeof vi.fn>;
53
+ searchLogsPage: ReturnType<typeof vi.fn>;
54
+ searchMetricsPage: ReturnType<typeof vi.fn>;
55
+ getTrace: ReturnType<typeof vi.fn>;
56
+ discoverMetrics: ReturnType<typeof vi.fn>;
57
+ searchTraces: ReturnType<typeof vi.fn>;
58
+ searchLogs: ReturnType<typeof vi.fn>;
59
+ searchMetrics: ReturnType<typeof vi.fn>;
60
+ };
61
+
62
+ function createWrapper(client: MockClient) {
63
+ return function Wrapper({ children }: { children: ReactNode }) {
64
+ return createElement(KopaiSDKProvider, {
65
+ client: client as unknown as KopaiClient,
66
+ children,
67
+ });
68
+ };
69
+ }
70
+
71
+ // Simple test components
72
+ function Box({
73
+ element,
74
+ children,
75
+ }: RendererComponentProps<typeof _testCatalog.components.Box>) {
76
+ return createElement(
77
+ "div",
78
+ { "data-type": element.type, "data-key": element.key },
79
+ children
80
+ );
81
+ }
82
+
83
+ function Text({
84
+ element,
85
+ }: RendererComponentProps<typeof _testCatalog.components.Text>) {
86
+ const { content } = element.props;
87
+ return createElement("span", null, content);
88
+ }
89
+
90
+ function Capture(
91
+ _props: RendererComponentProps<typeof _testCatalog.components.Capture>
92
+ ) {
93
+ return createElement("div", null, "captured");
94
+ }
95
+
96
+ function DataComponent(
97
+ props: RendererComponentProps<typeof _testCatalog.components.DataComponent>
98
+ ) {
99
+ if (!props.hasData) {
100
+ return createElement("div", { "data-testid": "no-data" }, "No data source");
101
+ }
102
+ const { data, loading, error } = props;
103
+ if (loading)
104
+ return createElement("div", { "data-testid": "loading" }, "Loading...");
105
+ if (error)
106
+ return createElement("div", { "data-testid": "error" }, error.message);
107
+ return createElement("div", { "data-testid": "data" }, JSON.stringify(data));
108
+ }
109
+
110
+ function RefetchComponent(
111
+ props: RendererComponentProps<typeof _testCatalog.components.RefetchComponent>
112
+ ) {
113
+ if (!props.hasData) return null;
114
+ return createElement(
115
+ "div",
116
+ { "data-testid": "data" },
117
+ JSON.stringify(props.data)
118
+ );
119
+ }
120
+
121
+ const TestRenderer = createRendererFromCatalog(_testCatalog, {
122
+ Box,
123
+ Text,
124
+ Capture,
125
+ DataComponent,
126
+ RefetchComponent,
127
+ });
128
+
129
+ describe("Renderer", () => {
130
+ it("renders null for null tree", () => {
131
+ const result = renderToStaticMarkup(
132
+ createElement(TestRenderer, { tree: null })
133
+ );
134
+ expect(result).toBe("");
135
+ });
136
+
137
+ it("renders null for tree without root", () => {
138
+ const tree = { root: "", elements: {} } as unknown as UITree;
139
+ const result = renderToStaticMarkup(createElement(TestRenderer, { tree }));
140
+ expect(result).toBe("");
141
+ });
142
+
143
+ it("renders single element", () => {
144
+ const tree = {
145
+ root: "text-1",
146
+ elements: {
147
+ "text-1": {
148
+ key: "text-1",
149
+ type: "Text",
150
+ children: [],
151
+ parentKey: "",
152
+ props: { content: "Hello" },
153
+ },
154
+ },
155
+ } satisfies UITree; // should be like this, not casts
156
+ const result = renderToStaticMarkup(createElement(TestRenderer, { tree }));
157
+ expect(result).toBe("<span>Hello</span>");
158
+ });
159
+
160
+ it("renders nested elements", () => {
161
+ const tree = {
162
+ root: "box-1",
163
+ elements: {
164
+ "box-1": {
165
+ key: "box-1",
166
+ type: "Box",
167
+ props: {},
168
+ children: ["text-1"],
169
+ parentKey: "",
170
+ },
171
+ "text-1": {
172
+ key: "text-1",
173
+ type: "Text",
174
+ props: { content: "Nested" },
175
+ children: [],
176
+ parentKey: "box-1",
177
+ },
178
+ },
179
+ } satisfies UITree;
180
+ const result = renderToStaticMarkup(createElement(TestRenderer, { tree }));
181
+ expect(result).toBe(
182
+ '<div data-type="Box" data-key="box-1"><span>Nested</span></div>'
183
+ );
184
+ });
185
+
186
+ it("renders deeply nested tree", () => {
187
+ const tree = {
188
+ root: "box-1",
189
+ elements: {
190
+ "box-1": {
191
+ key: "box-1",
192
+ type: "Box",
193
+ props: {},
194
+ children: ["box-2"],
195
+ parentKey: "",
196
+ },
197
+ "box-2": {
198
+ key: "box-2",
199
+ type: "Box",
200
+ props: {},
201
+ children: ["text-1"],
202
+ parentKey: "box-1",
203
+ },
204
+ "text-1": {
205
+ key: "text-1",
206
+ type: "Text",
207
+ props: { content: "Deep" },
208
+ children: [],
209
+ parentKey: "box-2",
210
+ },
211
+ },
212
+ } satisfies UITree;
213
+ const result = renderToStaticMarkup(createElement(TestRenderer, { tree }));
214
+ expect(result).toContain("Deep");
215
+ expect(result).toContain('data-key="box-2"');
216
+ });
217
+
218
+ it("skips children with missing elements", () => {
219
+ const tree = {
220
+ root: "box-1",
221
+ elements: {
222
+ "box-1": {
223
+ key: "box-1",
224
+ type: "Box",
225
+ props: {},
226
+ children: ["missing-1", "text-1"],
227
+ parentKey: "",
228
+ },
229
+ "text-1": {
230
+ key: "text-1",
231
+ type: "Text",
232
+ props: { content: "Present" },
233
+ children: [],
234
+ parentKey: "box-1",
235
+ },
236
+ },
237
+ } satisfies UITree;
238
+ const result = renderToStaticMarkup(createElement(TestRenderer, { tree }));
239
+ expect(result).toContain("Present");
240
+ expect(result).not.toContain("missing");
241
+ });
242
+
243
+ it("passes hasData=false for elements without dataSource", () => {
244
+ let receivedProps: RendererComponentProps<
245
+ typeof _testCatalog.components.Capture
246
+ > | null = null;
247
+ function CaptureLocal(
248
+ props: RendererComponentProps<typeof _testCatalog.components.Capture>
249
+ ) {
250
+ receivedProps = props;
251
+ return createElement("div", null, "captured");
252
+ }
253
+ const LocalRenderer = createRendererFromCatalog(_testCatalog, {
254
+ Box,
255
+ Text,
256
+ Capture: CaptureLocal,
257
+ DataComponent,
258
+ RefetchComponent,
259
+ });
260
+ const tree = {
261
+ root: "capture-1",
262
+ elements: {
263
+ "capture-1": {
264
+ key: "capture-1",
265
+ type: "Capture",
266
+ props: { content: "hello" },
267
+ children: [],
268
+ parentKey: "",
269
+ },
270
+ },
271
+ } satisfies UITree;
272
+ renderToStaticMarkup(createElement(LocalRenderer, { tree }));
273
+ expect(receivedProps).not.toBeNull();
274
+ expect(receivedProps!.hasData).toBe(false);
275
+ expect(receivedProps!.element.props).toEqual({ content: "hello" });
276
+ });
277
+ });
278
+
279
+ describe("Renderer with dataSource", () => {
280
+ const createMockClient = (): MockClient => ({
281
+ searchTracesPage: vi.fn(),
282
+ searchLogsPage: vi.fn(),
283
+ searchMetricsPage: vi.fn(),
284
+ getTrace: vi.fn(),
285
+ discoverMetrics: vi.fn(),
286
+ searchTraces: vi.fn(),
287
+ searchLogs: vi.fn(),
288
+ searchMetrics: vi.fn(),
289
+ });
290
+
291
+ let mockClient: MockClient;
292
+
293
+ beforeEach(() => {
294
+ mockClient = createMockClient();
295
+ queryClient.clear();
296
+ vi.clearAllMocks();
297
+ });
298
+
299
+ it("passes data props to component with dataSource", async () => {
300
+ mockClient.searchTracesPage.mockResolvedValueOnce({
301
+ data: [{ traceId: "abc" }],
302
+ });
303
+
304
+ const tree = {
305
+ root: "data-1",
306
+ elements: {
307
+ "data-1": {
308
+ key: "data-1",
309
+ type: "DataComponent",
310
+ props: {},
311
+ children: [],
312
+ parentKey: "",
313
+ dataSource: { method: "searchTracesPage", params: { limit: 10 } },
314
+ },
315
+ },
316
+ } satisfies UITree;
317
+
318
+ const Wrapper = createWrapper(mockClient);
319
+ render(createElement(TestRenderer, { tree }), {
320
+ wrapper: Wrapper,
321
+ });
322
+
323
+ // Initially loading
324
+ expect(screen.getByTestId("loading")).toBeDefined();
325
+
326
+ // After data loads
327
+ await waitFor(() => {
328
+ expect(screen.queryByTestId("data")).not.toBeNull();
329
+ });
330
+ expect(screen.getByTestId("data").textContent).toBe(
331
+ '{"data":[{"traceId":"abc"}]}'
332
+ );
333
+ });
334
+
335
+ it("passes loading state correctly", async () => {
336
+ let resolvePromise: (value: unknown) => void;
337
+ const promise = new Promise((resolve) => {
338
+ resolvePromise = resolve;
339
+ });
340
+ mockClient.searchTracesPage.mockReturnValueOnce(promise);
341
+
342
+ const tree = {
343
+ root: "data-1",
344
+ elements: {
345
+ "data-1": {
346
+ key: "data-1",
347
+ type: "DataComponent",
348
+ props: {},
349
+ children: [],
350
+ parentKey: "",
351
+ dataSource: { method: "searchTracesPage", params: {} },
352
+ },
353
+ },
354
+ } satisfies UITree;
355
+
356
+ const Wrapper = createWrapper(mockClient);
357
+ render(createElement(TestRenderer, { tree }), {
358
+ wrapper: Wrapper,
359
+ });
360
+
361
+ expect(screen.getByTestId("loading")).toBeDefined();
362
+
363
+ resolvePromise!({ data: [] });
364
+ await waitFor(() => {
365
+ expect(screen.queryByTestId("data")).not.toBeNull();
366
+ });
367
+ });
368
+
369
+ it("passes error state correctly", async () => {
370
+ mockClient.searchTracesPage.mockRejectedValueOnce(
371
+ new Error("Network error")
372
+ );
373
+
374
+ const tree = {
375
+ root: "data-1",
376
+ elements: {
377
+ "data-1": {
378
+ key: "data-1",
379
+ type: "DataComponent",
380
+ props: {},
381
+ children: [],
382
+ parentKey: "",
383
+ dataSource: { method: "searchTracesPage", params: {} },
384
+ },
385
+ },
386
+ } satisfies UITree;
387
+
388
+ const Wrapper = createWrapper(mockClient);
389
+ render(createElement(TestRenderer, { tree }), {
390
+ wrapper: Wrapper,
391
+ });
392
+
393
+ await waitFor(() => {
394
+ expect(screen.queryByTestId("error")).not.toBeNull();
395
+ });
396
+ expect(screen.getByTestId("error").textContent).toBe("Network error");
397
+ });
398
+
399
+ it("provides updateParams that triggers refetch with new params", async () => {
400
+ mockClient.searchTracesPage
401
+ .mockResolvedValueOnce({ data: [{ traceId: "first" }] })
402
+ .mockResolvedValueOnce({ data: [{ traceId: "second" }] });
403
+
404
+ let capturedUpdateParams:
405
+ | ((params: Record<string, unknown>) => void)
406
+ | null = null;
407
+ function RefetchComponentLocal(
408
+ props: RendererComponentProps<
409
+ typeof _testCatalog.components.RefetchComponent
410
+ >
411
+ ) {
412
+ if (!props.hasData) return null;
413
+ capturedUpdateParams = props.updateParams;
414
+ return createElement(
415
+ "div",
416
+ { "data-testid": "data" },
417
+ JSON.stringify(props.data)
418
+ );
419
+ }
420
+
421
+ const LocalRenderer = createRendererFromCatalog(_testCatalog, {
422
+ Box,
423
+ Text,
424
+ Capture,
425
+ DataComponent,
426
+ RefetchComponent: RefetchComponentLocal,
427
+ });
428
+
429
+ const tree = {
430
+ root: "data-1",
431
+ elements: {
432
+ "data-1": {
433
+ key: "data-1",
434
+ type: "RefetchComponent",
435
+ props: {},
436
+ children: [],
437
+ parentKey: "",
438
+ dataSource: { method: "searchTracesPage", params: {} },
439
+ },
440
+ },
441
+ } satisfies UITree;
442
+
443
+ const Wrapper = createWrapper(mockClient);
444
+ render(createElement(LocalRenderer, { tree }), {
445
+ wrapper: Wrapper,
446
+ });
447
+
448
+ await waitFor(() => {
449
+ expect(capturedUpdateParams).not.toBeNull();
450
+ });
451
+
452
+ act(() => {
453
+ capturedUpdateParams!({ limit: 5 });
454
+ });
455
+
456
+ await waitFor(() => {
457
+ expect(mockClient.searchTracesPage).toHaveBeenCalledTimes(2);
458
+ });
459
+ });
460
+
461
+ it("renders element without dataSource normally", () => {
462
+ const tree = {
463
+ root: "data-1",
464
+ elements: {
465
+ "data-1": {
466
+ key: "data-1",
467
+ type: "DataComponent",
468
+ props: {},
469
+ children: [],
470
+ parentKey: "",
471
+ // No dataSource
472
+ },
473
+ },
474
+ } satisfies UITree;
475
+
476
+ const Wrapper = createWrapper(mockClient);
477
+ render(createElement(TestRenderer, { tree }), {
478
+ wrapper: Wrapper,
479
+ });
480
+
481
+ expect(screen.getByTestId("no-data")).toBeDefined();
482
+ expect(screen.getByTestId("no-data").textContent).toBe("No data source");
483
+ });
484
+ });
485
+
486
+ describe("createRendererFromCatalog integration", () => {
487
+ const integrationCatalog = createCatalog({
488
+ name: "integration-test",
489
+ components: {
490
+ Wrapper: {
491
+ hasChildren: true,
492
+ description: "A wrapper",
493
+ props: z.object({}),
494
+ },
495
+ Label: {
496
+ hasChildren: false,
497
+ description: "A label",
498
+ props: z.object({ text: z.string() }),
499
+ },
500
+ },
501
+ });
502
+
503
+ type IntegrationUITree = z.infer<typeof integrationCatalog.uiTreeSchema>;
504
+
505
+ function Wrapper({
506
+ children,
507
+ }: RendererComponentProps<typeof integrationCatalog.components.Wrapper>) {
508
+ return createElement("div", { "data-testid": "wrapper" }, children);
509
+ }
510
+
511
+ function Label({
512
+ element,
513
+ }: RendererComponentProps<typeof integrationCatalog.components.Label>) {
514
+ return createElement(
515
+ "span",
516
+ { "data-testid": "label" },
517
+ element.props.text
518
+ );
519
+ }
520
+
521
+ const IntegrationRenderer = createRendererFromCatalog(integrationCatalog, {
522
+ Wrapper,
523
+ Label,
524
+ });
525
+
526
+ it("renders tree using createRendererFromCatalog", () => {
527
+ const tree: IntegrationUITree = {
528
+ root: "wrapper-1",
529
+ elements: {
530
+ "wrapper-1": {
531
+ key: "wrapper-1",
532
+ type: "Wrapper",
533
+ props: {},
534
+ children: ["label-1"],
535
+ parentKey: "",
536
+ },
537
+ "label-1": {
538
+ key: "label-1",
539
+ type: "Label",
540
+ props: { text: "Hello World" },
541
+ children: [],
542
+ parentKey: "wrapper-1",
543
+ },
544
+ },
545
+ };
546
+
547
+ const result = renderToStaticMarkup(
548
+ createElement(IntegrationRenderer, { tree })
549
+ );
550
+
551
+ expect(result).toContain("Hello World");
552
+ expect(result).toContain('data-testid="wrapper"');
553
+ expect(result).toContain('data-testid="label"');
554
+ });
555
+
556
+ it("renders single element tree", () => {
557
+ const tree: IntegrationUITree = {
558
+ root: "label-1",
559
+ elements: {
560
+ "label-1": {
561
+ key: "label-1",
562
+ type: "Label",
563
+ props: { text: "Test" },
564
+ children: [],
565
+ parentKey: "",
566
+ },
567
+ },
568
+ };
569
+
570
+ const result = renderToStaticMarkup(
571
+ createElement(IntegrationRenderer, { tree })
572
+ );
573
+
574
+ expect(result).toContain("Test");
575
+ });
576
+ });
577
+
578
+ describe("createRendererFromCatalog type safety", () => {
579
+ const typeCatalog = createCatalog({
580
+ name: "type-test",
581
+ components: {
582
+ Button: {
583
+ hasChildren: false,
584
+ description: "A button",
585
+ props: z.object({ label: z.string() }),
586
+ },
587
+ Container: {
588
+ hasChildren: true,
589
+ description: "A container",
590
+ props: z.object({ padding: z.number() }),
591
+ },
592
+ },
593
+ });
594
+
595
+ it("creates renderer with correct component types", () => {
596
+ expect.assertions(0);
597
+
598
+ function Button({
599
+ element,
600
+ }: RendererComponentProps<typeof typeCatalog.components.Button>) {
601
+ return createElement("button", null, element.props.label);
602
+ }
603
+
604
+ function Container({
605
+ element,
606
+ children,
607
+ }: RendererComponentProps<typeof typeCatalog.components.Container>) {
608
+ return createElement(
609
+ "div",
610
+ { style: { padding: element.props.padding } },
611
+ children
612
+ );
613
+ }
614
+
615
+ const _Renderer = createRendererFromCatalog(typeCatalog, {
616
+ Button,
617
+ Container,
618
+ });
619
+ });
620
+
621
+ it("errors when catalog component is missing", () => {
622
+ expect.assertions(0);
623
+
624
+ function Button({
625
+ element,
626
+ }: RendererComponentProps<typeof typeCatalog.components.Button>) {
627
+ return createElement("button", null, element.props.label);
628
+ }
629
+
630
+ // @ts-expect-error - Container is missing from registry
631
+ const _Renderer = createRendererFromCatalog(typeCatalog, {
632
+ Button,
633
+ });
634
+ });
635
+
636
+ it("errors when component has wrong props type", () => {
637
+ expect.assertions(0);
638
+
639
+ // Wrong props - expects { label: string } but gets { title: string }
640
+ function Button({ element }: { element: { props: { title: string } } }) {
641
+ return createElement("button", null, element.props.title);
642
+ }
643
+
644
+ function Container({
645
+ element,
646
+ children,
647
+ }: RendererComponentProps<typeof typeCatalog.components.Container>) {
648
+ return createElement(
649
+ "div",
650
+ { style: { padding: element.props.padding } },
651
+ children
652
+ );
653
+ }
654
+
655
+ const _Renderer = createRendererFromCatalog(typeCatalog, {
656
+ // @ts-expect-error - Button has wrong props type
657
+ Button,
658
+ Container,
659
+ });
660
+ });
661
+
662
+ it("errors when extra component is provided", () => {
663
+ expect.assertions(0);
664
+
665
+ function Button({
666
+ element,
667
+ }: RendererComponentProps<typeof typeCatalog.components.Button>) {
668
+ return createElement("button", null, element.props.label);
669
+ }
670
+
671
+ function Container({
672
+ element,
673
+ children,
674
+ }: RendererComponentProps<typeof typeCatalog.components.Container>) {
675
+ return createElement(
676
+ "div",
677
+ { style: { padding: element.props.padding } },
678
+ children
679
+ );
680
+ }
681
+
682
+ function Extra() {
683
+ return createElement("div", null, "extra");
684
+ }
685
+
686
+ const _Renderer = createRendererFromCatalog(typeCatalog, {
687
+ Button,
688
+ Container,
689
+ // @ts-expect-error - Extra is not in catalog
690
+ Extra,
691
+ });
692
+ });
693
+ });