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.
- package/.bin/ndbg +0 -0
- package/.claude/settings.local.json +21 -0
- package/.claude/skills/ndbg-debugger/ndbg-debugger/SKILL.md +116 -0
- package/.claude/skills/ndbg-debugger/ndbg-debugger/references/commands.md +173 -0
- package/CLAUDE.md +43 -0
- package/PROGRESS.md +261 -0
- package/README.md +67 -0
- package/biome.json +41 -0
- package/ndbg-spec.md +958 -0
- package/package.json +30 -0
- package/src/cdp/client.ts +198 -0
- package/src/cdp/types.ts +16 -0
- package/src/cli/parser.ts +287 -0
- package/src/cli/registry.ts +7 -0
- package/src/cli/types.ts +24 -0
- package/src/commands/attach.ts +47 -0
- package/src/commands/blackbox-ls.ts +38 -0
- package/src/commands/blackbox-rm.ts +57 -0
- package/src/commands/blackbox.ts +48 -0
- package/src/commands/break-ls.ts +57 -0
- package/src/commands/break-rm.ts +40 -0
- package/src/commands/break-toggle.ts +42 -0
- package/src/commands/break.ts +145 -0
- package/src/commands/breakable.ts +69 -0
- package/src/commands/catch.ts +38 -0
- package/src/commands/console.ts +61 -0
- package/src/commands/continue.ts +46 -0
- package/src/commands/eval.ts +70 -0
- package/src/commands/exceptions.ts +61 -0
- package/src/commands/hotpatch.ts +67 -0
- package/src/commands/launch.ts +69 -0
- package/src/commands/logpoint.ts +78 -0
- package/src/commands/pause.ts +46 -0
- package/src/commands/props.ts +77 -0
- package/src/commands/restart-frame.ts +36 -0
- package/src/commands/run-to.ts +70 -0
- package/src/commands/scripts.ts +57 -0
- package/src/commands/search.ts +73 -0
- package/src/commands/sessions.ts +71 -0
- package/src/commands/set-return.ts +49 -0
- package/src/commands/set.ts +61 -0
- package/src/commands/source.ts +59 -0
- package/src/commands/sourcemap.ts +66 -0
- package/src/commands/stack.ts +64 -0
- package/src/commands/state.ts +124 -0
- package/src/commands/status.ts +57 -0
- package/src/commands/step.ts +50 -0
- package/src/commands/stop.ts +27 -0
- package/src/commands/vars.ts +71 -0
- package/src/daemon/client.ts +147 -0
- package/src/daemon/entry.ts +242 -0
- package/src/daemon/paths.ts +26 -0
- package/src/daemon/server.ts +185 -0
- package/src/daemon/session-blackbox.ts +41 -0
- package/src/daemon/session-breakpoints.ts +492 -0
- package/src/daemon/session-execution.ts +121 -0
- package/src/daemon/session-inspection.ts +701 -0
- package/src/daemon/session-mutation.ts +197 -0
- package/src/daemon/session-state.ts +258 -0
- package/src/daemon/session.ts +938 -0
- package/src/daemon/spawn.ts +53 -0
- package/src/formatter/errors.ts +15 -0
- package/src/formatter/source.ts +74 -0
- package/src/formatter/stack.ts +70 -0
- package/src/formatter/values.ts +269 -0
- package/src/formatter/variables.ts +20 -0
- package/src/main.ts +45 -0
- package/src/protocol/messages.ts +316 -0
- package/src/refs/ref-table.ts +120 -0
- package/src/refs/resolver.ts +24 -0
- package/src/sourcemap/resolver.ts +318 -0
- package/tests/fixtures/async-app.js +34 -0
- package/tests/fixtures/console-app.js +12 -0
- package/tests/fixtures/error-app.js +28 -0
- package/tests/fixtures/exception-app.js +6 -0
- package/tests/fixtures/inspect-app.js +10 -0
- package/tests/fixtures/mutation-app.js +9 -0
- package/tests/fixtures/simple-app.js +50 -0
- package/tests/fixtures/step-app.js +13 -0
- package/tests/fixtures/ts-app/src/app.ts +21 -0
- package/tests/fixtures/ts-app/tsconfig.json +14 -0
- package/tests/integration/blackbox.test.ts +135 -0
- package/tests/integration/break-extras.test.ts +241 -0
- package/tests/integration/breakpoint.test.ts +217 -0
- package/tests/integration/console.test.ts +275 -0
- package/tests/integration/execution.test.ts +247 -0
- package/tests/integration/inspection.test.ts +311 -0
- package/tests/integration/mutation.test.ts +178 -0
- package/tests/integration/session.test.ts +223 -0
- package/tests/integration/source.test.ts +209 -0
- package/tests/integration/sourcemap.test.ts +214 -0
- package/tests/integration/state.test.ts +208 -0
- package/tests/unit/cdp-client.test.ts +422 -0
- package/tests/unit/daemon.test.ts +286 -0
- package/tests/unit/formatter.test.ts +716 -0
- package/tests/unit/parser.test.ts +105 -0
- package/tests/unit/refs.test.ts +383 -0
- package/tests/unit/sourcemap.test.ts +236 -0
- 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
|
+
});
|