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,422 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { CdpClient } from "../../src/cdp/client.ts";
3
+
4
+ /**
5
+ * Creates a mock WebSocket and a CdpClient wired to it.
6
+ * We use CdpClient.connect() with a real Bun WebSocket server
7
+ * that echoes nothing, then drive messages via client.handleMessage().
8
+ */
9
+
10
+ let server: ReturnType<typeof Bun.serve> | null = null;
11
+ let client: CdpClient | null = null;
12
+
13
+ async function createTestClient(): Promise<CdpClient> {
14
+ server = Bun.serve({
15
+ port: 0,
16
+ fetch(req, srv) {
17
+ if (srv.upgrade(req, { data: undefined })) {
18
+ return undefined;
19
+ }
20
+ return new Response("Not found", { status: 404 });
21
+ },
22
+ websocket: {
23
+ message() {
24
+ // No-op: test server does not auto-respond
25
+ },
26
+ },
27
+ });
28
+ const port = server.port;
29
+ const c = await CdpClient.connect(`ws://127.0.0.1:${port}`);
30
+ return c;
31
+ }
32
+
33
+ beforeEach(async () => {
34
+ client = await createTestClient();
35
+ });
36
+
37
+ afterEach(() => {
38
+ if (client?.connected) {
39
+ client.disconnect();
40
+ }
41
+ client = null;
42
+ if (server) {
43
+ server.stop(true);
44
+ server = null;
45
+ }
46
+ });
47
+
48
+ describe("CdpClient", () => {
49
+ describe("request ID auto-incrementing", () => {
50
+ test("first request has id=1, second has id=2", async () => {
51
+ const c = client!;
52
+ const sentMessages: string[] = [];
53
+ const originalSend = c["ws"].send.bind(c["ws"]);
54
+ c["ws"].send = (data: unknown) => {
55
+ sentMessages.push(typeof data === "string" ? data : "");
56
+ return originalSend(data as string);
57
+ };
58
+
59
+ // Fire send but don't await (server won't respond)
60
+ const p1 = c.send("Debugger.enable");
61
+ const p2 = c.send("Runtime.enable");
62
+
63
+ // Simulate responses
64
+ c.handleMessage(JSON.stringify({ id: 1, result: {} }));
65
+ c.handleMessage(JSON.stringify({ id: 2, result: {} }));
66
+
67
+ await p1;
68
+ await p2;
69
+
70
+ const msg1 = JSON.parse(sentMessages[0]!);
71
+ const msg2 = JSON.parse(sentMessages[1]!);
72
+
73
+ expect(msg1.id).toBe(1);
74
+ expect(msg1.method).toBe("Debugger.enable");
75
+ expect(msg2.id).toBe(2);
76
+ expect(msg2.method).toBe("Runtime.enable");
77
+ });
78
+
79
+ test("IDs continue incrementing across multiple sends", async () => {
80
+ const c = client!;
81
+
82
+ const p1 = c.send("Debugger.enable");
83
+ const p2 = c.send("Runtime.enable");
84
+ const p3 = c.send("Profiler.enable");
85
+
86
+ c.handleMessage(JSON.stringify({ id: 1, result: {} }));
87
+ c.handleMessage(JSON.stringify({ id: 2, result: {} }));
88
+ c.handleMessage(JSON.stringify({ id: 3, result: {} }));
89
+
90
+ await Promise.all([p1, p2, p3]);
91
+
92
+ // Next request should be id=4
93
+ const p4 = c.send("HeapProfiler.enable");
94
+ c.handleMessage(JSON.stringify({ id: 4, result: {} }));
95
+ await p4;
96
+
97
+ expect(c["nextId"]).toBe(5);
98
+ });
99
+ });
100
+
101
+ describe("event subscription and dispatching", () => {
102
+ test("on() registers handler and receives events", async () => {
103
+ const c = client!;
104
+ const received: unknown[] = [];
105
+
106
+ c.on("Debugger.paused", (params) => {
107
+ received.push(params);
108
+ });
109
+
110
+ c.handleMessage(
111
+ JSON.stringify({
112
+ method: "Debugger.paused",
113
+ params: { reason: "breakpoint", callFrames: [] },
114
+ }),
115
+ );
116
+
117
+ expect(received).toHaveLength(1);
118
+ expect(received[0]).toEqual({ reason: "breakpoint", callFrames: [] });
119
+ });
120
+
121
+ test("multiple handlers for the same event", () => {
122
+ const c = client!;
123
+ const results1: unknown[] = [];
124
+ const results2: unknown[] = [];
125
+
126
+ c.on("Runtime.consoleAPICalled", (params) => results1.push(params));
127
+ c.on("Runtime.consoleAPICalled", (params) => results2.push(params));
128
+
129
+ c.handleMessage(
130
+ JSON.stringify({
131
+ method: "Runtime.consoleAPICalled",
132
+ params: { type: "log" },
133
+ }),
134
+ );
135
+
136
+ expect(results1).toHaveLength(1);
137
+ expect(results2).toHaveLength(1);
138
+ });
139
+
140
+ test("off() removes a specific handler", () => {
141
+ const c = client!;
142
+ const results: unknown[] = [];
143
+ const handler = (params: unknown) => results.push(params);
144
+
145
+ c.on("Debugger.paused", handler);
146
+ c.off("Debugger.paused", handler);
147
+
148
+ c.handleMessage(
149
+ JSON.stringify({
150
+ method: "Debugger.paused",
151
+ params: { reason: "step" },
152
+ }),
153
+ );
154
+
155
+ expect(results).toHaveLength(0);
156
+ });
157
+
158
+ test("off() only removes the specified handler", () => {
159
+ const c = client!;
160
+ const results1: unknown[] = [];
161
+ const results2: unknown[] = [];
162
+ const handler1 = (params: unknown) => results1.push(params);
163
+ const handler2 = (params: unknown) => results2.push(params);
164
+
165
+ c.on("Debugger.paused", handler1);
166
+ c.on("Debugger.paused", handler2);
167
+ c.off("Debugger.paused", handler1);
168
+
169
+ c.handleMessage(
170
+ JSON.stringify({
171
+ method: "Debugger.paused",
172
+ params: { reason: "step" },
173
+ }),
174
+ );
175
+
176
+ expect(results1).toHaveLength(0);
177
+ expect(results2).toHaveLength(1);
178
+ });
179
+
180
+ test("events with no listeners are silently ignored", () => {
181
+ const c = client!;
182
+ // Should not throw
183
+ c.handleMessage(
184
+ JSON.stringify({
185
+ method: "Debugger.resumed",
186
+ params: {},
187
+ }),
188
+ );
189
+ });
190
+ });
191
+
192
+ describe("response handling", () => {
193
+ test("send() resolves with result on success", async () => {
194
+ const c = client!;
195
+ const promise = c.send("Runtime.evaluate", { expression: "1+1" });
196
+
197
+ c.handleMessage(JSON.stringify({ id: 1, result: { result: { type: "number", value: 2 } } }));
198
+
199
+ const result = await promise;
200
+ expect(result).toEqual({ result: { type: "number", value: 2 } });
201
+ });
202
+
203
+ test("send() rejects on CDP error response", async () => {
204
+ const c = client!;
205
+ const promise = c.send("Runtime.evaluate", { expression: "invalid(" });
206
+
207
+ c.handleMessage(
208
+ JSON.stringify({
209
+ id: 1,
210
+ error: { code: -32000, message: "Syntax error" },
211
+ }),
212
+ );
213
+
214
+ await expect(promise).rejects.toThrow("CDP error (-32000): Syntax error");
215
+ });
216
+
217
+ test("send() rejects when client is disconnected", async () => {
218
+ const c = client!;
219
+ c.disconnect();
220
+
221
+ await expect(c.send("Debugger.enable")).rejects.toThrow("CDP client is not connected");
222
+ });
223
+
224
+ test("send() includes params when provided", async () => {
225
+ const c = client!;
226
+ const sentMessages: string[] = [];
227
+ const originalSend = c["ws"].send.bind(c["ws"]);
228
+ c["ws"].send = (data: unknown) => {
229
+ sentMessages.push(typeof data === "string" ? data : "");
230
+ return originalSend(data as string);
231
+ };
232
+
233
+ const promise = c.send("Runtime.evaluate", { expression: "42" });
234
+ c.handleMessage(JSON.stringify({ id: 1, result: { result: { value: 42 } } }));
235
+ await promise;
236
+
237
+ const sent = JSON.parse(sentMessages[0]!);
238
+ expect(sent.params).toEqual({ expression: "42" });
239
+ });
240
+ });
241
+
242
+ describe("timeout behavior", () => {
243
+ test("send() rejects after timeout", async () => {
244
+ const c = client!;
245
+ // Patch DEFAULT_TIMEOUT_MS is not accessible, so we test by directly
246
+ // checking that the pending map contains a timer.
247
+ // Instead, we'll use a custom approach: send and never respond.
248
+
249
+ // We can't easily override the module-level const, so we test the
250
+ // mechanism by verifying a pending request exists and manually triggering
251
+ // the timeout behavior.
252
+ const promise = c.send("Debugger.enable");
253
+
254
+ // Verify request is pending
255
+ expect(c["pending"].size).toBe(1);
256
+
257
+ // Simulate what the timeout does: reject and remove from pending
258
+ const pending = c["pending"].get(1)!;
259
+ clearTimeout(pending.timer);
260
+ c["pending"].delete(1);
261
+ pending.reject(new Error("CDP request timed out: Debugger.enable (id=1)"));
262
+
263
+ await expect(promise).rejects.toThrow("CDP request timed out: Debugger.enable (id=1)");
264
+ });
265
+
266
+ test("pending requests have timers set", () => {
267
+ const c = client!;
268
+
269
+ // Send multiple requests (don't await)
270
+ c.send("Debugger.enable").catch(() => {});
271
+ c.send("Runtime.enable").catch(() => {});
272
+
273
+ expect(c["pending"].size).toBe(2);
274
+
275
+ const pending1 = c["pending"].get(1)!;
276
+ const pending2 = c["pending"].get(2)!;
277
+
278
+ // Timers should be set (non-null/undefined)
279
+ expect(pending1.timer).toBeDefined();
280
+ expect(pending2.timer).toBeDefined();
281
+
282
+ // Clean up — resolve them to avoid unhandled rejections
283
+ c.handleMessage(JSON.stringify({ id: 1, result: {} }));
284
+ c.handleMessage(JSON.stringify({ id: 2, result: {} }));
285
+ });
286
+ });
287
+
288
+ describe("disconnect and cleanup", () => {
289
+ test("disconnect() rejects all pending requests", async () => {
290
+ const c = client!;
291
+
292
+ const errors: string[] = [];
293
+ const p1 = c.send("Debugger.enable").catch((e: Error) => {
294
+ errors.push(e.message);
295
+ });
296
+ const p2 = c.send("Runtime.enable").catch((e: Error) => {
297
+ errors.push(e.message);
298
+ });
299
+
300
+ c.disconnect();
301
+
302
+ await p1;
303
+ await p2;
304
+
305
+ expect(errors).toHaveLength(2);
306
+ expect(errors[0]).toBe("CDP client disconnected");
307
+ expect(errors[1]).toBe("CDP client disconnected");
308
+ });
309
+
310
+ test("disconnect() sets connected to false", () => {
311
+ const c = client!;
312
+ expect(c.connected).toBe(true);
313
+ c.disconnect();
314
+ expect(c.connected).toBe(false);
315
+ });
316
+
317
+ test("disconnect() clears all event listeners", () => {
318
+ const c = client!;
319
+
320
+ c.on("Debugger.paused", () => {});
321
+ c.on("Runtime.consoleAPICalled", () => {});
322
+ expect(c["listeners"].size).toBe(2);
323
+
324
+ c.disconnect();
325
+ expect(c["listeners"].size).toBe(0);
326
+ });
327
+
328
+ test("disconnect() is idempotent", () => {
329
+ const c = client!;
330
+ c.disconnect();
331
+ // Should not throw
332
+ c.disconnect();
333
+ expect(c.connected).toBe(false);
334
+ });
335
+
336
+ test("pending map is empty after disconnect", () => {
337
+ const c = client!;
338
+
339
+ c.send("Debugger.enable").catch(() => {});
340
+ c.send("Runtime.enable").catch(() => {});
341
+ expect(c["pending"].size).toBe(2);
342
+
343
+ c.disconnect();
344
+ expect(c["pending"].size).toBe(0);
345
+ });
346
+ });
347
+
348
+ describe("connect", () => {
349
+ test("connected is true after successful connect", () => {
350
+ expect(client!.connected).toBe(true);
351
+ });
352
+
353
+ test("connect rejects on invalid URL", async () => {
354
+ await expect(CdpClient.connect("ws://127.0.0.1:1")).rejects.toThrow();
355
+ });
356
+ });
357
+
358
+ describe("enableDomains", () => {
359
+ test("sends enable for all four domains", async () => {
360
+ const c = client!;
361
+ const sentMethods: string[] = [];
362
+ const originalSend = c["ws"].send.bind(c["ws"]);
363
+ c["ws"].send = (data: unknown) => {
364
+ if (typeof data === "string") {
365
+ const parsed = JSON.parse(data);
366
+ sentMethods.push(parsed.method);
367
+ }
368
+ return originalSend(data as string);
369
+ };
370
+
371
+ const promise = c.enableDomains();
372
+
373
+ // Respond to all four
374
+ c.handleMessage(JSON.stringify({ id: 1, result: {} }));
375
+ c.handleMessage(JSON.stringify({ id: 2, result: {} }));
376
+ c.handleMessage(JSON.stringify({ id: 3, result: {} }));
377
+ c.handleMessage(JSON.stringify({ id: 4, result: {} }));
378
+
379
+ await promise;
380
+
381
+ expect(sentMethods).toContain("Debugger.enable");
382
+ expect(sentMethods).toContain("Runtime.enable");
383
+ expect(sentMethods).toContain("Profiler.enable");
384
+ expect(sentMethods).toContain("HeapProfiler.enable");
385
+ });
386
+ });
387
+
388
+ describe("runIfWaitingForDebugger", () => {
389
+ test("sends Runtime.runIfWaitingForDebugger", async () => {
390
+ const c = client!;
391
+ const sentMethods: string[] = [];
392
+ const originalSend = c["ws"].send.bind(c["ws"]);
393
+ c["ws"].send = (data: unknown) => {
394
+ if (typeof data === "string") {
395
+ const parsed = JSON.parse(data);
396
+ sentMethods.push(parsed.method);
397
+ }
398
+ return originalSend(data as string);
399
+ };
400
+
401
+ const promise = c.runIfWaitingForDebugger();
402
+ c.handleMessage(JSON.stringify({ id: 1, result: {} }));
403
+ await promise;
404
+
405
+ expect(sentMethods).toEqual(["Runtime.runIfWaitingForDebugger"]);
406
+ });
407
+ });
408
+
409
+ describe("malformed messages", () => {
410
+ test("invalid JSON is silently ignored", () => {
411
+ const c = client!;
412
+ // Should not throw
413
+ c.handleMessage("not json {{{");
414
+ });
415
+
416
+ test("response for unknown id is silently ignored", () => {
417
+ const c = client!;
418
+ // Should not throw
419
+ c.handleMessage(JSON.stringify({ id: 999, result: {} }));
420
+ });
421
+ });
422
+ });
@@ -0,0 +1,286 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, rmSync, writeFileSync } from "node:fs";
3
+ import { DaemonClient } from "../../src/daemon/client.ts";
4
+ import {
5
+ ensureSocketDir,
6
+ getLockPath,
7
+ getSocketDir,
8
+ getSocketPath,
9
+ } from "../../src/daemon/paths.ts";
10
+ import { DaemonServer } from "../../src/daemon/server.ts";
11
+
12
+ // Use a short test directory to stay within macOS 104-char Unix socket path limit
13
+ const TEST_SOCKET_DIR = `/tmp/ndbg-t${process.pid}`;
14
+
15
+ let originalEnv: string | undefined;
16
+ let testCounter = 0;
17
+
18
+ function testSession(label: string): string {
19
+ testCounter++;
20
+ return `${label}-${testCounter}`;
21
+ }
22
+
23
+ beforeEach(() => {
24
+ originalEnv = process.env.XDG_RUNTIME_DIR;
25
+ // Override socket dir for tests
26
+ process.env.XDG_RUNTIME_DIR = TEST_SOCKET_DIR;
27
+ ensureSocketDir();
28
+ });
29
+
30
+ afterEach(() => {
31
+ if (originalEnv !== undefined) {
32
+ process.env.XDG_RUNTIME_DIR = originalEnv;
33
+ } else {
34
+ delete process.env.XDG_RUNTIME_DIR;
35
+ }
36
+ // Cleanup test directory
37
+ if (existsSync(TEST_SOCKET_DIR)) {
38
+ rmSync(TEST_SOCKET_DIR, { recursive: true, force: true });
39
+ }
40
+ });
41
+
42
+ describe("DaemonServer", () => {
43
+ test("starts and accepts connections", async () => {
44
+ const session = testSession("start");
45
+ const server = new DaemonServer(session, { idleTimeout: 60 });
46
+
47
+ server.onRequest(async (req) => {
48
+ if (req.cmd === "ping") {
49
+ return { ok: true, data: "pong" };
50
+ }
51
+ return { ok: false, error: `Unknown: ${req.cmd}` };
52
+ });
53
+
54
+ await server.start();
55
+
56
+ try {
57
+ const socketPath = getSocketPath(session);
58
+ expect(existsSync(socketPath)).toBe(true);
59
+
60
+ const lockPath = getLockPath(session);
61
+ expect(existsSync(lockPath)).toBe(true);
62
+ } finally {
63
+ await server.stop();
64
+ }
65
+ });
66
+
67
+ test("cleans up on stop", async () => {
68
+ const session = testSession("clean");
69
+ const server = new DaemonServer(session, { idleTimeout: 60 });
70
+
71
+ server.onRequest(async () => ({ ok: true }));
72
+ await server.start();
73
+
74
+ const socketPath = getSocketPath(session);
75
+ const lockPath = getLockPath(session);
76
+
77
+ expect(existsSync(socketPath)).toBe(true);
78
+ expect(existsSync(lockPath)).toBe(true);
79
+
80
+ await server.stop();
81
+
82
+ expect(existsSync(socketPath)).toBe(false);
83
+ expect(existsSync(lockPath)).toBe(false);
84
+ });
85
+ });
86
+
87
+ describe("DaemonClient", () => {
88
+ test("sends request and receives response", async () => {
89
+ const session = testSession("cli");
90
+ const server = new DaemonServer(session, { idleTimeout: 60 });
91
+
92
+ server.onRequest(async (req) => {
93
+ if (req.cmd === "ping") {
94
+ return { ok: true, data: "pong" };
95
+ }
96
+ return { ok: false, error: `Unknown: ${req.cmd}` };
97
+ });
98
+
99
+ await server.start();
100
+
101
+ try {
102
+ const client = new DaemonClient(session);
103
+ const response = await client.request("ping");
104
+
105
+ expect(response.ok).toBe(true);
106
+ if (response.ok) {
107
+ expect(response.data).toBe("pong");
108
+ }
109
+ } finally {
110
+ await server.stop();
111
+ }
112
+ });
113
+
114
+ test("sends request with args", async () => {
115
+ const session = testSession("args");
116
+ const server = new DaemonServer(session, { idleTimeout: 60 });
117
+
118
+ server.onRequest(async (req) => {
119
+ if (req.cmd === "eval") {
120
+ return { ok: true, data: req.args };
121
+ }
122
+ return { ok: true, data: "no-args" };
123
+ });
124
+
125
+ await server.start();
126
+
127
+ try {
128
+ const client = new DaemonClient(session);
129
+ const response = await client.request("eval", { expression: "1+1" });
130
+
131
+ expect(response.ok).toBe(true);
132
+ if (response.ok) {
133
+ expect(response.data).toEqual({ expression: "1+1" });
134
+ }
135
+ } finally {
136
+ await server.stop();
137
+ }
138
+ });
139
+
140
+ test("handles unknown command", async () => {
141
+ const session = testSession("unk");
142
+ const server = new DaemonServer(session, { idleTimeout: 60 });
143
+
144
+ server.onRequest(async () => {
145
+ return { ok: true };
146
+ });
147
+
148
+ await server.start();
149
+
150
+ try {
151
+ const client = new DaemonClient(session);
152
+ // Unknown commands are rejected by schema validation in the server
153
+ const response = await client.request("nonexistent");
154
+
155
+ expect(response.ok).toBe(false);
156
+ if (!response.ok) {
157
+ expect(response.error).toContain("Unknown command");
158
+ expect(response.suggestion).toBeDefined();
159
+ }
160
+ } finally {
161
+ await server.stop();
162
+ }
163
+ });
164
+ });
165
+
166
+ describe("idle timeout", () => {
167
+ test("auto-terminates after idle timeout", async () => {
168
+ const session = testSession("idle");
169
+ // Use a very short timeout (0.5 seconds)
170
+ const server = new DaemonServer(session, { idleTimeout: 0.5 });
171
+
172
+ server.onRequest(async () => ({ ok: true, data: "pong" }));
173
+ await server.start();
174
+
175
+ const socketPath = getSocketPath(session);
176
+ expect(existsSync(socketPath)).toBe(true);
177
+
178
+ // Wait for idle timeout to kick in
179
+ await Bun.sleep(1000);
180
+
181
+ expect(existsSync(socketPath)).toBe(false);
182
+ });
183
+
184
+ test("resets idle timer on request", async () => {
185
+ const session = testSession("irst");
186
+ const server = new DaemonServer(session, { idleTimeout: 1 });
187
+
188
+ server.onRequest(async () => ({ ok: true, data: "pong" }));
189
+ await server.start();
190
+
191
+ const socketPath = getSocketPath(session);
192
+
193
+ try {
194
+ // Send a request before timeout
195
+ await Bun.sleep(500);
196
+ const client = new DaemonClient(session);
197
+ await client.request("ping");
198
+
199
+ // Socket should still exist after original timeout would have fired
200
+ await Bun.sleep(600);
201
+ expect(existsSync(socketPath)).toBe(true);
202
+ } finally {
203
+ await server.stop();
204
+ }
205
+ });
206
+ });
207
+
208
+ describe("lock file", () => {
209
+ test("prevents duplicate daemons", async () => {
210
+ const session = testSession("lock");
211
+ const server1 = new DaemonServer(session, { idleTimeout: 60 });
212
+ server1.onRequest(async () => ({ ok: true }));
213
+ await server1.start();
214
+
215
+ try {
216
+ const server2 = new DaemonServer(session, { idleTimeout: 60 });
217
+ server2.onRequest(async () => ({ ok: true }));
218
+
219
+ expect(server2.start()).rejects.toThrow(/already running/);
220
+ } finally {
221
+ await server1.stop();
222
+ }
223
+ });
224
+
225
+ test("cleans up stale lock file", async () => {
226
+ const session = testSession("stale");
227
+ const lockPath = getLockPath(session);
228
+
229
+ // Write a lock file with a non-existent PID
230
+ writeFileSync(lockPath, "999999");
231
+
232
+ const server = new DaemonServer(session, { idleTimeout: 60 });
233
+ server.onRequest(async () => ({ ok: true }));
234
+
235
+ // Should not throw because the PID doesn't exist
236
+ await server.start();
237
+ await server.stop();
238
+ });
239
+ });
240
+
241
+ describe("dead socket detection", () => {
242
+ test("detects dead socket (connection refused)", async () => {
243
+ const session = testSession("dead");
244
+ const socketPath = getSocketPath(session);
245
+
246
+ // Create a fake socket file (not actually listening)
247
+ writeFileSync(socketPath, "");
248
+
249
+ const client = new DaemonClient(session);
250
+ expect(client.request("ping")).rejects.toThrow();
251
+ });
252
+ });
253
+
254
+ describe("listSessions", () => {
255
+ test("returns active sessions", async () => {
256
+ const session1 = testSession("la");
257
+ const session2 = testSession("lb");
258
+
259
+ const server1 = new DaemonServer(session1, { idleTimeout: 60 });
260
+ server1.onRequest(async () => ({ ok: true }));
261
+ await server1.start();
262
+
263
+ const server2 = new DaemonServer(session2, { idleTimeout: 60 });
264
+ server2.onRequest(async () => ({ ok: true }));
265
+ await server2.start();
266
+
267
+ try {
268
+ const sessions = DaemonClient.listSessions();
269
+ expect(sessions).toContain(session1);
270
+ expect(sessions).toContain(session2);
271
+ } finally {
272
+ await server1.stop();
273
+ await server2.stop();
274
+ }
275
+ });
276
+
277
+ test("returns empty array when no sessions", () => {
278
+ // Clean up socket dir to ensure it's empty
279
+ const dir = getSocketDir();
280
+ if (existsSync(dir)) {
281
+ rmSync(dir, { recursive: true, force: true });
282
+ }
283
+ const sessions = DaemonClient.listSessions();
284
+ expect(sessions).toEqual([]);
285
+ });
286
+ });