@telorun/debug-ui 0.2.1 → 0.4.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.
- package/README.md +21 -6
- package/app-dist/assets/index-BMTuPr57.js +126 -0
- package/app-dist/assets/index-CWG3ZSzH.css +1 -0
- package/app-dist/index.html +2 -2
- package/app-single/index.html +48 -26
- package/package.json +4 -2
- package/src/components/DebugPanel.tsx +12 -3
- package/src/components/EventGraph.tsx +462 -0
- package/src/components/index.ts +1 -0
- package/src/components/styles.css +270 -0
- package/src/filter.ts +6 -2
- package/src/graph.test.ts +222 -0
- package/src/graph.ts +360 -0
- package/src/index.ts +16 -0
- package/app-dist/assets/index-CK8ArLzP.css +0 -1
- package/app-dist/assets/index-CwTwcQ4d.js +0 -104
|
@@ -327,3 +327,273 @@
|
|
|
327
327
|
.tdbg-log-stderr .tdbg-logtext {
|
|
328
328
|
color: var(--tdbg-bad);
|
|
329
329
|
}
|
|
330
|
+
|
|
331
|
+
/* ── Graph view ─────────────────────────────────────────────────────────── */
|
|
332
|
+
.tdbg-graph {
|
|
333
|
+
display: flex;
|
|
334
|
+
flex: 1;
|
|
335
|
+
min-height: 0;
|
|
336
|
+
}
|
|
337
|
+
.tdbg-graph-canvas {
|
|
338
|
+
flex: 1;
|
|
339
|
+
min-width: 0;
|
|
340
|
+
min-height: 0;
|
|
341
|
+
}
|
|
342
|
+
/* Theme xyflow chrome to the panel palette (v12 CSS variables). */
|
|
343
|
+
.tdbg-graph .react-flow {
|
|
344
|
+
--xy-edge-stroke-default: var(--tdbg-muted);
|
|
345
|
+
--xy-background-pattern-dots-color-default: var(--tdbg-line);
|
|
346
|
+
--xy-controls-button-background-color-default: var(--tdbg-panel);
|
|
347
|
+
--xy-controls-button-background-color-hover-default: var(--tdbg-bg);
|
|
348
|
+
--xy-controls-button-color-default: var(--tdbg-text);
|
|
349
|
+
--xy-controls-button-color-hover-default: var(--tdbg-accent);
|
|
350
|
+
--xy-controls-button-border-color-default: var(--tdbg-line);
|
|
351
|
+
background: var(--tdbg-bg);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.tdbg-node {
|
|
355
|
+
box-sizing: border-box;
|
|
356
|
+
padding: 6px 9px;
|
|
357
|
+
border: 1px solid var(--tdbg-line);
|
|
358
|
+
border-radius: 7px;
|
|
359
|
+
background: var(--tdbg-panel);
|
|
360
|
+
color: var(--tdbg-text);
|
|
361
|
+
font: inherit;
|
|
362
|
+
cursor: pointer;
|
|
363
|
+
transition:
|
|
364
|
+
border-color 0.15s,
|
|
365
|
+
box-shadow 0.15s,
|
|
366
|
+
opacity 0.2s;
|
|
367
|
+
}
|
|
368
|
+
.tdbg-node-created {
|
|
369
|
+
opacity: 0.7;
|
|
370
|
+
}
|
|
371
|
+
.tdbg-node-initialized {
|
|
372
|
+
border-color: var(--tdbg-good);
|
|
373
|
+
}
|
|
374
|
+
.tdbg-node-torndown {
|
|
375
|
+
opacity: 0.4;
|
|
376
|
+
border-style: dashed;
|
|
377
|
+
}
|
|
378
|
+
.tdbg-node-sel {
|
|
379
|
+
border-color: var(--tdbg-accent);
|
|
380
|
+
box-shadow: 0 0 0 1px var(--tdbg-accent);
|
|
381
|
+
}
|
|
382
|
+
.tdbg-node-handle {
|
|
383
|
+
width: 7px;
|
|
384
|
+
height: 7px;
|
|
385
|
+
border: none;
|
|
386
|
+
background: var(--tdbg-muted);
|
|
387
|
+
}
|
|
388
|
+
.tdbg-node-head {
|
|
389
|
+
display: flex;
|
|
390
|
+
align-items: center;
|
|
391
|
+
gap: 6px;
|
|
392
|
+
}
|
|
393
|
+
.tdbg-node-dot {
|
|
394
|
+
width: 8px;
|
|
395
|
+
height: 8px;
|
|
396
|
+
border-radius: 50%;
|
|
397
|
+
flex: 0 0 auto;
|
|
398
|
+
background: var(--tdbg-muted);
|
|
399
|
+
}
|
|
400
|
+
.tdbg-node-dot-initialized {
|
|
401
|
+
background: var(--tdbg-good);
|
|
402
|
+
}
|
|
403
|
+
.tdbg-node-dot-torndown {
|
|
404
|
+
background: var(--tdbg-bad);
|
|
405
|
+
}
|
|
406
|
+
.tdbg-node-name {
|
|
407
|
+
font-weight: 600;
|
|
408
|
+
white-space: nowrap;
|
|
409
|
+
overflow: hidden;
|
|
410
|
+
text-overflow: ellipsis;
|
|
411
|
+
}
|
|
412
|
+
.tdbg-node-count {
|
|
413
|
+
margin-left: auto;
|
|
414
|
+
padding: 0 5px;
|
|
415
|
+
border-radius: 999px;
|
|
416
|
+
background: var(--tdbg-good-bg);
|
|
417
|
+
color: var(--tdbg-good);
|
|
418
|
+
font-size: 11px;
|
|
419
|
+
}
|
|
420
|
+
.tdbg-node-count-bad {
|
|
421
|
+
background: var(--tdbg-bad-bg);
|
|
422
|
+
color: var(--tdbg-bad);
|
|
423
|
+
}
|
|
424
|
+
.tdbg-node-kind {
|
|
425
|
+
margin-top: 1px;
|
|
426
|
+
color: var(--tdbg-muted);
|
|
427
|
+
font-size: 11px;
|
|
428
|
+
white-space: nowrap;
|
|
429
|
+
overflow: hidden;
|
|
430
|
+
text-overflow: ellipsis;
|
|
431
|
+
}
|
|
432
|
+
.tdbg-node-pulse-ok {
|
|
433
|
+
animation: tdbg-pulse-ok 0.65s ease-out;
|
|
434
|
+
}
|
|
435
|
+
.tdbg-node-pulse-bad {
|
|
436
|
+
animation: tdbg-pulse-bad 0.65s ease-out;
|
|
437
|
+
}
|
|
438
|
+
@keyframes tdbg-pulse-ok {
|
|
439
|
+
0% {
|
|
440
|
+
box-shadow: 0 0 0 0 var(--tdbg-good);
|
|
441
|
+
}
|
|
442
|
+
100% {
|
|
443
|
+
box-shadow: 0 0 0 10px transparent;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
@keyframes tdbg-pulse-bad {
|
|
447
|
+
0% {
|
|
448
|
+
box-shadow: 0 0 0 0 var(--tdbg-bad);
|
|
449
|
+
}
|
|
450
|
+
100% {
|
|
451
|
+
box-shadow: 0 0 0 10px transparent;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.tdbg-suffix-created {
|
|
456
|
+
background: var(--tdbg-line);
|
|
457
|
+
color: var(--tdbg-muted);
|
|
458
|
+
}
|
|
459
|
+
.tdbg-suffix-torndown {
|
|
460
|
+
background: var(--tdbg-bad-bg);
|
|
461
|
+
color: var(--tdbg-bad);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
.tdbg-graph-detail {
|
|
465
|
+
display: flex;
|
|
466
|
+
flex-direction: column;
|
|
467
|
+
width: 340px;
|
|
468
|
+
flex: 0 0 auto;
|
|
469
|
+
min-height: 0;
|
|
470
|
+
overflow: auto;
|
|
471
|
+
border-left: 1px solid var(--tdbg-line);
|
|
472
|
+
background: var(--tdbg-panel);
|
|
473
|
+
}
|
|
474
|
+
.tdbg-graph-detail-head {
|
|
475
|
+
display: flex;
|
|
476
|
+
align-items: center;
|
|
477
|
+
gap: 8px;
|
|
478
|
+
padding: 8px 10px;
|
|
479
|
+
border-bottom: 1px solid var(--tdbg-line);
|
|
480
|
+
}
|
|
481
|
+
.tdbg-graph-detail-head .tdbg-node-name {
|
|
482
|
+
flex: 1;
|
|
483
|
+
}
|
|
484
|
+
.tdbg-graph-detail-meta {
|
|
485
|
+
display: flex;
|
|
486
|
+
align-items: center;
|
|
487
|
+
flex-wrap: wrap;
|
|
488
|
+
gap: 8px;
|
|
489
|
+
padding: 8px 10px;
|
|
490
|
+
}
|
|
491
|
+
.tdbg-graph-detail-body {
|
|
492
|
+
display: flex;
|
|
493
|
+
flex-direction: column;
|
|
494
|
+
min-height: 0;
|
|
495
|
+
}
|
|
496
|
+
.tdbg-graph-detail-sub {
|
|
497
|
+
display: flex;
|
|
498
|
+
align-items: center;
|
|
499
|
+
gap: 8px;
|
|
500
|
+
padding: 4px 10px 8px;
|
|
501
|
+
color: var(--tdbg-muted);
|
|
502
|
+
}
|
|
503
|
+
.tdbg-graph-detail-label {
|
|
504
|
+
padding: 8px 10px 2px;
|
|
505
|
+
color: var(--tdbg-muted);
|
|
506
|
+
font-size: 11px;
|
|
507
|
+
text-transform: uppercase;
|
|
508
|
+
letter-spacing: 0.04em;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/* Invocation list (left rail of the Graph view). */
|
|
512
|
+
.tdbg-trace-list {
|
|
513
|
+
display: flex;
|
|
514
|
+
flex-direction: column;
|
|
515
|
+
width: 220px;
|
|
516
|
+
flex: 0 0 auto;
|
|
517
|
+
min-height: 0;
|
|
518
|
+
overflow: auto;
|
|
519
|
+
border-right: 1px solid var(--tdbg-line);
|
|
520
|
+
background: var(--tdbg-panel);
|
|
521
|
+
}
|
|
522
|
+
.tdbg-trace-list-head {
|
|
523
|
+
position: sticky;
|
|
524
|
+
top: 0;
|
|
525
|
+
padding: 8px 10px;
|
|
526
|
+
background: var(--tdbg-panel);
|
|
527
|
+
border-bottom: 1px solid var(--tdbg-line);
|
|
528
|
+
color: var(--tdbg-muted);
|
|
529
|
+
font-size: 11px;
|
|
530
|
+
text-transform: uppercase;
|
|
531
|
+
letter-spacing: 0.04em;
|
|
532
|
+
}
|
|
533
|
+
.tdbg-trace-row {
|
|
534
|
+
display: flex;
|
|
535
|
+
align-items: center;
|
|
536
|
+
gap: 8px;
|
|
537
|
+
width: 100%;
|
|
538
|
+
padding: 5px 10px;
|
|
539
|
+
background: none;
|
|
540
|
+
border: none;
|
|
541
|
+
border-bottom: 1px solid var(--tdbg-line);
|
|
542
|
+
color: inherit;
|
|
543
|
+
cursor: pointer;
|
|
544
|
+
text-align: left;
|
|
545
|
+
font: inherit;
|
|
546
|
+
}
|
|
547
|
+
.tdbg-trace-row:hover {
|
|
548
|
+
background: var(--tdbg-bg);
|
|
549
|
+
}
|
|
550
|
+
.tdbg-trace-row-on {
|
|
551
|
+
background: var(--tdbg-bg);
|
|
552
|
+
box-shadow: inset 2px 0 0 var(--tdbg-accent);
|
|
553
|
+
}
|
|
554
|
+
.tdbg-trace-row .tdbg-node-name {
|
|
555
|
+
flex: 1;
|
|
556
|
+
}
|
|
557
|
+
.tdbg-trace-calls {
|
|
558
|
+
padding: 0 6px;
|
|
559
|
+
border-radius: 999px;
|
|
560
|
+
background: var(--tdbg-line);
|
|
561
|
+
color: var(--tdbg-muted);
|
|
562
|
+
font-size: 11px;
|
|
563
|
+
}
|
|
564
|
+
.tdbg-trace-empty {
|
|
565
|
+
padding: 12px 10px;
|
|
566
|
+
color: var(--tdbg-muted);
|
|
567
|
+
font-size: 12px;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
.tdbg-node-root {
|
|
571
|
+
border-color: var(--tdbg-accent);
|
|
572
|
+
}
|
|
573
|
+
.tdbg-node-bad {
|
|
574
|
+
border-color: var(--tdbg-bad);
|
|
575
|
+
}
|
|
576
|
+
.tdbg-node-roottag {
|
|
577
|
+
padding: 0 5px;
|
|
578
|
+
border-radius: 999px;
|
|
579
|
+
background: rgba(79, 156, 240, 0.18);
|
|
580
|
+
color: var(--tdbg-accent);
|
|
581
|
+
font-size: 10px;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
.tdbg-graph-toggle {
|
|
585
|
+
display: inline-flex;
|
|
586
|
+
align-items: center;
|
|
587
|
+
gap: 6px;
|
|
588
|
+
padding: 4px 8px;
|
|
589
|
+
color: var(--tdbg-text);
|
|
590
|
+
background: var(--tdbg-panel);
|
|
591
|
+
border: 1px solid var(--tdbg-line);
|
|
592
|
+
border-radius: 6px;
|
|
593
|
+
font-size: 12px;
|
|
594
|
+
cursor: pointer;
|
|
595
|
+
user-select: none;
|
|
596
|
+
}
|
|
597
|
+
.tdbg-graph-toggle input {
|
|
598
|
+
cursor: pointer;
|
|
599
|
+
}
|
package/src/filter.ts
CHANGED
|
@@ -23,8 +23,12 @@ export function matchesFilter(event: DebugEvent, filter: EventFilter): boolean {
|
|
|
23
23
|
if (suffixes.length > 0 && !suffixes.includes(eventSuffix(event.event))) {
|
|
24
24
|
return false;
|
|
25
25
|
}
|
|
26
|
-
if (filter.kind
|
|
27
|
-
|
|
26
|
+
if (filter.kind) {
|
|
27
|
+
// Lifecycle events carry the kind in the dotted name; dispatch (trace) events
|
|
28
|
+
// carry it in `payload.ref.kind` (the name dropped the kind prefix). Match either.
|
|
29
|
+
const refKind = (event.payload as { ref?: { kind?: unknown } } | undefined)?.ref?.kind;
|
|
30
|
+
const haystack = `${event.event} ${typeof refKind === "string" ? refKind : ""}`.toLowerCase();
|
|
31
|
+
if (!haystack.includes(filter.kind.toLowerCase())) return false;
|
|
28
32
|
}
|
|
29
33
|
if (filter.text) {
|
|
30
34
|
const needle = filter.text.toLowerCase();
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { deriveGraph, deriveInvocations, traceSubgraph } from "./graph.js";
|
|
3
|
+
import type { DebugFrame } from "./wire.js";
|
|
4
|
+
|
|
5
|
+
let seq = 0;
|
|
6
|
+
const ev = (event: string, payload?: unknown): DebugFrame => ({
|
|
7
|
+
timestamp: `2026-01-01T00:00:00.${String(seq++).padStart(3, "0")}Z`,
|
|
8
|
+
event,
|
|
9
|
+
payload,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const created = (kind: string, name: string, deps: { kind: string; name: string }[] = []) =>
|
|
13
|
+
ev(`${kind}.${name}.Created`, { resource: { kind, name }, dependencies: deps });
|
|
14
|
+
|
|
15
|
+
type Outcome = "ok" | "failed" | "rejected" | "cancelled";
|
|
16
|
+
|
|
17
|
+
/** A terminal dispatch (trace) event: the name drops the kind, the payload carries
|
|
18
|
+
* the structured trace contract `{ capability, phase, outcome, ref, … }`. */
|
|
19
|
+
const disp = (
|
|
20
|
+
name: string,
|
|
21
|
+
kind: string,
|
|
22
|
+
outcome: Outcome,
|
|
23
|
+
suffix: string,
|
|
24
|
+
detail: Record<string, unknown> = {},
|
|
25
|
+
): DebugFrame =>
|
|
26
|
+
ev(`${name}.${suffix}`, { capability: "invoke", phase: "end", outcome, ref: { kind, name }, ...detail });
|
|
27
|
+
|
|
28
|
+
describe("deriveGraph", () => {
|
|
29
|
+
it("creates a gray (created) node on Created", () => {
|
|
30
|
+
const { nodes } = deriveGraph([created("Http.Server", "api")]);
|
|
31
|
+
expect(nodes).toHaveLength(1);
|
|
32
|
+
expect(nodes[0]).toMatchObject({ id: "api", kind: "Http.Server", name: "api", status: "created", invokeCount: 0 });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("brightens the node on Initialized", () => {
|
|
36
|
+
const { nodes } = deriveGraph([
|
|
37
|
+
created("Http.Server", "api"),
|
|
38
|
+
ev("Http.Server.api.Initialized", { resource: { kind: "Http.Server", name: "api" } }),
|
|
39
|
+
]);
|
|
40
|
+
expect(nodes[0].status).toBe("initialized");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("marks the node torndown on Teardown", () => {
|
|
44
|
+
const { nodes } = deriveGraph([
|
|
45
|
+
created("Http.Server", "api"),
|
|
46
|
+
ev("Http.Server.api.Teardown", { resource: { kind: "Http.Server", name: "api" } }),
|
|
47
|
+
]);
|
|
48
|
+
expect(nodes[0].status).toBe("torndown");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("wires edges from Created dependencies", () => {
|
|
52
|
+
const { edges } = deriveGraph([
|
|
53
|
+
created("Sql.Pool", "db"),
|
|
54
|
+
created("Http.Server", "api", [{ kind: "Sql.Pool", name: "db" }]),
|
|
55
|
+
]);
|
|
56
|
+
expect(edges).toEqual([{ id: "api->db", source: "api", target: "db" }]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("dedupes repeated dependency edges", () => {
|
|
60
|
+
const dep = [{ kind: "Sql.Pool", name: "db" }];
|
|
61
|
+
const { edges } = deriveGraph([created("Http.Server", "api", dep), created("Http.Server", "api", dep)]);
|
|
62
|
+
expect(edges).toHaveLength(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("records an ok invocation with inputs and outputs", () => {
|
|
66
|
+
const { nodes } = deriveGraph([
|
|
67
|
+
created("Js.Script", "echo"),
|
|
68
|
+
disp("echo", "Js.Script", "ok", "Invoked", { inputs: { msg: "hi" }, outputs: { echoed: "hi" } }),
|
|
69
|
+
]);
|
|
70
|
+
expect(nodes[0].invokeCount).toBe(1);
|
|
71
|
+
expect(nodes[0].lastInvoke).toMatchObject({
|
|
72
|
+
outcome: "ok",
|
|
73
|
+
suffix: "Invoked",
|
|
74
|
+
inputs: { msg: "hi" },
|
|
75
|
+
outputs: { echoed: "hi" },
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("skips a phase:start event (no outcome) — only terminal events count", () => {
|
|
80
|
+
const { nodes } = deriveGraph([
|
|
81
|
+
created("Js.Script", "echo"),
|
|
82
|
+
ev("echo.Invoking", { capability: "invoke", phase: "start", ref: { kind: "Js.Script", name: "echo" }, inputs: {} }),
|
|
83
|
+
]);
|
|
84
|
+
expect(nodes[0].invokeCount).toBe(0);
|
|
85
|
+
expect(nodes[0].lastInvoke).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("records failure, rejection and cancellation outcomes from the payload", () => {
|
|
89
|
+
const base = created("Js.Script", "x");
|
|
90
|
+
expect(
|
|
91
|
+
deriveGraph([base, disp("x", "Js.Script", "failed", "InvokeFailed", { inputs: {}, name: "Error", message: "boom" })])
|
|
92
|
+
.nodes[0].lastInvoke?.outcome,
|
|
93
|
+
).toBe("failed");
|
|
94
|
+
expect(
|
|
95
|
+
deriveGraph([base, disp("x", "Js.Script", "rejected", "InvokeRejected", { inputs: {}, code: "E", message: "no" })])
|
|
96
|
+
.nodes[0].lastInvoke?.outcome,
|
|
97
|
+
).toBe("rejected");
|
|
98
|
+
expect(
|
|
99
|
+
deriveGraph([base, disp("x", "Js.Script", "cancelled", "InvokeCancelled", { inputs: {}, reason: "stop" })])
|
|
100
|
+
.nodes[0].lastInvoke?.outcome,
|
|
101
|
+
).toBe("cancelled");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("treats InvokeRejected.Undeclared as a rejection on the right node", () => {
|
|
105
|
+
const { nodes } = deriveGraph([
|
|
106
|
+
created("Js.Script", "x"),
|
|
107
|
+
disp("x", "Js.Script", "rejected", "InvokeRejected.Undeclared", { inputs: {}, code: "E", message: "no" }),
|
|
108
|
+
]);
|
|
109
|
+
expect(nodes[0].lastInvoke).toMatchObject({ outcome: "rejected", suffix: "Undeclared" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("counts repeated invocations and advances the activity index", () => {
|
|
113
|
+
const { nodes } = deriveGraph([
|
|
114
|
+
created("Js.Script", "x"),
|
|
115
|
+
disp("x", "Js.Script", "ok", "Invoked", { inputs: {}, outputs: 1 }),
|
|
116
|
+
disp("x", "Js.Script", "ok", "Invoked", { inputs: {}, outputs: 2 }),
|
|
117
|
+
]);
|
|
118
|
+
expect(nodes[0].invokeCount).toBe(2);
|
|
119
|
+
expect(nodes[0].lastInvoke?.outputs).toBe(2);
|
|
120
|
+
expect(nodes[0].lastActivitySeq).toBeGreaterThan(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("attributes an invocation to the resource named in the payload ref", () => {
|
|
124
|
+
const { nodes } = deriveGraph([
|
|
125
|
+
created("Http.Server", "api"),
|
|
126
|
+
created("Http.Server.api", "sub"),
|
|
127
|
+
disp("sub", "Http.Server.api", "ok", "Invoked", { inputs: {}, outputs: "ok" }),
|
|
128
|
+
]);
|
|
129
|
+
expect(nodes.find((n) => n.name === "sub")?.invokeCount).toBe(1);
|
|
130
|
+
expect(nodes.find((n) => n.name === "api")?.invokeCount).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("ignores log frames and non-resource events", () => {
|
|
134
|
+
const { nodes, edges } = deriveGraph([
|
|
135
|
+
{ kind: "log", timestamp: "2026-01-01T00:00:00.000Z", stream: "stdout", line: "hello" },
|
|
136
|
+
ev("Kernel.Started", {}),
|
|
137
|
+
created("Js.Script", "x"),
|
|
138
|
+
]);
|
|
139
|
+
expect(nodes).toHaveLength(1);
|
|
140
|
+
expect(edges).toHaveLength(0);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/** A terminal dispatch event carrying trace ids — `spanId` / `parentSpanId`. */
|
|
145
|
+
const inv = (
|
|
146
|
+
name: string,
|
|
147
|
+
kind: string,
|
|
148
|
+
spanId: number,
|
|
149
|
+
parentSpanId: number | undefined,
|
|
150
|
+
outcome: Outcome,
|
|
151
|
+
suffix: string,
|
|
152
|
+
detail: Record<string, unknown> = {},
|
|
153
|
+
): DebugFrame =>
|
|
154
|
+
ev(`${name}.${suffix}`, {
|
|
155
|
+
spanId,
|
|
156
|
+
parentSpanId,
|
|
157
|
+
capability: "invoke",
|
|
158
|
+
phase: "end",
|
|
159
|
+
outcome,
|
|
160
|
+
ref: { kind, name },
|
|
161
|
+
...detail,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("deriveInvocations", () => {
|
|
165
|
+
it("is empty when events carry no span ids (tracing off)", () => {
|
|
166
|
+
const t = deriveInvocations([disp("x", "Js.Script", "ok", "Invoked", { outputs: 1 })]);
|
|
167
|
+
expect(t.roots).toHaveLength(0);
|
|
168
|
+
expect(t.byId.size).toBe(0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("collects a root invocation with its resource, inputs and outputs", () => {
|
|
172
|
+
const t = deriveInvocations([inv("api", "Http.Server", 1, undefined, "ok", "Invoked", { inputs: { a: 1 }, outputs: "ok" })]);
|
|
173
|
+
expect(t.roots).toHaveLength(1);
|
|
174
|
+
expect(t.roots[0]).toMatchObject({ id: 1, kind: "Http.Server", name: "api", outcome: "ok", inputs: { a: 1 }, outputs: "ok" });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("links children to parents and keeps only roots at the top", () => {
|
|
178
|
+
const t = deriveInvocations([
|
|
179
|
+
inv("root", "A", 1, undefined, "ok", "Invoked", { outputs: 1 }),
|
|
180
|
+
inv("child", "B", 2, 1, "ok", "Invoked", { outputs: 2 }),
|
|
181
|
+
inv("grand", "C", 3, 2, "ok", "Invoked", { outputs: 3 }),
|
|
182
|
+
]);
|
|
183
|
+
expect(t.roots.map((r) => r.id)).toEqual([1]);
|
|
184
|
+
expect(t.childrenOf.get(1)).toEqual([2]);
|
|
185
|
+
expect(t.childrenOf.get(2)).toEqual([3]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("dedupes the InvokeRejected.Undeclared echo by id, keeping the primary", () => {
|
|
189
|
+
const t = deriveInvocations([
|
|
190
|
+
inv("x", "A", 1, undefined, "rejected", "InvokeRejected", { code: "E" }),
|
|
191
|
+
inv("x", "A", 1, undefined, "rejected", "InvokeRejected.Undeclared", { code: "E" }),
|
|
192
|
+
]);
|
|
193
|
+
expect(t.byId.size).toBe(1);
|
|
194
|
+
expect(t.byId.get(1)).toMatchObject({ outcome: "rejected", suffix: "InvokeRejected" });
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("traceSubgraph", () => {
|
|
199
|
+
it("returns only the participating resources wired by call edges", () => {
|
|
200
|
+
const t = deriveInvocations([
|
|
201
|
+
inv("root", "A", 1, undefined, "ok", "Invoked", { outputs: 1 }),
|
|
202
|
+
inv("child", "B", 2, 1, "ok", "Invoked", { outputs: 2 }),
|
|
203
|
+
inv("other", "C", 9, undefined, "ok", "Invoked", { outputs: 9 }), // a different trace
|
|
204
|
+
]);
|
|
205
|
+
const g = traceSubgraph(t, 1);
|
|
206
|
+
expect(g.nodes.map((n) => n.id).sort()).toEqual(["A.root", "B.child"]);
|
|
207
|
+
expect(g.nodes.find((n) => n.id === "A.root")?.isRoot).toBe(true);
|
|
208
|
+
expect(g.edges).toEqual([{ id: "A.root->B.child", source: "A.root", target: "B.child" }]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("collapses repeated calls to one node holding every invocation", () => {
|
|
212
|
+
const t = deriveInvocations([
|
|
213
|
+
inv("root", "A", 1, undefined, "ok", "Invoked", { outputs: 1 }),
|
|
214
|
+
inv("svc", "B", 2, 1, "ok", "Invoked", { outputs: 2 }),
|
|
215
|
+
inv("svc", "B", 3, 1, "ok", "Invoked", { outputs: 3 }),
|
|
216
|
+
]);
|
|
217
|
+
const g = traceSubgraph(t, 1);
|
|
218
|
+
const svc = g.nodes.find((n) => n.id === "B.svc");
|
|
219
|
+
expect(svc?.invocations.map((i) => i.id)).toEqual([2, 3]);
|
|
220
|
+
expect(g.edges).toHaveLength(1);
|
|
221
|
+
});
|
|
222
|
+
});
|