@principal-ai/principal-view-core 0.25.0 → 0.26.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.
@@ -0,0 +1,521 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ buildCanvasFileManifest,
4
+ buildWorkflowFileManifest,
5
+ buildStoryboardFileManifest,
6
+ } from './CanvasFileManifest';
7
+ import type { ExtendedCanvas, OtelEventNode, OtelSpanConventionNode } from '../types/canvas';
8
+ import type { WorkflowTemplate } from '../workflow/types';
9
+
10
+ describe('CanvasFileManifest', () => {
11
+ describe('buildCanvasFileManifest', () => {
12
+ test('extracts instrumentation files from otel-event nodes', () => {
13
+ const canvas: ExtendedCanvas = {
14
+ nodes: [
15
+ {
16
+ id: 'node-1',
17
+ type: 'otel-event',
18
+ x: 0,
19
+ y: 0,
20
+ width: 100,
21
+ height: 50,
22
+ name: 'Test Event',
23
+ event: { name: 'test.started', attributes: {} },
24
+ otel: {
25
+ files: ['src/api/handler.ts', 'src/api/utils.ts'],
26
+ },
27
+ } as OtelEventNode,
28
+ ],
29
+ };
30
+
31
+ const manifest = buildCanvasFileManifest(canvas, 'test', 'test.otel.canvas', 'otel');
32
+
33
+ expect(manifest.stats.instrumentationFiles).toBe(2);
34
+ expect(manifest.byRole.instrumentation).toHaveLength(2);
35
+ expect(manifest.fileToNodes.get('src/api/handler.ts')).toEqual(['node-1']);
36
+ expect(manifest.nodeToFiles.get('node-1')).toEqual(['src/api/handler.ts', 'src/api/utils.ts']);
37
+ });
38
+
39
+ test('extracts references from otel nodes', () => {
40
+ const canvas: ExtendedCanvas = {
41
+ nodes: [
42
+ {
43
+ id: 'node-1',
44
+ type: 'otel-event',
45
+ x: 0,
46
+ y: 0,
47
+ width: 100,
48
+ height: 50,
49
+ name: 'External Event',
50
+ event: { name: 'external.call', attributes: {} },
51
+ otel: {
52
+ origin: 'external',
53
+ references: ['https://docs.example.com', '@some/package'],
54
+ },
55
+ } as OtelEventNode,
56
+ ],
57
+ };
58
+
59
+ const manifest = buildCanvasFileManifest(canvas, 'test', 'test.otel.canvas', 'otel');
60
+
61
+ expect(manifest.stats.referenceFiles).toBe(2);
62
+ expect(manifest.byRole.reference).toHaveLength(2);
63
+ expect(manifest.byOrigin.external).toHaveLength(2);
64
+ });
65
+
66
+ test('tracks event names for otel-event nodes', () => {
67
+ const canvas: ExtendedCanvas = {
68
+ nodes: [
69
+ {
70
+ id: 'node-1',
71
+ type: 'otel-event',
72
+ x: 0,
73
+ y: 0,
74
+ width: 100,
75
+ height: 50,
76
+ name: 'Event 1',
77
+ event: { name: 'user.login', attributes: {} },
78
+ otel: {
79
+ files: ['src/auth/login.ts'],
80
+ },
81
+ } as OtelEventNode,
82
+ {
83
+ id: 'node-2',
84
+ type: 'otel-event',
85
+ x: 0,
86
+ y: 100,
87
+ width: 100,
88
+ height: 50,
89
+ name: 'Event 2',
90
+ event: { name: 'user.logout', attributes: {} },
91
+ otel: {
92
+ files: ['src/auth/logout.ts'],
93
+ },
94
+ } as OtelEventNode,
95
+ ],
96
+ };
97
+
98
+ const manifest = buildCanvasFileManifest(canvas, 'test', 'test.otel.canvas', 'otel');
99
+
100
+ expect(manifest.stats.uniqueEventNames).toBe(2);
101
+ expect(manifest.eventToFiles.get('user.login')).toEqual(['src/auth/login.ts']);
102
+ expect(manifest.eventToFiles.get('user.logout')).toEqual(['src/auth/logout.ts']);
103
+ });
104
+
105
+ test('merges duplicate file references across nodes', () => {
106
+ const canvas: ExtendedCanvas = {
107
+ nodes: [
108
+ {
109
+ id: 'node-1',
110
+ type: 'otel-event',
111
+ x: 0,
112
+ y: 0,
113
+ width: 100,
114
+ height: 50,
115
+ name: 'Event 1',
116
+ event: { name: 'event.one', attributes: {} },
117
+ otel: {
118
+ files: ['src/shared.ts'],
119
+ },
120
+ } as OtelEventNode,
121
+ {
122
+ id: 'node-2',
123
+ type: 'otel-event',
124
+ x: 0,
125
+ y: 100,
126
+ width: 100,
127
+ height: 50,
128
+ name: 'Event 2',
129
+ event: { name: 'event.two', attributes: {} },
130
+ otel: {
131
+ files: ['src/shared.ts'],
132
+ },
133
+ } as OtelEventNode,
134
+ ],
135
+ };
136
+
137
+ const manifest = buildCanvasFileManifest(canvas, 'test', 'test.otel.canvas', 'otel');
138
+
139
+ expect(manifest.stats.totalFiles).toBe(1);
140
+ expect(manifest.files[0].nodeIds).toEqual(['node-1', 'node-2']);
141
+ expect(manifest.files[0].eventNames).toEqual(['event.one', 'event.two']);
142
+ expect(manifest.fileToNodes.get('src/shared.ts')).toEqual(['node-1', 'node-2']);
143
+ });
144
+
145
+ test('handles otel-span-convention nodes', () => {
146
+ const canvas: ExtendedCanvas = {
147
+ nodes: [
148
+ {
149
+ id: 'span-node',
150
+ type: 'otel-span-convention',
151
+ x: 0,
152
+ y: 0,
153
+ width: 100,
154
+ height: 50,
155
+ name: 'HTTP Request',
156
+ otel: {
157
+ spanPattern: 'http.request',
158
+ files: ['src/middleware/tracing.ts'],
159
+ },
160
+ } as OtelSpanConventionNode,
161
+ ],
162
+ };
163
+
164
+ const manifest = buildCanvasFileManifest(canvas, 'test', 'test.spans.canvas', 'spans');
165
+
166
+ expect(manifest.stats.instrumentationFiles).toBe(1);
167
+ expect(manifest.canvasType).toBe('spans');
168
+ // Span convention nodes don't have event names
169
+ expect(manifest.files[0].eventNames).toEqual([]);
170
+ });
171
+
172
+ test('ignores non-OTEL nodes', () => {
173
+ const canvas: ExtendedCanvas = {
174
+ nodes: [
175
+ {
176
+ id: 'text-node',
177
+ type: 'text',
178
+ x: 0,
179
+ y: 0,
180
+ width: 100,
181
+ height: 50,
182
+ text: 'Some documentation',
183
+ },
184
+ {
185
+ id: 'otel-node',
186
+ type: 'otel-event',
187
+ x: 0,
188
+ y: 100,
189
+ width: 100,
190
+ height: 50,
191
+ name: 'Real Event',
192
+ event: { name: 'real.event', attributes: {} },
193
+ otel: {
194
+ files: ['src/real.ts'],
195
+ },
196
+ } as OtelEventNode,
197
+ ],
198
+ };
199
+
200
+ const manifest = buildCanvasFileManifest(canvas, 'test', 'test.otel.canvas', 'otel');
201
+
202
+ expect(manifest.stats.totalFiles).toBe(1);
203
+ expect(manifest.stats.nodesWithFiles).toBe(1);
204
+ expect(manifest.stats.nodesWithoutFiles).toBe(1);
205
+ });
206
+
207
+ test('handles empty canvas', () => {
208
+ const canvas: ExtendedCanvas = {
209
+ nodes: [],
210
+ };
211
+
212
+ const manifest = buildCanvasFileManifest(canvas, 'test', 'test.otel.canvas', 'otel');
213
+
214
+ expect(manifest.stats.totalFiles).toBe(0);
215
+ expect(manifest.files).toEqual([]);
216
+ expect(manifest.byRole.instrumentation).toEqual([]);
217
+ });
218
+
219
+ test('handles nodes without otel metadata', () => {
220
+ const canvas: ExtendedCanvas = {
221
+ nodes: [
222
+ {
223
+ id: 'node-no-otel',
224
+ type: 'otel-event',
225
+ x: 0,
226
+ y: 0,
227
+ width: 100,
228
+ height: 50,
229
+ name: 'Draft Event',
230
+ event: { name: 'draft.event', attributes: {} },
231
+ // No otel property
232
+ } as unknown as OtelEventNode,
233
+ ],
234
+ };
235
+
236
+ const manifest = buildCanvasFileManifest(canvas, 'test', 'test.otel.canvas', 'otel');
237
+
238
+ expect(manifest.stats.totalFiles).toBe(0);
239
+ expect(manifest.stats.nodesWithoutFiles).toBe(1);
240
+ });
241
+ });
242
+
243
+ describe('buildWorkflowFileManifest', () => {
244
+ test('includes workflow-level files', () => {
245
+ const canvas: ExtendedCanvas = {
246
+ nodes: [
247
+ {
248
+ id: 'node-1',
249
+ type: 'otel-event',
250
+ x: 0,
251
+ y: 0,
252
+ width: 100,
253
+ height: 50,
254
+ name: 'Event',
255
+ event: { name: 'test.event', attributes: {} },
256
+ otel: {
257
+ files: ['src/event.ts'],
258
+ },
259
+ } as OtelEventNode,
260
+ ],
261
+ };
262
+
263
+ const canvasManifest = buildCanvasFileManifest(canvas, 'test', 'test.otel.canvas', 'otel');
264
+
265
+ const workflow: WorkflowTemplate = {
266
+ version: '1.0.0',
267
+ canvas: 'test.otel.canvas',
268
+ name: 'Test Workflow',
269
+ description: 'Test',
270
+ rootSpan: 'test.span',
271
+ files: ['src/api/route.ts'],
272
+ scenarioSelection: 'first-match',
273
+ scenarios: [
274
+ {
275
+ id: 'success',
276
+ priority: 1,
277
+ description: 'Success scenario',
278
+ template: {
279
+ events: {
280
+ 'test.event': 'Event happened',
281
+ },
282
+ },
283
+ },
284
+ ],
285
+ };
286
+
287
+ const workflowManifest = buildWorkflowFileManifest(
288
+ canvasManifest,
289
+ workflow,
290
+ 'test-workflow',
291
+ 'test.workflow.json'
292
+ );
293
+
294
+ expect(workflowManifest.workflowFiles).toHaveLength(1);
295
+ expect(workflowManifest.workflowFiles[0].role).toBe('root-span');
296
+ expect(workflowManifest.allFiles).toHaveLength(2);
297
+ expect(workflowManifest.rootSpan).toBe('test.span');
298
+ });
299
+
300
+ test('filters files by scenario', () => {
301
+ const canvas: ExtendedCanvas = {
302
+ nodes: [
303
+ {
304
+ id: 'node-1',
305
+ type: 'otel-event',
306
+ x: 0,
307
+ y: 0,
308
+ width: 100,
309
+ height: 50,
310
+ name: 'Login Event',
311
+ event: { name: 'user.login', attributes: {} },
312
+ otel: {
313
+ files: ['src/auth/login.ts'],
314
+ },
315
+ } as OtelEventNode,
316
+ {
317
+ id: 'node-2',
318
+ type: 'otel-event',
319
+ x: 0,
320
+ y: 100,
321
+ width: 100,
322
+ height: 50,
323
+ name: 'Logout Event',
324
+ event: { name: 'user.logout', attributes: {} },
325
+ otel: {
326
+ files: ['src/auth/logout.ts'],
327
+ },
328
+ } as OtelEventNode,
329
+ ],
330
+ };
331
+
332
+ const canvasManifest = buildCanvasFileManifest(canvas, 'test', 'test.otel.canvas', 'otel');
333
+
334
+ const workflow: WorkflowTemplate = {
335
+ version: '1.0.0',
336
+ canvas: 'test.otel.canvas',
337
+ name: 'Auth Workflow',
338
+ description: 'Auth',
339
+ rootSpan: 'auth.flow',
340
+ files: ['src/api/auth.ts'],
341
+ scenarioSelection: 'first-match',
342
+ scenarios: [
343
+ {
344
+ id: 'login-only',
345
+ priority: 1,
346
+ description: 'Login only',
347
+ template: {
348
+ events: {
349
+ 'user.login': 'User logged in',
350
+ },
351
+ },
352
+ },
353
+ {
354
+ id: 'full-flow',
355
+ priority: 2,
356
+ description: 'Full flow',
357
+ template: {
358
+ events: {
359
+ 'user.login': 'User logged in',
360
+ 'user.logout': 'User logged out',
361
+ },
362
+ },
363
+ },
364
+ ],
365
+ };
366
+
367
+ const workflowManifest = buildWorkflowFileManifest(
368
+ canvasManifest,
369
+ workflow,
370
+ 'auth-workflow',
371
+ 'auth.workflow.json'
372
+ );
373
+
374
+ const loginOnlyFiles = workflowManifest.byScenario.get('login-only');
375
+ const fullFlowFiles = workflowManifest.byScenario.get('full-flow');
376
+
377
+ // login-only: workflow file + login file
378
+ expect(loginOnlyFiles).toHaveLength(2);
379
+ expect(loginOnlyFiles?.map((f) => f.path)).toContain('src/api/auth.ts');
380
+ expect(loginOnlyFiles?.map((f) => f.path)).toContain('src/auth/login.ts');
381
+
382
+ // full-flow: workflow file + both event files
383
+ expect(fullFlowFiles).toHaveLength(3);
384
+ expect(fullFlowFiles?.map((f) => f.path)).toContain('src/auth/login.ts');
385
+ expect(fullFlowFiles?.map((f) => f.path)).toContain('src/auth/logout.ts');
386
+ });
387
+
388
+ test('merges overlapping files between canvas and workflow', () => {
389
+ const canvas: ExtendedCanvas = {
390
+ nodes: [
391
+ {
392
+ id: 'node-1',
393
+ type: 'otel-event',
394
+ x: 0,
395
+ y: 0,
396
+ width: 100,
397
+ height: 50,
398
+ name: 'Event',
399
+ event: { name: 'test.event', attributes: {} },
400
+ otel: {
401
+ files: ['src/shared.ts'],
402
+ },
403
+ } as OtelEventNode,
404
+ ],
405
+ };
406
+
407
+ const canvasManifest = buildCanvasFileManifest(canvas, 'test', 'test.otel.canvas', 'otel');
408
+
409
+ const workflow: WorkflowTemplate = {
410
+ version: '1.0.0',
411
+ canvas: 'test.otel.canvas',
412
+ name: 'Test Workflow',
413
+ description: 'Test',
414
+ rootSpan: 'test.span',
415
+ files: ['src/shared.ts'], // Same file as canvas node
416
+ scenarioSelection: 'first-match',
417
+ scenarios: [],
418
+ };
419
+
420
+ const workflowManifest = buildWorkflowFileManifest(
421
+ canvasManifest,
422
+ workflow,
423
+ 'test-workflow',
424
+ 'test.workflow.json'
425
+ );
426
+
427
+ // Should be merged, not duplicated
428
+ expect(workflowManifest.allFiles).toHaveLength(1);
429
+ // Role should be upgraded to root-span
430
+ expect(workflowManifest.allFiles[0].role).toBe('root-span');
431
+ expect(workflowManifest.allFiles[0].workflowIds).toContain('test-workflow');
432
+ });
433
+ });
434
+
435
+ describe('buildStoryboardFileManifest', () => {
436
+ test('aggregates files across canvas and workflows', () => {
437
+ const canvas: ExtendedCanvas = {
438
+ nodes: [
439
+ {
440
+ id: 'node-1',
441
+ type: 'otel-event',
442
+ x: 0,
443
+ y: 0,
444
+ width: 100,
445
+ height: 50,
446
+ name: 'Event',
447
+ event: { name: 'test.event', attributes: {} },
448
+ otel: {
449
+ files: ['src/canvas-file.ts'],
450
+ },
451
+ } as OtelEventNode,
452
+ ],
453
+ };
454
+
455
+ const canvasManifest = buildCanvasFileManifest(canvas, 'test', 'test.otel.canvas', 'otel');
456
+
457
+ const workflow1: WorkflowTemplate = {
458
+ version: '1.0.0',
459
+ canvas: 'test.otel.canvas',
460
+ name: 'Workflow 1',
461
+ description: 'Test',
462
+ rootSpan: 'workflow1.span',
463
+ files: ['src/workflow1.ts'],
464
+ scenarioSelection: 'first-match',
465
+ scenarios: [{ id: 's1', priority: 1, description: 'S1', template: {} }],
466
+ };
467
+
468
+ const workflow2: WorkflowTemplate = {
469
+ version: '1.0.0',
470
+ canvas: 'test.otel.canvas',
471
+ name: 'Workflow 2',
472
+ description: 'Test',
473
+ rootSpan: 'workflow2.span',
474
+ files: ['src/workflow2.ts'],
475
+ scenarioSelection: 'first-match',
476
+ scenarios: [{ id: 's2', priority: 1, description: 'S2', template: {} }],
477
+ };
478
+
479
+ const workflowManifest1 = buildWorkflowFileManifest(
480
+ canvasManifest,
481
+ workflow1,
482
+ 'workflow-1',
483
+ 'workflow1.workflow.json'
484
+ );
485
+
486
+ const workflowManifest2 = buildWorkflowFileManifest(
487
+ canvasManifest,
488
+ workflow2,
489
+ 'workflow-2',
490
+ 'workflow2.workflow.json'
491
+ );
492
+
493
+ const storyboardManifest = buildStoryboardFileManifest(
494
+ canvasManifest,
495
+ [workflowManifest1, workflowManifest2],
496
+ 'test-storyboard',
497
+ '.principal-views/test-storyboard'
498
+ );
499
+
500
+ expect(storyboardManifest.allFiles).toHaveLength(3);
501
+ expect(storyboardManifest.stats.workflowCount).toBe(2);
502
+ expect(storyboardManifest.stats.scenarioCount).toBe(2);
503
+ expect(storyboardManifest.stats.rootSpanFiles).toBe(2);
504
+ });
505
+
506
+ test('includes canvas manifest reference', () => {
507
+ const canvas: ExtendedCanvas = { nodes: [] };
508
+ const canvasManifest = buildCanvasFileManifest(canvas, 'test', 'test.otel.canvas', 'otel');
509
+
510
+ const storyboardManifest = buildStoryboardFileManifest(
511
+ canvasManifest,
512
+ [],
513
+ 'test-storyboard',
514
+ '.principal-views/test-storyboard'
515
+ );
516
+
517
+ expect(storyboardManifest.canvas).toBe(canvasManifest);
518
+ expect(storyboardManifest.workflows).toEqual([]);
519
+ });
520
+ });
521
+ });