agent-dbg 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 (99) hide show
  1. package/.bin/ndbg +0 -0
  2. package/.claude/settings.local.json +21 -0
  3. package/.claude/skills/ndbg-debugger/ndbg-debugger/SKILL.md +116 -0
  4. package/.claude/skills/ndbg-debugger/ndbg-debugger/references/commands.md +173 -0
  5. package/CLAUDE.md +43 -0
  6. package/PROGRESS.md +261 -0
  7. package/README.md +67 -0
  8. package/biome.json +41 -0
  9. package/ndbg-spec.md +958 -0
  10. package/package.json +30 -0
  11. package/src/cdp/client.ts +198 -0
  12. package/src/cdp/types.ts +16 -0
  13. package/src/cli/parser.ts +287 -0
  14. package/src/cli/registry.ts +7 -0
  15. package/src/cli/types.ts +24 -0
  16. package/src/commands/attach.ts +47 -0
  17. package/src/commands/blackbox-ls.ts +38 -0
  18. package/src/commands/blackbox-rm.ts +57 -0
  19. package/src/commands/blackbox.ts +48 -0
  20. package/src/commands/break-ls.ts +57 -0
  21. package/src/commands/break-rm.ts +40 -0
  22. package/src/commands/break-toggle.ts +42 -0
  23. package/src/commands/break.ts +145 -0
  24. package/src/commands/breakable.ts +69 -0
  25. package/src/commands/catch.ts +38 -0
  26. package/src/commands/console.ts +61 -0
  27. package/src/commands/continue.ts +46 -0
  28. package/src/commands/eval.ts +70 -0
  29. package/src/commands/exceptions.ts +61 -0
  30. package/src/commands/hotpatch.ts +67 -0
  31. package/src/commands/launch.ts +69 -0
  32. package/src/commands/logpoint.ts +78 -0
  33. package/src/commands/pause.ts +46 -0
  34. package/src/commands/props.ts +77 -0
  35. package/src/commands/restart-frame.ts +36 -0
  36. package/src/commands/run-to.ts +70 -0
  37. package/src/commands/scripts.ts +57 -0
  38. package/src/commands/search.ts +73 -0
  39. package/src/commands/sessions.ts +71 -0
  40. package/src/commands/set-return.ts +49 -0
  41. package/src/commands/set.ts +61 -0
  42. package/src/commands/source.ts +59 -0
  43. package/src/commands/sourcemap.ts +66 -0
  44. package/src/commands/stack.ts +64 -0
  45. package/src/commands/state.ts +124 -0
  46. package/src/commands/status.ts +57 -0
  47. package/src/commands/step.ts +50 -0
  48. package/src/commands/stop.ts +27 -0
  49. package/src/commands/vars.ts +71 -0
  50. package/src/daemon/client.ts +147 -0
  51. package/src/daemon/entry.ts +242 -0
  52. package/src/daemon/paths.ts +26 -0
  53. package/src/daemon/server.ts +185 -0
  54. package/src/daemon/session-blackbox.ts +41 -0
  55. package/src/daemon/session-breakpoints.ts +492 -0
  56. package/src/daemon/session-execution.ts +121 -0
  57. package/src/daemon/session-inspection.ts +701 -0
  58. package/src/daemon/session-mutation.ts +197 -0
  59. package/src/daemon/session-state.ts +258 -0
  60. package/src/daemon/session.ts +938 -0
  61. package/src/daemon/spawn.ts +53 -0
  62. package/src/formatter/errors.ts +15 -0
  63. package/src/formatter/source.ts +74 -0
  64. package/src/formatter/stack.ts +70 -0
  65. package/src/formatter/values.ts +269 -0
  66. package/src/formatter/variables.ts +20 -0
  67. package/src/main.ts +45 -0
  68. package/src/protocol/messages.ts +316 -0
  69. package/src/refs/ref-table.ts +120 -0
  70. package/src/refs/resolver.ts +24 -0
  71. package/src/sourcemap/resolver.ts +318 -0
  72. package/tests/fixtures/async-app.js +34 -0
  73. package/tests/fixtures/console-app.js +12 -0
  74. package/tests/fixtures/error-app.js +28 -0
  75. package/tests/fixtures/exception-app.js +6 -0
  76. package/tests/fixtures/inspect-app.js +10 -0
  77. package/tests/fixtures/mutation-app.js +9 -0
  78. package/tests/fixtures/simple-app.js +50 -0
  79. package/tests/fixtures/step-app.js +13 -0
  80. package/tests/fixtures/ts-app/src/app.ts +21 -0
  81. package/tests/fixtures/ts-app/tsconfig.json +14 -0
  82. package/tests/integration/blackbox.test.ts +135 -0
  83. package/tests/integration/break-extras.test.ts +241 -0
  84. package/tests/integration/breakpoint.test.ts +217 -0
  85. package/tests/integration/console.test.ts +275 -0
  86. package/tests/integration/execution.test.ts +247 -0
  87. package/tests/integration/inspection.test.ts +311 -0
  88. package/tests/integration/mutation.test.ts +178 -0
  89. package/tests/integration/session.test.ts +223 -0
  90. package/tests/integration/source.test.ts +209 -0
  91. package/tests/integration/sourcemap.test.ts +214 -0
  92. package/tests/integration/state.test.ts +208 -0
  93. package/tests/unit/cdp-client.test.ts +422 -0
  94. package/tests/unit/daemon.test.ts +286 -0
  95. package/tests/unit/formatter.test.ts +716 -0
  96. package/tests/unit/parser.test.ts +105 -0
  97. package/tests/unit/refs.test.ts +383 -0
  98. package/tests/unit/sourcemap.test.ts +236 -0
  99. package/tsconfig.json +32 -0
@@ -0,0 +1,716 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { formatError } from "../../src/formatter/errors.ts";
3
+ import { formatSource, type SourceLine } from "../../src/formatter/source.ts";
4
+ import { formatStack, type StackFrame } from "../../src/formatter/stack.ts";
5
+ import type { RemoteObject } from "../../src/formatter/values.ts";
6
+ import { formatValue } from "../../src/formatter/values.ts";
7
+ import { formatVariables, type Variable } from "../../src/formatter/variables.ts";
8
+
9
+ // =============================================================================
10
+ // formatValue
11
+ // =============================================================================
12
+
13
+ describe("formatValue", () => {
14
+ describe("primitives", () => {
15
+ test("undefined", () => {
16
+ expect(formatValue({ type: "undefined" })).toBe("undefined");
17
+ });
18
+
19
+ test("null", () => {
20
+ expect(formatValue({ type: "object", subtype: "null", value: null })).toBe("null");
21
+ });
22
+
23
+ test("boolean true", () => {
24
+ expect(formatValue({ type: "boolean", value: true })).toBe("true");
25
+ });
26
+
27
+ test("boolean false", () => {
28
+ expect(formatValue({ type: "boolean", value: false })).toBe("false");
29
+ });
30
+
31
+ test("number integer", () => {
32
+ expect(formatValue({ type: "number", value: 42 })).toBe("42");
33
+ });
34
+
35
+ test("number float", () => {
36
+ expect(formatValue({ type: "number", value: 3.14 })).toBe("3.14");
37
+ });
38
+
39
+ test("number zero", () => {
40
+ expect(formatValue({ type: "number", value: 0 })).toBe("0");
41
+ });
42
+
43
+ test("string", () => {
44
+ expect(formatValue({ type: "string", value: "hello" })).toBe('"hello"');
45
+ });
46
+
47
+ test("empty string", () => {
48
+ expect(formatValue({ type: "string", value: "" })).toBe('""');
49
+ });
50
+
51
+ test("bigint", () => {
52
+ expect(formatValue({ type: "bigint", value: 3, description: "3n" })).toBe("3n");
53
+ });
54
+
55
+ test("symbol", () => {
56
+ expect(formatValue({ type: "symbol", description: "Symbol(mySymbol)" })).toBe(
57
+ "Symbol(mySymbol)",
58
+ );
59
+ });
60
+
61
+ test("unserializable NaN", () => {
62
+ expect(formatValue({ type: "number", unserializableValue: "NaN" })).toBe("NaN");
63
+ });
64
+
65
+ test("unserializable Infinity", () => {
66
+ expect(formatValue({ type: "number", unserializableValue: "Infinity" })).toBe("Infinity");
67
+ });
68
+ });
69
+
70
+ describe("objects", () => {
71
+ test("generic object with preview", () => {
72
+ const obj: RemoteObject = {
73
+ type: "object",
74
+ className: "Job",
75
+ objectId: "1",
76
+ preview: {
77
+ type: "object",
78
+ description: "Job",
79
+ overflow: false,
80
+ properties: [
81
+ { name: "id", type: "string", value: "test-123" },
82
+ { name: "type", type: "string", value: "email" },
83
+ { name: "retries", type: "number", value: "2" },
84
+ ],
85
+ },
86
+ };
87
+ expect(formatValue(obj)).toBe('Job { id: "test-123", type: "email", retries: 2 }');
88
+ });
89
+
90
+ test("object with overflow", () => {
91
+ const obj: RemoteObject = {
92
+ type: "object",
93
+ className: "Config",
94
+ objectId: "1",
95
+ preview: {
96
+ type: "object",
97
+ description: "Config",
98
+ overflow: true,
99
+ properties: [
100
+ { name: "host", type: "string", value: "localhost" },
101
+ { name: "port", type: "number", value: "3000" },
102
+ ],
103
+ },
104
+ };
105
+ expect(formatValue(obj)).toBe('Config { host: "localhost", port: 3000, ... }');
106
+ });
107
+
108
+ test("object without preview", () => {
109
+ const obj: RemoteObject = {
110
+ type: "object",
111
+ className: "MyClass",
112
+ objectId: "1",
113
+ };
114
+ expect(formatValue(obj)).toBe("MyClass {...}");
115
+ });
116
+ });
117
+
118
+ describe("arrays", () => {
119
+ test("array with preview", () => {
120
+ const obj: RemoteObject = {
121
+ type: "object",
122
+ subtype: "array",
123
+ className: "Array",
124
+ description: "Array(3)",
125
+ objectId: "1",
126
+ preview: {
127
+ type: "object",
128
+ subtype: "array",
129
+ description: "Array(3)",
130
+ overflow: false,
131
+ properties: [
132
+ { name: "0", type: "string", value: "a" },
133
+ { name: "1", type: "string", value: "b" },
134
+ { name: "2", type: "string", value: "c" },
135
+ ],
136
+ },
137
+ };
138
+ expect(formatValue(obj)).toBe('Array(3) [ "a", "b", "c" ]');
139
+ });
140
+
141
+ test("array with overflow", () => {
142
+ const obj: RemoteObject = {
143
+ type: "object",
144
+ subtype: "array",
145
+ className: "Array",
146
+ description: "Array(47)",
147
+ objectId: "1",
148
+ preview: {
149
+ type: "object",
150
+ subtype: "array",
151
+ description: "Array(47)",
152
+ overflow: true,
153
+ properties: [
154
+ { name: "0", type: "string", value: "a" },
155
+ { name: "1", type: "string", value: "b" },
156
+ { name: "2", type: "string", value: "c" },
157
+ ],
158
+ },
159
+ };
160
+ expect(formatValue(obj)).toBe('Array(47) [ "a", "b", "c", ... ]');
161
+ });
162
+ });
163
+
164
+ describe("functions", () => {
165
+ test("named function", () => {
166
+ const obj: RemoteObject = {
167
+ type: "function",
168
+ className: "Function",
169
+ description: "function processResult(job) { ... }",
170
+ };
171
+ expect(formatValue(obj)).toBe("Function processResult(job)");
172
+ });
173
+
174
+ test("async function", () => {
175
+ const obj: RemoteObject = {
176
+ type: "function",
177
+ className: "Function",
178
+ description: "async function fetchData(url, options) { ... }",
179
+ };
180
+ expect(formatValue(obj)).toBe("Function fetchData(url, options)");
181
+ });
182
+
183
+ test("anonymous function", () => {
184
+ const obj: RemoteObject = {
185
+ type: "function",
186
+ className: "Function",
187
+ description: "function() { ... }",
188
+ };
189
+ expect(formatValue(obj)).toBe("Function anonymous()");
190
+ });
191
+ });
192
+
193
+ describe("promises", () => {
194
+ test("pending promise", () => {
195
+ const obj: RemoteObject = {
196
+ type: "object",
197
+ subtype: "promise",
198
+ className: "Promise",
199
+ objectId: "1",
200
+ preview: {
201
+ type: "object",
202
+ subtype: "promise",
203
+ description: "Promise",
204
+ overflow: false,
205
+ properties: [{ name: "[[PromiseState]]", type: "string", value: "pending" }],
206
+ },
207
+ };
208
+ expect(formatValue(obj)).toBe("Promise { <pending> }");
209
+ });
210
+
211
+ test("resolved promise", () => {
212
+ const obj: RemoteObject = {
213
+ type: "object",
214
+ subtype: "promise",
215
+ className: "Promise",
216
+ objectId: "1",
217
+ preview: {
218
+ type: "object",
219
+ subtype: "promise",
220
+ description: "Promise",
221
+ overflow: false,
222
+ properties: [
223
+ { name: "[[PromiseState]]", type: "string", value: "fulfilled" },
224
+ { name: "[[PromiseResult]]", type: "number", value: "42" },
225
+ ],
226
+ },
227
+ };
228
+ expect(formatValue(obj)).toBe("Promise { <resolved: 42> }");
229
+ });
230
+
231
+ test("rejected promise", () => {
232
+ const obj: RemoteObject = {
233
+ type: "object",
234
+ subtype: "promise",
235
+ className: "Promise",
236
+ objectId: "1",
237
+ preview: {
238
+ type: "object",
239
+ subtype: "promise",
240
+ description: "Promise",
241
+ overflow: false,
242
+ properties: [
243
+ { name: "[[PromiseState]]", type: "string", value: "rejected" },
244
+ {
245
+ name: "[[PromiseResult]]",
246
+ type: "string",
247
+ value: "connection refused",
248
+ },
249
+ ],
250
+ },
251
+ };
252
+ expect(formatValue(obj)).toBe('Promise { <rejected: "connection refused"> }');
253
+ });
254
+
255
+ test("promise without preview (pending)", () => {
256
+ const obj: RemoteObject = {
257
+ type: "object",
258
+ subtype: "promise",
259
+ className: "Promise",
260
+ objectId: "1",
261
+ preview: {
262
+ type: "object",
263
+ subtype: "promise",
264
+ description: "Promise",
265
+ overflow: false,
266
+ properties: [],
267
+ },
268
+ };
269
+ expect(formatValue(obj)).toBe("Promise { <pending> }");
270
+ });
271
+ });
272
+
273
+ describe("errors", () => {
274
+ test("error with stack", () => {
275
+ const obj: RemoteObject = {
276
+ type: "object",
277
+ subtype: "error",
278
+ className: "Error",
279
+ description:
280
+ "Error: connection refused\n at connect (src/db.ts:12:5)\n at init (src/app.ts:3:1)",
281
+ };
282
+ expect(formatValue(obj)).toBe("Error: connection refused (at src/db.ts:12:5)");
283
+ });
284
+
285
+ test("error without stack", () => {
286
+ const obj: RemoteObject = {
287
+ type: "object",
288
+ subtype: "error",
289
+ className: "TypeError",
290
+ description: "TypeError: Cannot read properties of null",
291
+ };
292
+ expect(formatValue(obj)).toBe("TypeError: Cannot read properties of null");
293
+ });
294
+ });
295
+
296
+ describe("maps and sets", () => {
297
+ test("map with entries", () => {
298
+ const obj: RemoteObject = {
299
+ type: "object",
300
+ subtype: "map",
301
+ className: "Map",
302
+ description: "Map(3)",
303
+ objectId: "1",
304
+ preview: {
305
+ type: "object",
306
+ subtype: "map",
307
+ description: "Map(3)",
308
+ overflow: false,
309
+ properties: [],
310
+ entries: [
311
+ {
312
+ key: { name: "key", type: "string", value: "a" },
313
+ value: { name: "value", type: "number", value: "1" },
314
+ },
315
+ {
316
+ key: { name: "key", type: "string", value: "b" },
317
+ value: { name: "value", type: "number", value: "2" },
318
+ },
319
+ {
320
+ key: { name: "key", type: "string", value: "c" },
321
+ value: { name: "value", type: "number", value: "3" },
322
+ },
323
+ ],
324
+ },
325
+ };
326
+ expect(formatValue(obj)).toBe('Map(3) { "a" => 1, "b" => 2, "c" => 3 }');
327
+ });
328
+
329
+ test("set with entries", () => {
330
+ const obj: RemoteObject = {
331
+ type: "object",
332
+ subtype: "set",
333
+ className: "Set",
334
+ description: "Set(3)",
335
+ objectId: "1",
336
+ preview: {
337
+ type: "object",
338
+ subtype: "set",
339
+ description: "Set(3)",
340
+ overflow: false,
341
+ properties: [],
342
+ entries: [
343
+ { value: { name: "value", type: "number", value: "1" } },
344
+ { value: { name: "value", type: "number", value: "2" } },
345
+ { value: { name: "value", type: "number", value: "3" } },
346
+ ],
347
+ },
348
+ };
349
+ expect(formatValue(obj)).toBe("Set(3) { 1, 2, 3 }");
350
+ });
351
+ });
352
+
353
+ describe("dates and regexp", () => {
354
+ test("date", () => {
355
+ const obj: RemoteObject = {
356
+ type: "object",
357
+ subtype: "date",
358
+ className: "Date",
359
+ description: "2024-01-15T10:30:00.000Z",
360
+ };
361
+ expect(formatValue(obj)).toBe('Date("2024-01-15T10:30:00.000Z")');
362
+ });
363
+
364
+ test("regexp", () => {
365
+ const obj: RemoteObject = {
366
+ type: "object",
367
+ subtype: "regexp",
368
+ className: "RegExp",
369
+ description: "/pattern/gi",
370
+ };
371
+ expect(formatValue(obj)).toBe("/pattern/gi");
372
+ });
373
+ });
374
+
375
+ describe("buffers", () => {
376
+ test("buffer with preview", () => {
377
+ const obj: RemoteObject = {
378
+ type: "object",
379
+ className: "Buffer",
380
+ description: "Buffer(1024)",
381
+ objectId: "1",
382
+ preview: {
383
+ type: "object",
384
+ description: "Buffer(1024)",
385
+ overflow: true,
386
+ properties: [
387
+ { name: "0", type: "number", value: "72" },
388
+ { name: "1", type: "number", value: "101" },
389
+ { name: "2", type: "number", value: "108" },
390
+ { name: "3", type: "number", value: "108" },
391
+ { name: "4", type: "number", value: "111" },
392
+ ],
393
+ },
394
+ };
395
+ expect(formatValue(obj)).toBe("Buffer(1024) <48 65 6c 6c 6f ...>");
396
+ });
397
+ });
398
+
399
+ describe("truncation", () => {
400
+ test("truncates long string at default 80 chars", () => {
401
+ const longStr = "a".repeat(200);
402
+ const result = formatValue({ type: "string", value: longStr });
403
+ expect(result.length).toBeLessThanOrEqual(80);
404
+ expect(result.endsWith("...")).toBe(true);
405
+ });
406
+
407
+ test("truncates at custom maxLen", () => {
408
+ const obj: RemoteObject = {
409
+ type: "string",
410
+ value: "this is a moderately long string value",
411
+ };
412
+ const result = formatValue(obj, 30);
413
+ expect(result.length).toBeLessThanOrEqual(30);
414
+ expect(result.endsWith("...")).toBe(true);
415
+ });
416
+
417
+ test("does not truncate short values", () => {
418
+ const result = formatValue({ type: "string", value: "hi" });
419
+ expect(result).toBe('"hi"');
420
+ });
421
+
422
+ test("truncates long object preview", () => {
423
+ const obj: RemoteObject = {
424
+ type: "object",
425
+ className: "VeryLongClassName",
426
+ objectId: "1",
427
+ preview: {
428
+ type: "object",
429
+ description: "VeryLongClassName",
430
+ overflow: true,
431
+ properties: [
432
+ { name: "longPropertyName1", type: "string", value: "longValue1" },
433
+ { name: "longPropertyName2", type: "string", value: "longValue2" },
434
+ { name: "longPropertyName3", type: "string", value: "longValue3" },
435
+ ],
436
+ },
437
+ };
438
+ const result = formatValue(obj, 60);
439
+ expect(result.length).toBeLessThanOrEqual(60);
440
+ });
441
+ });
442
+ });
443
+
444
+ // =============================================================================
445
+ // formatSource
446
+ // =============================================================================
447
+
448
+ describe("formatSource", () => {
449
+ test("formats source with current line marker", () => {
450
+ const lines: SourceLine[] = [
451
+ { lineNumber: 45, content: " async processJob(job: Job) {" },
452
+ { lineNumber: 46, content: " const lock = await this.acquireLock(job.id);" },
453
+ { lineNumber: 47, content: " if (!lock) return;", isCurrent: true },
454
+ { lineNumber: 48, content: " const result = await this.execute(job);" },
455
+ { lineNumber: 49, content: " await this.markComplete(job.id);" },
456
+ ];
457
+ const result = formatSource(lines);
458
+ const resultLines = result.split("\n");
459
+ expect(resultLines).toHaveLength(5);
460
+ // Current line should have arrow marker
461
+ expect(resultLines[2]).toContain("\u2192");
462
+ expect(resultLines[2]).toContain("47");
463
+ expect(resultLines[2]).toContain("if (!lock) return;");
464
+ // Other lines should not have markers
465
+ expect(resultLines[0]).not.toContain("\u2192");
466
+ expect(resultLines[0]).not.toContain("\u25CF");
467
+ });
468
+
469
+ test("formats source with breakpoint marker", () => {
470
+ const lines: SourceLine[] = [
471
+ { lineNumber: 47, content: " if (!lock) return;" },
472
+ {
473
+ lineNumber: 48,
474
+ content: " const result = await this.execute(job);",
475
+ hasBreakpoint: true,
476
+ },
477
+ ];
478
+ const result = formatSource(lines);
479
+ const resultLines = result.split("\n");
480
+ expect(resultLines[1]).toContain("\u25CF");
481
+ expect(resultLines[1]).toContain("48");
482
+ });
483
+
484
+ test("current line takes priority combined with breakpoint", () => {
485
+ const lines: SourceLine[] = [
486
+ { lineNumber: 47, content: " if (!lock) return;", isCurrent: true, hasBreakpoint: true },
487
+ ];
488
+ const result = formatSource(lines);
489
+ expect(result).toContain("\u2192");
490
+ expect(result).toContain("\u25CF");
491
+ });
492
+
493
+ test("right-aligns line numbers", () => {
494
+ const lines: SourceLine[] = [
495
+ { lineNumber: 8, content: " line8" },
496
+ { lineNumber: 9, content: " line9" },
497
+ { lineNumber: 10, content: " line10" },
498
+ ];
499
+ const result = formatSource(lines);
500
+ const resultLines = result.split("\n");
501
+ // Line 8 should be padded: " 8" and line 10 should be "10"
502
+ expect(resultLines[0]).toContain(" 8\u2502");
503
+ expect(resultLines[2]).toContain("10\u2502");
504
+ });
505
+
506
+ test("empty lines array returns empty string", () => {
507
+ expect(formatSource([])).toBe("");
508
+ });
509
+ });
510
+
511
+ // =============================================================================
512
+ // formatStack
513
+ // =============================================================================
514
+
515
+ describe("formatStack", () => {
516
+ test("formats basic stack frames", () => {
517
+ const frames: StackFrame[] = [
518
+ { ref: "@f0", functionName: "processJob", file: "src/queue/processor.ts", line: 47 },
519
+ { ref: "@f1", functionName: "poll", file: "src/queue/processor.ts", line: 71 },
520
+ ];
521
+ const result = formatStack(frames);
522
+ const resultLines = result.split("\n");
523
+ expect(resultLines).toHaveLength(2);
524
+ expect(resultLines[0]).toContain("@f0");
525
+ expect(resultLines[0]).toContain("processJob");
526
+ expect(resultLines[0]).toContain("src/queue/processor.ts:47");
527
+ expect(resultLines[1]).toContain("@f1");
528
+ expect(resultLines[1]).toContain("poll");
529
+ });
530
+
531
+ test("includes async gap markers", () => {
532
+ const frames: StackFrame[] = [
533
+ { ref: "@f0", functionName: "processJob", file: "src/queue/processor.ts", line: 47 },
534
+ { ref: "@f1", functionName: "poll", file: "src/queue/processor.ts", line: 71 },
535
+ {
536
+ ref: "@f2",
537
+ functionName: "start",
538
+ file: "src/queue/processor.ts",
539
+ line: 12,
540
+ isAsync: true,
541
+ },
542
+ ];
543
+ const result = formatStack(frames);
544
+ expect(result).toContain("\u250A async gap");
545
+ const resultLines = result.split("\n");
546
+ // async gap should appear before the async frame
547
+ const gapIdx = resultLines.findIndex((l) => l.includes("async gap"));
548
+ const f2Idx = resultLines.findIndex((l) => l.includes("@f2"));
549
+ expect(gapIdx).toBeLessThan(f2Idx);
550
+ });
551
+
552
+ test("collapses blackboxed frames", () => {
553
+ const frames: StackFrame[] = [
554
+ { ref: "@f0", functionName: "processJob", file: "src/queue/processor.ts", line: 47 },
555
+ {
556
+ ref: "@f1",
557
+ functionName: "internal1",
558
+ file: "node:internal/timers:100",
559
+ line: 100,
560
+ isBlackboxed: true,
561
+ },
562
+ {
563
+ ref: "@f2",
564
+ functionName: "internal2",
565
+ file: "node:internal/timers:200",
566
+ line: 200,
567
+ isBlackboxed: true,
568
+ },
569
+ {
570
+ ref: "@f3",
571
+ functionName: "internal3",
572
+ file: "node:internal/timers:300",
573
+ line: 300,
574
+ isBlackboxed: true,
575
+ },
576
+ { ref: "@f4", functionName: "start", file: "src/queue/processor.ts", line: 12 },
577
+ ];
578
+ const result = formatStack(frames);
579
+ expect(result).toContain("3 framework frames (blackboxed)");
580
+ // Should not contain individual blackboxed frame refs
581
+ expect(result).not.toContain("@f1");
582
+ expect(result).not.toContain("@f2");
583
+ expect(result).not.toContain("@f3");
584
+ // Should contain non-blackboxed frames
585
+ expect(result).toContain("@f0");
586
+ expect(result).toContain("@f4");
587
+ });
588
+
589
+ test("collapses single blackboxed frame", () => {
590
+ const frames: StackFrame[] = [
591
+ { ref: "@f0", functionName: "main", file: "src/app.ts", line: 1 },
592
+ {
593
+ ref: "@f1",
594
+ functionName: "internal",
595
+ file: "node:timers:10",
596
+ line: 10,
597
+ isBlackboxed: true,
598
+ },
599
+ { ref: "@f2", functionName: "handler", file: "src/app.ts", line: 20 },
600
+ ];
601
+ const result = formatStack(frames);
602
+ expect(result).toContain("1 framework frame (blackboxed)");
603
+ });
604
+
605
+ test("aligns columns", () => {
606
+ const frames: StackFrame[] = [
607
+ { ref: "@f0", functionName: "a", file: "x.ts", line: 1 },
608
+ { ref: "@f1", functionName: "longName", file: "y.ts", line: 2 },
609
+ ];
610
+ const result = formatStack(frames);
611
+ const resultLines = result.split("\n");
612
+ // Both lines should have consistent spacing
613
+ expect(resultLines[0]).toMatch(/^@f0\s+a\s+x\.ts:1$/);
614
+ expect(resultLines[1]).toMatch(/^@f1\s+longName\s+y\.ts:2$/);
615
+ });
616
+
617
+ test("includes column when present", () => {
618
+ const frames: StackFrame[] = [
619
+ { ref: "@f0", functionName: "fn", file: "src/app.ts", line: 10, column: 5 },
620
+ ];
621
+ const result = formatStack(frames);
622
+ expect(result).toContain("src/app.ts:10:5");
623
+ });
624
+ });
625
+
626
+ // =============================================================================
627
+ // formatError
628
+ // =============================================================================
629
+
630
+ describe("formatError", () => {
631
+ test("formats simple error message", () => {
632
+ const result = formatError("Something went wrong");
633
+ expect(result).toBe("\u2717 Something went wrong");
634
+ });
635
+
636
+ test("formats error with details", () => {
637
+ const result = formatError("Cannot set breakpoint at src/queue/processor.ts:46", [
638
+ "Nearest valid lines: 45, 47",
639
+ ]);
640
+ const lines = result.split("\n");
641
+ expect(lines[0]).toBe("\u2717 Cannot set breakpoint at src/queue/processor.ts:46");
642
+ expect(lines[1]).toBe(" Nearest valid lines: 45, 47");
643
+ });
644
+
645
+ test("formats error with suggestion", () => {
646
+ const result = formatError(
647
+ "Cannot set breakpoint at src/queue/processor.ts:46 \u2014 no breakable location",
648
+ ["Nearest valid lines: 45, 47"],
649
+ "ndbg break src/queue/processor.ts:47",
650
+ );
651
+ const lines = result.split("\n");
652
+ expect(lines).toHaveLength(3);
653
+ expect(lines[0]).toContain("\u2717");
654
+ expect(lines[1]).toBe(" Nearest valid lines: 45, 47");
655
+ expect(lines[2]).toBe(" \u2192 Try: ndbg break src/queue/processor.ts:47");
656
+ });
657
+
658
+ test("formats error without details but with suggestion", () => {
659
+ const result = formatError("No active session", undefined, "ndbg launch --brk node app.js");
660
+ const lines = result.split("\n");
661
+ expect(lines).toHaveLength(2);
662
+ expect(lines[0]).toBe("\u2717 No active session");
663
+ expect(lines[1]).toBe(" \u2192 Try: ndbg launch --brk node app.js");
664
+ });
665
+ });
666
+
667
+ // =============================================================================
668
+ // formatVariables
669
+ // =============================================================================
670
+
671
+ describe("formatVariables", () => {
672
+ test("formats variables with aligned columns", () => {
673
+ const vars: Variable[] = [
674
+ { ref: "@v1", name: "job", value: 'Job { id: "test-123", type: "email", retries: 2 }' },
675
+ { ref: "@v2", name: "lock", value: "false" },
676
+ {
677
+ ref: "@v3",
678
+ name: "this",
679
+ value: 'QueueProcessor { workerId: "worker-a", redis: [Redis] }',
680
+ },
681
+ ];
682
+ const result = formatVariables(vars);
683
+ const lines = result.split("\n");
684
+ expect(lines).toHaveLength(3);
685
+ // All refs should be aligned (same column width)
686
+ expect(lines[0]).toContain("@v1");
687
+ expect(lines[1]).toContain("@v2");
688
+ expect(lines[2]).toContain("@v3");
689
+ // Names should be aligned
690
+ expect(lines[0]).toContain("job ");
691
+ expect(lines[1]).toContain("lock");
692
+ expect(lines[2]).toContain("this");
693
+ });
694
+
695
+ test("single variable", () => {
696
+ const vars: Variable[] = [{ ref: "@v1", name: "x", value: "42" }];
697
+ const result = formatVariables(vars);
698
+ expect(result).toBe("@v1 x 42");
699
+ });
700
+
701
+ test("empty variables returns empty string", () => {
702
+ expect(formatVariables([])).toBe("");
703
+ });
704
+
705
+ test("aligns refs of different lengths", () => {
706
+ const vars: Variable[] = [
707
+ { ref: "@v1", name: "a", value: "1" },
708
+ { ref: "@v10", name: "b", value: "2" },
709
+ ];
710
+ const result = formatVariables(vars);
711
+ const lines = result.split("\n");
712
+ // @v1 should be padded to match @v10 length
713
+ expect(lines[0]).toMatch(/^@v1\s+a\s+1$/);
714
+ expect(lines[1]).toMatch(/^@v10\s+b\s+2$/);
715
+ });
716
+ });