@telorun/debug-ui 0.2.0 → 0.3.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-CWG3ZSzH.css +1 -0
- package/app-dist/assets/index-DS4SK8dD.js +126 -0
- package/app-dist/index.html +2 -2
- package/app-single/index.html +48 -26
- package/package.json +3 -1
- package/src/components/DebugPanel.tsx +12 -3
- package/src/components/EventGraph.tsx +456 -0
- package/src/components/index.ts +1 -0
- package/src/components/styles.css +270 -0
- package/src/endpoints.ts +11 -1
- package/src/graph.test.ts +194 -0
- package/src/graph.ts +332 -0
- package/src/index.ts +16 -0
- package/app-dist/assets/index-Bye-pLmT.js +0 -104
- package/app-dist/assets/index-CK8ArLzP.css +0 -1
|
@@ -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/endpoints.ts
CHANGED
|
@@ -21,8 +21,18 @@ export function endpointHref(endpoint: AppEndpoint): string | null {
|
|
|
21
21
|
return `http://${formatHost(endpoint.host)}:${endpoint.port}`;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/** Display label for an endpoint, e.g. `localhost:8080` or `:9000/udp`.
|
|
24
|
+
/** Display label for an endpoint, e.g. `localhost:8080` or `:9000/udp`. When the
|
|
25
|
+
* endpoint carries an absolute `url`, label with that url's authority so the text
|
|
26
|
+
* matches the link actually opened — `host`/`port` may be display-only values
|
|
27
|
+
* (e.g. a proxy/ingress whose `host` isn't itself routable). */
|
|
25
28
|
export function endpointLabel(endpoint: AppEndpoint): string {
|
|
29
|
+
if (endpoint.url) {
|
|
30
|
+
try {
|
|
31
|
+
return new URL(endpoint.url).host;
|
|
32
|
+
} catch {
|
|
33
|
+
// Malformed url — fall back to host:port below.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
26
36
|
const host = endpoint.host ? formatHost(endpoint.host) : "";
|
|
27
37
|
const base = `${host}:${endpoint.port}`;
|
|
28
38
|
return endpoint.protocol === "tcp" ? base : `${base}/udp`;
|
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
describe("deriveGraph", () => {
|
|
16
|
+
it("creates a gray (created) node on Created", () => {
|
|
17
|
+
const { nodes } = deriveGraph([created("Http.Server", "api")]);
|
|
18
|
+
expect(nodes).toHaveLength(1);
|
|
19
|
+
expect(nodes[0]).toMatchObject({ id: "api", kind: "Http.Server", name: "api", status: "created", invokeCount: 0 });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("brightens the node on Initialized", () => {
|
|
23
|
+
const { nodes } = deriveGraph([
|
|
24
|
+
created("Http.Server", "api"),
|
|
25
|
+
ev("Http.Server.api.Initialized", { resource: { kind: "Http.Server", name: "api" } }),
|
|
26
|
+
]);
|
|
27
|
+
expect(nodes[0].status).toBe("initialized");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("marks the node torndown on Teardown", () => {
|
|
31
|
+
const { nodes } = deriveGraph([
|
|
32
|
+
created("Http.Server", "api"),
|
|
33
|
+
ev("Http.Server.api.Teardown", { resource: { kind: "Http.Server", name: "api" } }),
|
|
34
|
+
]);
|
|
35
|
+
expect(nodes[0].status).toBe("torndown");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("wires edges from Created dependencies", () => {
|
|
39
|
+
const { edges } = deriveGraph([
|
|
40
|
+
created("Sql.Pool", "db"),
|
|
41
|
+
created("Http.Server", "api", [{ kind: "Sql.Pool", name: "db" }]),
|
|
42
|
+
]);
|
|
43
|
+
expect(edges).toEqual([{ id: "api->db", source: "api", target: "db" }]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("dedupes repeated dependency edges", () => {
|
|
47
|
+
const dep = [{ kind: "Sql.Pool", name: "db" }];
|
|
48
|
+
const { edges } = deriveGraph([created("Http.Server", "api", dep), created("Http.Server", "api", dep)]);
|
|
49
|
+
expect(edges).toHaveLength(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("records an ok invocation with inputs and outputs", () => {
|
|
53
|
+
const { nodes } = deriveGraph([
|
|
54
|
+
created("Js.Script", "echo"),
|
|
55
|
+
ev("Js.Script.echo.Invoked", { inputs: { msg: "hi" }, outputs: { echoed: "hi" } }),
|
|
56
|
+
]);
|
|
57
|
+
expect(nodes[0].invokeCount).toBe(1);
|
|
58
|
+
expect(nodes[0].lastInvoke).toMatchObject({
|
|
59
|
+
outcome: "ok",
|
|
60
|
+
suffix: "Invoked",
|
|
61
|
+
inputs: { msg: "hi" },
|
|
62
|
+
outputs: { echoed: "hi" },
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("classifies failure, rejection and cancellation outcomes", () => {
|
|
67
|
+
const base = created("Js.Script", "x");
|
|
68
|
+
expect(
|
|
69
|
+
deriveGraph([base, ev("Js.Script.x.InvokeFailed", { inputs: {}, name: "Error", message: "boom" })])
|
|
70
|
+
.nodes[0].lastInvoke?.outcome,
|
|
71
|
+
).toBe("failed");
|
|
72
|
+
expect(
|
|
73
|
+
deriveGraph([base, ev("Js.Script.x.InvokeRejected", { inputs: {}, code: "E", message: "no" })])
|
|
74
|
+
.nodes[0].lastInvoke?.outcome,
|
|
75
|
+
).toBe("rejected");
|
|
76
|
+
expect(
|
|
77
|
+
deriveGraph([base, ev("Js.Script.x.InvokeCancelled", { inputs: {}, reason: "stop" })])
|
|
78
|
+
.nodes[0].lastInvoke?.outcome,
|
|
79
|
+
).toBe("cancelled");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("treats InvokeRejected.Undeclared as a rejection on the right node", () => {
|
|
83
|
+
const { nodes } = deriveGraph([
|
|
84
|
+
created("Js.Script", "x"),
|
|
85
|
+
ev("Js.Script.x.InvokeRejected.Undeclared", { inputs: {}, code: "E", message: "no" }),
|
|
86
|
+
]);
|
|
87
|
+
expect(nodes[0].lastInvoke).toMatchObject({ outcome: "rejected", suffix: "InvokeRejected.Undeclared" });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("counts repeated invocations and advances the activity index", () => {
|
|
91
|
+
const { nodes } = deriveGraph([
|
|
92
|
+
created("Js.Script", "x"),
|
|
93
|
+
ev("Js.Script.x.Invoked", { inputs: {}, outputs: 1 }),
|
|
94
|
+
ev("Js.Script.x.Invoked", { inputs: {}, outputs: 2 }),
|
|
95
|
+
]);
|
|
96
|
+
expect(nodes[0].invokeCount).toBe(2);
|
|
97
|
+
expect(nodes[0].lastInvoke?.outputs).toBe(2);
|
|
98
|
+
expect(nodes[0].lastActivitySeq).toBeGreaterThan(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("attributes an invocation to the longest matching resource prefix", () => {
|
|
102
|
+
// `Http.Server.api` and `Http.Server.api.sub` share a prefix; the event must
|
|
103
|
+
// land on the deeper node, not the shorter one.
|
|
104
|
+
const { nodes } = deriveGraph([
|
|
105
|
+
created("Http.Server", "api"),
|
|
106
|
+
created("Http.Server.api", "sub"),
|
|
107
|
+
ev("Http.Server.api.sub.Invoked", { inputs: {}, outputs: "ok" }),
|
|
108
|
+
]);
|
|
109
|
+
expect(nodes.find((n) => n.name === "sub")?.invokeCount).toBe(1);
|
|
110
|
+
expect(nodes.find((n) => n.name === "api")?.invokeCount).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("ignores log frames and non-resource events", () => {
|
|
114
|
+
const { nodes, edges } = deriveGraph([
|
|
115
|
+
{ kind: "log", timestamp: "2026-01-01T00:00:00.000Z", stream: "stdout", line: "hello" },
|
|
116
|
+
ev("Kernel.Started", {}),
|
|
117
|
+
created("Js.Script", "x"),
|
|
118
|
+
]);
|
|
119
|
+
expect(nodes).toHaveLength(1);
|
|
120
|
+
expect(edges).toHaveLength(0);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const inv = (
|
|
125
|
+
event: string,
|
|
126
|
+
invocationId: number,
|
|
127
|
+
parentInvocationId: number | undefined,
|
|
128
|
+
payload: unknown,
|
|
129
|
+
): DebugFrame => ({
|
|
130
|
+
timestamp: `2026-01-01T00:00:00.${String(seq++).padStart(3, "0")}Z`,
|
|
131
|
+
event,
|
|
132
|
+
payload,
|
|
133
|
+
metadata: { invocationId, ...(parentInvocationId !== undefined ? { parentInvocationId } : {}) },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("deriveInvocations", () => {
|
|
137
|
+
it("is empty when events carry no invocation metadata (tracing off)", () => {
|
|
138
|
+
const t = deriveInvocations([ev("Js.Script.x.Invoked", { outputs: 1 })]);
|
|
139
|
+
expect(t.roots).toHaveLength(0);
|
|
140
|
+
expect(t.byId.size).toBe(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("collects a root invocation with its resource, inputs and outputs", () => {
|
|
144
|
+
const t = deriveInvocations([inv("Http.Server.api.Invoked", 1, undefined, { inputs: { a: 1 }, outputs: "ok" })]);
|
|
145
|
+
expect(t.roots).toHaveLength(1);
|
|
146
|
+
expect(t.roots[0]).toMatchObject({ id: 1, kind: "Http.Server", name: "api", outcome: "ok", inputs: { a: 1 }, outputs: "ok" });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("links children to parents and keeps only roots at the top", () => {
|
|
150
|
+
const t = deriveInvocations([
|
|
151
|
+
inv("A.root.Invoked", 1, undefined, { outputs: 1 }),
|
|
152
|
+
inv("B.child.Invoked", 2, 1, { outputs: 2 }),
|
|
153
|
+
inv("C.grand.Invoked", 3, 2, { outputs: 3 }),
|
|
154
|
+
]);
|
|
155
|
+
expect(t.roots.map((r) => r.id)).toEqual([1]);
|
|
156
|
+
expect(t.childrenOf.get(1)).toEqual([2]);
|
|
157
|
+
expect(t.childrenOf.get(2)).toEqual([3]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("dedupes the InvokeRejected.Undeclared echo by id", () => {
|
|
161
|
+
const t = deriveInvocations([
|
|
162
|
+
inv("A.x.InvokeRejected", 1, undefined, { code: "E" }),
|
|
163
|
+
inv("A.x.InvokeRejected.Undeclared", 1, undefined, { code: "E" }),
|
|
164
|
+
]);
|
|
165
|
+
expect(t.byId.size).toBe(1);
|
|
166
|
+
expect(t.byId.get(1)).toMatchObject({ outcome: "rejected", suffix: "InvokeRejected" });
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("traceSubgraph", () => {
|
|
171
|
+
it("returns only the participating resources wired by call edges", () => {
|
|
172
|
+
const t = deriveInvocations([
|
|
173
|
+
inv("A.root.Invoked", 1, undefined, { outputs: 1 }),
|
|
174
|
+
inv("B.child.Invoked", 2, 1, { outputs: 2 }),
|
|
175
|
+
inv("C.other.Invoked", 9, undefined, { outputs: 9 }), // a different trace
|
|
176
|
+
]);
|
|
177
|
+
const g = traceSubgraph(t, 1);
|
|
178
|
+
expect(g.nodes.map((n) => n.id).sort()).toEqual(["A.root", "B.child"]);
|
|
179
|
+
expect(g.nodes.find((n) => n.id === "A.root")?.isRoot).toBe(true);
|
|
180
|
+
expect(g.edges).toEqual([{ id: "A.root->B.child", source: "A.root", target: "B.child" }]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("collapses repeated calls to one node holding every invocation", () => {
|
|
184
|
+
const t = deriveInvocations([
|
|
185
|
+
inv("A.root.Invoked", 1, undefined, { outputs: 1 }),
|
|
186
|
+
inv("B.svc.Invoked", 2, 1, { outputs: 2 }),
|
|
187
|
+
inv("B.svc.Invoked", 3, 1, { outputs: 3 }),
|
|
188
|
+
]);
|
|
189
|
+
const g = traceSubgraph(t, 1);
|
|
190
|
+
const svc = g.nodes.find((n) => n.id === "B.svc");
|
|
191
|
+
expect(svc?.invocations.map((i) => i.id)).toEqual([2, 3]);
|
|
192
|
+
expect(g.edges).toHaveLength(1);
|
|
193
|
+
});
|
|
194
|
+
});
|