@telorun/debug-ui 0.3.0 → 0.5.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/src/graph.test.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { deriveGraph, deriveInvocations, traceSubgraph } from "./graph.js";
2
+ import {
3
+ collapseTopology,
4
+ deriveGraph,
5
+ deriveInvocations,
6
+ subtreeGraph,
7
+ traceSubgraph,
8
+ } from "./graph.js";
3
9
  import type { DebugFrame } from "./wire.js";
4
10
 
5
11
  let seq = 0;
@@ -12,6 +18,48 @@ const ev = (event: string, payload?: unknown): DebugFrame => ({
12
18
  const created = (kind: string, name: string, deps: { kind: string; name: string }[] = []) =>
13
19
  ev(`${kind}.${name}.Created`, { resource: { kind, name }, dependencies: deps });
14
20
 
21
+ type Owner = { kind: string; name: string; id: string };
22
+ const idOf = (kind: string, name: string, ownerId?: string) =>
23
+ ownerId ? `${ownerId}/${kind}.${name}` : `${kind}.${name}`;
24
+
25
+ /** A `Created` carrying the hierarchical `id` (and `owner`, for a spawned child)
26
+ * the current kernel emits — the shape that disambiguates two instances of the
27
+ * same templated kind. */
28
+ const createdR = (
29
+ kind: string,
30
+ name: string,
31
+ opts: { owner?: Owner; deps?: { kind: string; name: string; id?: string }[] } = {},
32
+ ) =>
33
+ ev(`${kind}.${name}.Created`, {
34
+ resource: { kind, name, id: idOf(kind, name, opts.owner?.id) },
35
+ ...(opts.owner ? { owner: opts.owner } : {}),
36
+ dependencies: opts.deps ?? [],
37
+ });
38
+
39
+ /** A terminal dispatch event whose `ref` carries the hierarchical `id`. */
40
+ const dispId = (
41
+ name: string,
42
+ kind: string,
43
+ id: string,
44
+ outcome: Outcome,
45
+ suffix: string,
46
+ detail: Record<string, unknown> = {},
47
+ ): DebugFrame =>
48
+ ev(`${name}.${suffix}`, { capability: "invoke", phase: "end", outcome, ref: { kind, name, id }, ...detail });
49
+
50
+ type Outcome = "ok" | "failed" | "rejected" | "cancelled";
51
+
52
+ /** A terminal dispatch (trace) event: the name drops the kind, the payload carries
53
+ * the structured trace contract `{ capability, phase, outcome, ref, … }`. */
54
+ const disp = (
55
+ name: string,
56
+ kind: string,
57
+ outcome: Outcome,
58
+ suffix: string,
59
+ detail: Record<string, unknown> = {},
60
+ ): DebugFrame =>
61
+ ev(`${name}.${suffix}`, { capability: "invoke", phase: "end", outcome, ref: { kind, name }, ...detail });
62
+
15
63
  describe("deriveGraph", () => {
16
64
  it("creates a gray (created) node on Created", () => {
17
65
  const { nodes } = deriveGraph([created("Http.Server", "api")]);
@@ -52,7 +100,7 @@ describe("deriveGraph", () => {
52
100
  it("records an ok invocation with inputs and outputs", () => {
53
101
  const { nodes } = deriveGraph([
54
102
  created("Js.Script", "echo"),
55
- ev("Js.Script.echo.Invoked", { inputs: { msg: "hi" }, outputs: { echoed: "hi" } }),
103
+ disp("echo", "Js.Script", "ok", "Invoked", { inputs: { msg: "hi" }, outputs: { echoed: "hi" } }),
56
104
  ]);
57
105
  expect(nodes[0].invokeCount).toBe(1);
58
106
  expect(nodes[0].lastInvoke).toMatchObject({
@@ -63,18 +111,27 @@ describe("deriveGraph", () => {
63
111
  });
64
112
  });
65
113
 
66
- it("classifies failure, rejection and cancellation outcomes", () => {
114
+ it("skips a phase:start event (no outcome) — only terminal events count", () => {
115
+ const { nodes } = deriveGraph([
116
+ created("Js.Script", "echo"),
117
+ ev("echo.Invoking", { capability: "invoke", phase: "start", ref: { kind: "Js.Script", name: "echo" }, inputs: {} }),
118
+ ]);
119
+ expect(nodes[0].invokeCount).toBe(0);
120
+ expect(nodes[0].lastInvoke).toBeUndefined();
121
+ });
122
+
123
+ it("records failure, rejection and cancellation outcomes from the payload", () => {
67
124
  const base = created("Js.Script", "x");
68
125
  expect(
69
- deriveGraph([base, ev("Js.Script.x.InvokeFailed", { inputs: {}, name: "Error", message: "boom" })])
126
+ deriveGraph([base, disp("x", "Js.Script", "failed", "InvokeFailed", { inputs: {}, name: "Error", message: "boom" })])
70
127
  .nodes[0].lastInvoke?.outcome,
71
128
  ).toBe("failed");
72
129
  expect(
73
- deriveGraph([base, ev("Js.Script.x.InvokeRejected", { inputs: {}, code: "E", message: "no" })])
130
+ deriveGraph([base, disp("x", "Js.Script", "rejected", "InvokeRejected", { inputs: {}, code: "E", message: "no" })])
74
131
  .nodes[0].lastInvoke?.outcome,
75
132
  ).toBe("rejected");
76
133
  expect(
77
- deriveGraph([base, ev("Js.Script.x.InvokeCancelled", { inputs: {}, reason: "stop" })])
134
+ deriveGraph([base, disp("x", "Js.Script", "cancelled", "InvokeCancelled", { inputs: {}, reason: "stop" })])
78
135
  .nodes[0].lastInvoke?.outcome,
79
136
  ).toBe("cancelled");
80
137
  });
@@ -82,29 +139,27 @@ describe("deriveGraph", () => {
82
139
  it("treats InvokeRejected.Undeclared as a rejection on the right node", () => {
83
140
  const { nodes } = deriveGraph([
84
141
  created("Js.Script", "x"),
85
- ev("Js.Script.x.InvokeRejected.Undeclared", { inputs: {}, code: "E", message: "no" }),
142
+ disp("x", "Js.Script", "rejected", "InvokeRejected.Undeclared", { inputs: {}, code: "E", message: "no" }),
86
143
  ]);
87
- expect(nodes[0].lastInvoke).toMatchObject({ outcome: "rejected", suffix: "InvokeRejected.Undeclared" });
144
+ expect(nodes[0].lastInvoke).toMatchObject({ outcome: "rejected", suffix: "Undeclared" });
88
145
  });
89
146
 
90
147
  it("counts repeated invocations and advances the activity index", () => {
91
148
  const { nodes } = deriveGraph([
92
149
  created("Js.Script", "x"),
93
- ev("Js.Script.x.Invoked", { inputs: {}, outputs: 1 }),
94
- ev("Js.Script.x.Invoked", { inputs: {}, outputs: 2 }),
150
+ disp("x", "Js.Script", "ok", "Invoked", { inputs: {}, outputs: 1 }),
151
+ disp("x", "Js.Script", "ok", "Invoked", { inputs: {}, outputs: 2 }),
95
152
  ]);
96
153
  expect(nodes[0].invokeCount).toBe(2);
97
154
  expect(nodes[0].lastInvoke?.outputs).toBe(2);
98
155
  expect(nodes[0].lastActivitySeq).toBeGreaterThan(0);
99
156
  });
100
157
 
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.
158
+ it("attributes an invocation to the resource named in the payload ref", () => {
104
159
  const { nodes } = deriveGraph([
105
160
  created("Http.Server", "api"),
106
161
  created("Http.Server.api", "sub"),
107
- ev("Http.Server.api.sub.Invoked", { inputs: {}, outputs: "ok" }),
162
+ disp("sub", "Http.Server.api", "ok", "Invoked", { inputs: {}, outputs: "ok" }),
108
163
  ]);
109
164
  expect(nodes.find((n) => n.name === "sub")?.invokeCount).toBe(1);
110
165
  expect(nodes.find((n) => n.name === "api")?.invokeCount).toBe(0);
@@ -121,46 +176,222 @@ describe("deriveGraph", () => {
121
176
  });
122
177
  });
123
178
 
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 } : {}) },
179
+ describe("owner grouping", () => {
180
+ const todos: Owner = { kind: "Crud.Resource", name: "todos", id: "Crud.Resource.todos" };
181
+ const users: Owner = { kind: "Crud.Resource", name: "users", id: "Crud.Resource.users" };
182
+
183
+ it("keeps same-named children of two templated instances from colliding", () => {
184
+ const { nodes } = deriveGraph([
185
+ createdR("Crud.Resource", "todos"),
186
+ createdR("Crud.Resource", "users"),
187
+ createdR("SqlRepo.Read", "reader", { owner: todos }),
188
+ createdR("SqlRepo.Read", "reader", { owner: users }),
189
+ ]);
190
+ const readers = nodes.filter((n) => n.name === "reader");
191
+ expect(readers.map((n) => n.id).sort()).toEqual([
192
+ "Crud.Resource.todos/SqlRepo.Read.reader",
193
+ "Crud.Resource.users/SqlRepo.Read.reader",
194
+ ]);
195
+ expect(readers.map((n) => n.ownerId).sort()).toEqual([
196
+ "Crud.Resource.todos",
197
+ "Crud.Resource.users",
198
+ ]);
199
+ });
200
+
201
+ it("attributes an invocation to the right instance via ref.id", () => {
202
+ const { nodes } = deriveGraph([
203
+ createdR("Crud.Resource", "todos"),
204
+ createdR("Crud.Resource", "users"),
205
+ createdR("SqlRepo.Read", "reader", { owner: todos }),
206
+ createdR("SqlRepo.Read", "reader", { owner: users }),
207
+ dispId("reader", "SqlRepo.Read", "Crud.Resource.todos/SqlRepo.Read.reader", "ok", "Invoked", {
208
+ inputs: {},
209
+ outputs: 1,
210
+ }),
211
+ ]);
212
+ expect(nodes.find((n) => n.id === "Crud.Resource.todos/SqlRepo.Read.reader")?.invokeCount).toBe(1);
213
+ expect(nodes.find((n) => n.id === "Crud.Resource.users/SqlRepo.Read.reader")?.invokeCount).toBe(0);
214
+ });
215
+
216
+ it("collapseTopology hides children until their owner is expanded", () => {
217
+ const graph = deriveGraph([
218
+ createdR("Crud.Resource", "todos"),
219
+ createdR("SqlRepo.Read", "reader", { owner: todos }),
220
+ createdR("SqlRepo.Create", "creator", { owner: todos }),
221
+ ]);
222
+
223
+ const collapsedDefault = collapseTopology(graph, new Set());
224
+ expect(collapsedDefault.nodes.map((n) => n.id)).toEqual(["Crud.Resource.todos"]);
225
+ expect(collapsedDefault.nodes[0]).toMatchObject({ childCount: 2, expanded: false });
226
+
227
+ const expanded = collapseTopology(graph, new Set(["Crud.Resource.todos"]));
228
+ expect(expanded.nodes.map((n) => n.id).sort()).toEqual([
229
+ "Crud.Resource.todos",
230
+ "Crud.Resource.todos/SqlRepo.Create.creator",
231
+ "Crud.Resource.todos/SqlRepo.Read.reader",
232
+ ]);
233
+ expect(expanded.nodes.find((n) => n.id === "Crud.Resource.todos")).toMatchObject({ expanded: true });
234
+
235
+ // Revealed children stay attached to their owner via ownership edges.
236
+ const owns = expanded.edges.filter((e) => e.ownership);
237
+ expect(owns.map((e) => e.target).sort()).toEqual([
238
+ "Crud.Resource.todos/SqlRepo.Create.creator",
239
+ "Crud.Resource.todos/SqlRepo.Read.reader",
240
+ ]);
241
+ expect(owns.every((e) => e.source === "Crud.Resource.todos")).toBe(true);
242
+ });
243
+
244
+ it("adds no ownership edges while the owner is collapsed", () => {
245
+ const graph = deriveGraph([
246
+ createdR("Crud.Resource", "todos"),
247
+ createdR("SqlRepo.Read", "reader", { owner: todos }),
248
+ ]);
249
+ expect(collapseTopology(graph, new Set()).edges.some((e) => e.ownership)).toBe(false);
250
+ });
251
+
252
+ it("collapseTopology folds a hidden child's edge onto its owner", () => {
253
+ const graph = deriveGraph([
254
+ createdR("Sql.Connection", "db"),
255
+ createdR("Crud.Resource", "todos"),
256
+ createdR("SqlRepo.Read", "reader", {
257
+ owner: todos,
258
+ deps: [{ kind: "Sql.Connection", name: "db", id: "Sql.Connection.db" }],
259
+ }),
260
+ ]);
261
+ const collapsed = collapseTopology(graph, new Set());
262
+ expect(collapsed.edges).toContainEqual({
263
+ id: "Crud.Resource.todos->Sql.Connection.db",
264
+ source: "Crud.Resource.todos",
265
+ target: "Sql.Connection.db",
266
+ });
267
+ // The child node itself is hidden while collapsed.
268
+ expect(collapsed.nodes.some((n) => n.id.endsWith("SqlRepo.Read.reader"))).toBe(false);
269
+ });
134
270
  });
135
271
 
272
+ describe("subtreeGraph (drill-down pane)", () => {
273
+ const todos: Owner = { kind: "Crud.Resource", name: "todos", id: "Crud.Resource.todos" };
274
+
275
+ it("links the parent only to children not already reached by a sibling", () => {
276
+ const topology = deriveGraph([
277
+ createdR("Crud.Resource", "todos"),
278
+ createdR("Http.Api", "api", {
279
+ owner: todos,
280
+ deps: [{ kind: "SqlRepo.Read", name: "reader", id: "Crud.Resource.todos/SqlRepo.Read.reader" }],
281
+ }),
282
+ createdR("SqlRepo.Read", "reader", { owner: todos }),
283
+ ]);
284
+ const sub = subtreeGraph(topology, "Crud.Resource.todos");
285
+
286
+ expect(sub.nodes.map((n) => n.id).sort()).toEqual([
287
+ "Crud.Resource.todos",
288
+ "Crud.Resource.todos/Http.Api.api",
289
+ "Crud.Resource.todos/SqlRepo.Read.reader",
290
+ ]);
291
+ expect(sub.nodes.find((n) => n.id === "Crud.Resource.todos")?.expanded).toBe(true);
292
+
293
+ // The parent owns `api` directly; `reader` is reached through `api`, so the
294
+ // redundant parent→reader ownership edge is dropped.
295
+ const owns = sub.edges.filter((e) => e.ownership);
296
+ expect(owns.map((e) => e.target)).toEqual(["Crud.Resource.todos/Http.Api.api"]);
297
+ expect(owns.some((e) => e.target.endsWith("SqlRepo.Read.reader"))).toBe(false);
298
+
299
+ // The dependency edge among children (api → reader) is kept.
300
+ expect(
301
+ sub.edges.some(
302
+ (e) =>
303
+ !e.ownership &&
304
+ e.source === "Crud.Resource.todos/Http.Api.api" &&
305
+ e.target === "Crud.Resource.todos/SqlRepo.Read.reader",
306
+ ),
307
+ ).toBe(true);
308
+ });
309
+
310
+ it("reports each child's own childCount and drills recursively", () => {
311
+ const reader: Owner = {
312
+ kind: "SqlRepo.Read",
313
+ name: "reader",
314
+ id: "Crud.Resource.todos/SqlRepo.Read.reader",
315
+ };
316
+ const topology = deriveGraph([
317
+ createdR("Crud.Resource", "todos"),
318
+ createdR("SqlRepo.Read", "reader", { owner: todos }),
319
+ createdR("Sql.Query", "reader-query", { owner: reader }),
320
+ ]);
321
+
322
+ const lvl1 = subtreeGraph(topology, "Crud.Resource.todos");
323
+ expect(lvl1.nodes.find((n) => n.id === reader.id)?.childCount).toBe(1);
324
+
325
+ const lvl2 = subtreeGraph(topology, reader.id);
326
+ expect(lvl2.nodes.map((n) => n.id).sort()).toEqual([
327
+ "Crud.Resource.todos/SqlRepo.Read.reader",
328
+ "Crud.Resource.todos/SqlRepo.Read.reader/Sql.Query.reader-query",
329
+ ]);
330
+ });
331
+
332
+ it("drops dependency edges that leave the subtree", () => {
333
+ const topology = deriveGraph([
334
+ createdR("Sql.Connection", "db"),
335
+ createdR("Crud.Resource", "todos"),
336
+ createdR("SqlRepo.Read", "reader", {
337
+ owner: todos,
338
+ deps: [{ kind: "Sql.Connection", name: "db", id: "Sql.Connection.db" }],
339
+ }),
340
+ ]);
341
+ const sub = subtreeGraph(topology, "Crud.Resource.todos");
342
+ expect(sub.nodes.some((n) => n.id === "Sql.Connection.db")).toBe(false);
343
+ expect(sub.edges.some((e) => e.target === "Sql.Connection.db")).toBe(false);
344
+ });
345
+ });
346
+
347
+ /** A terminal dispatch event carrying trace ids — `spanId` / `parentSpanId`. */
348
+ const inv = (
349
+ name: string,
350
+ kind: string,
351
+ spanId: number,
352
+ parentSpanId: number | undefined,
353
+ outcome: Outcome,
354
+ suffix: string,
355
+ detail: Record<string, unknown> = {},
356
+ ): DebugFrame =>
357
+ ev(`${name}.${suffix}`, {
358
+ spanId,
359
+ parentSpanId,
360
+ capability: "invoke",
361
+ phase: "end",
362
+ outcome,
363
+ ref: { kind, name },
364
+ ...detail,
365
+ });
366
+
136
367
  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 })]);
368
+ it("is empty when events carry no span ids (tracing off)", () => {
369
+ const t = deriveInvocations([disp("x", "Js.Script", "ok", "Invoked", { outputs: 1 })]);
139
370
  expect(t.roots).toHaveLength(0);
140
371
  expect(t.byId.size).toBe(0);
141
372
  });
142
373
 
143
374
  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" })]);
375
+ const t = deriveInvocations([inv("api", "Http.Server", 1, undefined, "ok", "Invoked", { inputs: { a: 1 }, outputs: "ok" })]);
145
376
  expect(t.roots).toHaveLength(1);
146
377
  expect(t.roots[0]).toMatchObject({ id: 1, kind: "Http.Server", name: "api", outcome: "ok", inputs: { a: 1 }, outputs: "ok" });
147
378
  });
148
379
 
149
380
  it("links children to parents and keeps only roots at the top", () => {
150
381
  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 }),
382
+ inv("root", "A", 1, undefined, "ok", "Invoked", { outputs: 1 }),
383
+ inv("child", "B", 2, 1, "ok", "Invoked", { outputs: 2 }),
384
+ inv("grand", "C", 3, 2, "ok", "Invoked", { outputs: 3 }),
154
385
  ]);
155
386
  expect(t.roots.map((r) => r.id)).toEqual([1]);
156
387
  expect(t.childrenOf.get(1)).toEqual([2]);
157
388
  expect(t.childrenOf.get(2)).toEqual([3]);
158
389
  });
159
390
 
160
- it("dedupes the InvokeRejected.Undeclared echo by id", () => {
391
+ it("dedupes the InvokeRejected.Undeclared echo by id, keeping the primary", () => {
161
392
  const t = deriveInvocations([
162
- inv("A.x.InvokeRejected", 1, undefined, { code: "E" }),
163
- inv("A.x.InvokeRejected.Undeclared", 1, undefined, { code: "E" }),
393
+ inv("x", "A", 1, undefined, "rejected", "InvokeRejected", { code: "E" }),
394
+ inv("x", "A", 1, undefined, "rejected", "InvokeRejected.Undeclared", { code: "E" }),
164
395
  ]);
165
396
  expect(t.byId.size).toBe(1);
166
397
  expect(t.byId.get(1)).toMatchObject({ outcome: "rejected", suffix: "InvokeRejected" });
@@ -170,9 +401,9 @@ describe("deriveInvocations", () => {
170
401
  describe("traceSubgraph", () => {
171
402
  it("returns only the participating resources wired by call edges", () => {
172
403
  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
404
+ inv("root", "A", 1, undefined, "ok", "Invoked", { outputs: 1 }),
405
+ inv("child", "B", 2, 1, "ok", "Invoked", { outputs: 2 }),
406
+ inv("other", "C", 9, undefined, "ok", "Invoked", { outputs: 9 }), // a different trace
176
407
  ]);
177
408
  const g = traceSubgraph(t, 1);
178
409
  expect(g.nodes.map((n) => n.id).sort()).toEqual(["A.root", "B.child"]);
@@ -182,9 +413,9 @@ describe("traceSubgraph", () => {
182
413
 
183
414
  it("collapses repeated calls to one node holding every invocation", () => {
184
415
  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 }),
416
+ inv("root", "A", 1, undefined, "ok", "Invoked", { outputs: 1 }),
417
+ inv("svc", "B", 2, 1, "ok", "Invoked", { outputs: 2 }),
418
+ inv("svc", "B", 3, 1, "ok", "Invoked", { outputs: 3 }),
188
419
  ]);
189
420
  const g = traceSubgraph(t, 1);
190
421
  const svc = g.nodes.find((n) => n.id === "B.svc");