agent-dbg 0.1.8 → 0.2.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.
@@ -6,8 +6,10 @@ import type { RemoteObject } from "../formatter/values.ts";
6
6
  import { formatValue } from "../formatter/values.ts";
7
7
  import { RefTable } from "../refs/ref-table.ts";
8
8
  import { SourceMapResolver } from "../sourcemap/resolver.ts";
9
+ import { createAdapter } from "./adapters/index.ts";
9
10
  import { DaemonLogger } from "./logger.ts";
10
11
  import { ensureSocketDir, getDaemonLogPath, getLogPath } from "./paths.ts";
12
+ import type { RuntimeAdapter } from "./runtime-adapter.ts";
11
13
  import {
12
14
  addBlackbox as addBlackboxImpl,
13
15
  listBlackbox as listBlackboxImpl,
@@ -134,7 +136,9 @@ export interface SessionStatus {
134
136
  scriptCount: number;
135
137
  }
136
138
 
137
- const INSPECTOR_URL_REGEX = /Debugger listening on (wss?:\/\/\S+)/;
139
+ // Node.js: "Debugger listening on ws://..."
140
+ // Bun: " ws://localhost:PORT/ID" (on its own indented line)
141
+ const INSPECTOR_URL_REGEX = /(?:Debugger listening on\s+)?(wss?:\/\/\S+)/;
138
142
  const INSPECTOR_TIMEOUT_MS = 5_000;
139
143
 
140
144
  export class DebugSession {
@@ -157,6 +161,7 @@ export class DebugSession {
157
161
  new Map();
158
162
  launchCommand: string[] | null = null;
159
163
  launchOptions: { brk?: boolean; port?: number } | null = null;
164
+ adapter: RuntimeAdapter;
160
165
  cdpLogger: CdpLogger;
161
166
  daemonLogger: DaemonLogger;
162
167
 
@@ -165,6 +170,13 @@ export class DebugSession {
165
170
  ensureSocketDir();
166
171
  this.cdpLogger = new CdpLogger(getLogPath(session));
167
172
  this.daemonLogger = options?.daemonLogger ?? new DaemonLogger(getDaemonLogPath(session));
173
+ // Default to NodeAdapter; overridden in launch() when command is known
174
+ this.adapter = createAdapter(["node"]);
175
+ }
176
+
177
+ /** Detected runtime name — delegates to the adapter */
178
+ get runtime(): "node" | "bun" | "unknown" {
179
+ return this.adapter.name;
168
180
  }
169
181
 
170
182
  // ── Session lifecycle ─────────────────────────────────────────────
@@ -183,15 +195,19 @@ export class DebugSession {
183
195
 
184
196
  this.launchCommand = command;
185
197
  this.launchOptions = options;
198
+ this.adapter = createAdapter(command);
186
199
 
187
200
  const brk = options.brk ?? true;
188
201
  const port = options.port ?? 0;
202
+
203
+ // Both Bun and Node.js support --inspect-brk (Bun also has --inspect-wait
204
+ // but --inspect-brk works better for our pause strategy)
189
205
  const inspectFlag = brk ? `--inspect-brk=${port}` : `--inspect=${port}`;
190
206
 
191
207
  // Build the args: inject inspect flag after the runtime (first element)
192
- const runtime = command[0] as string;
208
+ const runtimeBin = command[0] as string;
193
209
  const rest = command.slice(1);
194
- const spawnArgs = [runtime, inspectFlag, ...rest];
210
+ const spawnArgs = [runtimeBin, inspectFlag, ...rest];
195
211
 
196
212
  const proc = Bun.spawn(spawnArgs, {
197
213
  stdin: "ignore",
@@ -236,7 +252,23 @@ export class DebugSession {
236
252
  };
237
253
 
238
254
  if (this.pauseInfo) {
239
- result.pauseInfo = this.pauseInfo;
255
+ // Source-map translate for display
256
+ const translated = { ...this.pauseInfo };
257
+ if (translated.scriptId && translated.line !== undefined) {
258
+ const resolved = this.resolveOriginalLocation(
259
+ translated.scriptId,
260
+ translated.line + 1, // pauseInfo.line is 0-based
261
+ translated.column ?? 0,
262
+ );
263
+ if (resolved) {
264
+ translated.url = resolved.url;
265
+ translated.line = resolved.line - 1;
266
+ if (resolved.column !== undefined) {
267
+ translated.column = resolved.column - 1;
268
+ }
269
+ }
270
+ }
271
+ result.pauseInfo = translated;
240
272
  }
241
273
 
242
274
  return result;
@@ -652,21 +684,16 @@ export class DebugSession {
652
684
  const settle = () => {
653
685
  if (settled) return;
654
686
  settled = true;
655
- clearTimeout(timer);
656
687
  clearInterval(pollTimer);
657
- this.cdp?.off("Debugger.paused", handler);
658
688
  this.onProcessExit = null;
659
689
  resolve();
660
690
  };
661
691
 
662
- const timer = setTimeout(() => {
663
- // Don't reject — the process is still running, just not paused yet
664
- settle();
665
- }, timeoutMs);
666
-
667
- const handler = () => {
668
- settle();
669
- };
692
+ // Use waitFor for the event subscription + timeout
693
+ this.cdp
694
+ ?.waitFor("Debugger.paused", { timeoutMs })
695
+ .then(() => settle())
696
+ .catch(() => settle()); // timeout — don't reject, just settle
670
697
 
671
698
  // Poll as a fallback in case the event/callback is missed
672
699
  // (e.g., process exits and monitorProcessExit runs before
@@ -677,7 +704,6 @@ export class DebugSession {
677
704
  }
678
705
  }, 100);
679
706
 
680
- this.cdp?.on("Debugger.paused", handler);
681
707
  // Also resolve if the process exits during execution
682
708
  this.onProcessExit = settle;
683
709
  });
@@ -724,32 +750,7 @@ export class DebugSession {
724
750
  // ── Private helpers ───────────────────────────────────────────────
725
751
 
726
752
  private async waitForBrkPause(): Promise<void> {
727
- // Give the Debugger.paused event a moment to arrive (older Node.js)
728
- if (!this.isPaused()) {
729
- await Bun.sleep(100);
730
- }
731
- // On Node.js v24+, --inspect-brk does not emit Debugger.paused when the
732
- // debugger connects after the process is already paused. We request an
733
- // explicit pause and then signal Runtime.runIfWaitingForDebugger so the
734
- // process starts execution and immediately hits our pause request.
735
- if (!this.isPaused() && this.cdp) {
736
- await this.cdp.send("Debugger.pause");
737
- await this.cdp.send("Runtime.runIfWaitingForDebugger");
738
- const deadline = Date.now() + 2_000;
739
- while (!this.isPaused() && Date.now() < deadline) {
740
- await Bun.sleep(50);
741
- }
742
- }
743
- // On Node.js v24+, the initial --inspect-brk pause lands in an internal
744
- // bootstrap module (node:internal/...) rather than the user script.
745
- // Resume past internal pauses until we reach user code.
746
- let skips = 0;
747
- while (this.isPaused() && this.cdp && this.pauseInfo?.url?.startsWith("node:") && skips < 5) {
748
- skips++;
749
- const waiter = this.createPauseWaiter(5_000);
750
- await this.cdp.send("Debugger.resume");
751
- await waiter;
752
- }
753
+ return this.adapter.waitForBrkPause(this);
753
754
  }
754
755
 
755
756
  private async connectCdp(wsUrl: string): Promise<void> {
@@ -761,13 +762,14 @@ export class DebugSession {
761
762
  // Set up event handlers before enabling domains so we don't miss any events
762
763
  this.setupCdpEventHandlers(cdp);
763
764
 
765
+ // Runtime-specific pre-enable hook (e.g. Bun needs Inspector.enable first)
766
+ await this.adapter.preEnable(cdp);
767
+
764
768
  await cdp.enableDomains();
765
769
 
766
770
  // Re-apply blackbox patterns if any exist
767
771
  if (this.blackboxPatterns.length > 0) {
768
- await cdp.send("Debugger.setBlackboxPatterns", {
769
- patterns: this.blackboxPatterns,
770
- });
772
+ await this.adapter.setBlackboxPatterns(cdp, this.blackboxPatterns);
771
773
  }
772
774
 
773
775
  // Update state to running if not already paused
@@ -66,8 +66,12 @@ export function formatSource(lines: SourceLine[]): string {
66
66
 
67
67
  // Add column indicator under current line
68
68
  if (line.isCurrent && trimmed.caretOffset !== undefined && trimmed.caretOffset >= 0) {
69
- const gutterWidth = numWidth + 4; // marker(2) + space(1) + numWidth + │(1)
70
- result.push(`${" ".repeat(gutterWidth)}${" ".repeat(trimmed.caretOffset)}^`);
69
+ const gutter = " ".repeat(numWidth + 4); // marker(2) + space(1) + numWidth + │(1)
70
+ // Preserve tabs from source so ^ aligns in terminal
71
+ const indent = trimmed.text
72
+ .slice(0, trimmed.caretOffset)
73
+ .replace(/[^\t]/g, " ");
74
+ result.push(`${gutter}${indent}^`);
71
75
  }
72
76
  }
73
77
  return result.join("\n");
@@ -0,0 +1,12 @@
1
+ // Simple test fixture for Bun debugging
2
+ debugger; // Should trigger a pause
3
+
4
+ function greet(name) {
5
+ const message = `Hello, ${name}!`;
6
+ console.log(message);
7
+ return message;
8
+ }
9
+
10
+ const x = 42;
11
+ const greeting = greet("Bun");
12
+ console.log("x =", x);
@@ -0,0 +1,135 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { DebugSession } from "../../src/daemon/session.ts";
3
+
4
+ function waitForState(
5
+ session: DebugSession,
6
+ target: "paused" | "running" | "idle",
7
+ timeoutMs = 5_000,
8
+ ): Promise<void> {
9
+ return new Promise((resolve, reject) => {
10
+ if (session.state === target) return resolve();
11
+ const deadline = Date.now() + timeoutMs;
12
+ const timer = setInterval(() => {
13
+ if (session.state === target) {
14
+ clearInterval(timer);
15
+ resolve();
16
+ } else if (Date.now() > deadline) {
17
+ clearInterval(timer);
18
+ reject(new Error(`Timed out waiting for state=${target}, current=${session.state}`));
19
+ }
20
+ }, 50);
21
+ });
22
+ }
23
+
24
+ describe("Bun debugging", () => {
25
+ let session: DebugSession;
26
+
27
+ afterEach(async () => {
28
+ if (session) {
29
+ await session.stop().catch(() => {});
30
+ }
31
+ });
32
+
33
+ test("launches and pauses with --inspect-brk", async () => {
34
+ session = new DebugSession("bun-test-launch");
35
+ const result = await session.launch(["bun", "tests/fixtures/simple-app.js"], { brk: true });
36
+
37
+ expect(result.paused).toBe(true);
38
+ expect(result.pid).toBeGreaterThan(0);
39
+ expect(result.wsUrl).toContain("ws://");
40
+ expect(session.state).toBe("paused");
41
+ expect(session.runtime).toBe("bun");
42
+ });
43
+
44
+ test("detects bun runtime", async () => {
45
+ session = new DebugSession("bun-test-detect");
46
+ await session.launch(["bun", "tests/fixtures/simple-app.js"], { brk: true });
47
+ expect(session.runtime).toBe("bun");
48
+ });
49
+
50
+ test("state includes source-mapped location", async () => {
51
+ session = new DebugSession("bun-test-state");
52
+ await session.launch(["bun", "tests/fixtures/simple-app.js"], { brk: true });
53
+ await Bun.sleep(200); // Wait for source maps to load
54
+
55
+ const state = await session.buildState({ code: true, stack: true });
56
+ expect(state.status).toBe("paused");
57
+ // Source-mapped location should point to original source
58
+ expect(state.location?.url).toContain("simple-app.js");
59
+ expect(state.location?.line).toBe(38); // const counter = new Counter(10)
60
+ expect(state.source?.lines?.some((l) => l.current)).toBe(true);
61
+ });
62
+
63
+ test("eval works in paused context", async () => {
64
+ session = new DebugSession("bun-test-eval");
65
+ await session.launch(["bun", "tests/fixtures/simple-app.js"], { brk: true });
66
+
67
+ const r1 = await session.eval("1+1");
68
+ expect(r1.value).toBe("2");
69
+
70
+ const r2 = await session.eval("typeof Bun");
71
+ expect(r2.value).toBe('"object"');
72
+ });
73
+
74
+ test("breakpoint by scriptId hits", async () => {
75
+ session = new DebugSession("bun-test-bp");
76
+ await session.launch(["bun", "tests/fixtures/simple-app.js"], { brk: true });
77
+ await Bun.sleep(200); // Wait for source maps
78
+
79
+ // Set breakpoint inside greet function (original line 6)
80
+ const bp = await session.setBreakpoint("tests/fixtures/simple-app.js", 6);
81
+ expect(bp.ref).toMatch(/^BP#/);
82
+
83
+ // Continue — should hit breakpoint
84
+ await session.continue();
85
+ await waitForState(session, "paused");
86
+
87
+ expect(session.state).toBe("paused");
88
+ expect(session.pauseInfo?.reason).toBe("Breakpoint");
89
+ });
90
+
91
+ test("step over works", async () => {
92
+ session = new DebugSession("bun-test-step");
93
+ await session.launch(["bun", "tests/fixtures/simple-app.js"], { brk: true });
94
+
95
+ const initialLine = session.pauseInfo?.line;
96
+ await session.step("over");
97
+ expect(session.state).toBe("paused");
98
+ // Should have advanced at least one line
99
+ expect(session.pauseInfo?.line).not.toBe(initialLine);
100
+ });
101
+
102
+ test("step into enters function", async () => {
103
+ session = new DebugSession("bun-test-step-into");
104
+ await session.launch(["bun", "tests/fixtures/simple-app.js"], { brk: true });
105
+
106
+ // Step over to reach line 30 (greet call) then step into
107
+ await session.step("over"); // past const counter
108
+ await session.step("into"); // into greet("World")
109
+
110
+ expect(session.state).toBe("paused");
111
+ // Should be inside greet function
112
+ const stack = session.getStack({});
113
+ expect(stack[0]?.functionName).toBe("greet");
114
+ });
115
+
116
+ test("scripts list includes user script", async () => {
117
+ session = new DebugSession("bun-test-scripts");
118
+ await session.launch(["bun", "tests/fixtures/simple-app.js"], { brk: true });
119
+
120
+ const scripts = session.getScripts();
121
+ const userScript = scripts.find((s) => s.url.includes("simple-app.js"));
122
+ expect(userScript).toBeDefined();
123
+ });
124
+
125
+ test("continue resumes execution", async () => {
126
+ session = new DebugSession("bun-test-continue");
127
+ await session.launch(["bun", "tests/fixtures/simple-app.js"], { brk: true });
128
+
129
+ // Set a breakpoint so continue stops again (otherwise continue() waits for next pause indefinitely)
130
+ await session.setBreakpoint("tests/fixtures/simple-app.js", 6);
131
+ await session.continue();
132
+ expect(session.state).toBe("paused");
133
+ expect(session.pauseInfo?.reason).toBe("Breakpoint");
134
+ });
135
+ });
@@ -356,7 +356,7 @@ describe("CdpClient", () => {
356
356
  });
357
357
 
358
358
  describe("enableDomains", () => {
359
- test("sends enable for all four domains", async () => {
359
+ test("sends enable for required and optional domains", async () => {
360
360
  const c = client!;
361
361
  const sentMethods: string[] = [];
362
362
  const originalSend = c["ws"].send.bind(c["ws"]);
@@ -364,24 +364,52 @@ describe("CdpClient", () => {
364
364
  if (typeof data === "string") {
365
365
  const parsed = JSON.parse(data);
366
366
  sentMethods.push(parsed.method);
367
+ // Auto-respond with success
368
+ setTimeout(() => {
369
+ c.handleMessage(JSON.stringify({ id: parsed.id, result: {} }));
370
+ }, 0);
367
371
  }
368
372
  return originalSend(data as string);
369
373
  };
370
374
 
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;
375
+ await c.enableDomains();
380
376
 
381
377
  expect(sentMethods).toContain("Debugger.enable");
382
378
  expect(sentMethods).toContain("Runtime.enable");
383
379
  expect(sentMethods).toContain("Profiler.enable");
384
380
  expect(sentMethods).toContain("HeapProfiler.enable");
381
+ expect(c.enabledDomains.has("Debugger")).toBe(true);
382
+ expect(c.enabledDomains.has("Runtime")).toBe(true);
383
+ expect(c.enabledDomains.has("Profiler")).toBe(true);
384
+ expect(c.enabledDomains.has("HeapProfiler")).toBe(true);
385
+ });
386
+
387
+ test("tracks which optional domains succeed", async () => {
388
+ const c = client!;
389
+ const originalSend = c["ws"].send.bind(c["ws"]);
390
+ c["ws"].send = (data: unknown) => {
391
+ if (typeof data === "string") {
392
+ const parsed = JSON.parse(data);
393
+ setTimeout(() => {
394
+ if (parsed.method === "Profiler.enable" || parsed.method === "HeapProfiler.enable") {
395
+ // Simulate Bun: optional domains fail
396
+ c.handleMessage(
397
+ JSON.stringify({ id: parsed.id, error: { code: -32601, message: "not found" } }),
398
+ );
399
+ } else {
400
+ c.handleMessage(JSON.stringify({ id: parsed.id, result: {} }));
401
+ }
402
+ }, 0);
403
+ }
404
+ return originalSend(data as string);
405
+ };
406
+
407
+ await c.enableDomains();
408
+
409
+ expect(c.enabledDomains.has("Debugger")).toBe(true);
410
+ expect(c.enabledDomains.has("Runtime")).toBe(true);
411
+ expect(c.enabledDomains.has("Profiler")).toBe(false);
412
+ expect(c.enabledDomains.has("HeapProfiler")).toBe(false);
385
413
  });
386
414
  });
387
415
 
@@ -406,6 +434,102 @@ describe("CdpClient", () => {
406
434
  });
407
435
  });
408
436
 
437
+ describe("waitFor", () => {
438
+ test("resolves with event params when event fires", async () => {
439
+ const c = client!;
440
+ const promise = c.waitFor("Debugger.paused", { timeoutMs: 1_000 });
441
+
442
+ c.handleMessage(
443
+ JSON.stringify({
444
+ method: "Debugger.paused",
445
+ params: { reason: "other", callFrames: [] },
446
+ }),
447
+ );
448
+
449
+ const result = await promise;
450
+ expect(result).toEqual({ reason: "other", callFrames: [] });
451
+ });
452
+
453
+ test("rejects on timeout", async () => {
454
+ const c = client!;
455
+ const promise = c.waitFor("Debugger.paused", { timeoutMs: 50 });
456
+
457
+ await expect(promise).rejects.toThrow("waitFor timed out: Debugger.paused");
458
+ });
459
+
460
+ test("filter skips non-matching events", async () => {
461
+ const c = client!;
462
+ const promise = c.waitFor("Debugger.paused", {
463
+ timeoutMs: 1_000,
464
+ filter: (p) => p.reason === "step",
465
+ });
466
+
467
+ // This one should be skipped
468
+ c.handleMessage(
469
+ JSON.stringify({
470
+ method: "Debugger.paused",
471
+ params: { reason: "other", callFrames: [] },
472
+ }),
473
+ );
474
+
475
+ // This one should match
476
+ c.handleMessage(
477
+ JSON.stringify({
478
+ method: "Debugger.paused",
479
+ params: { reason: "step", callFrames: [] },
480
+ }),
481
+ );
482
+
483
+ const result = await promise;
484
+ expect(result).toEqual({ reason: "step", callFrames: [] });
485
+ });
486
+
487
+ test("cleans up listener after resolving", async () => {
488
+ const c = client!;
489
+ const promise = c.waitFor("Debugger.paused", { timeoutMs: 1_000 });
490
+
491
+ c.handleMessage(
492
+ JSON.stringify({
493
+ method: "Debugger.paused",
494
+ params: { reason: "other", callFrames: [] },
495
+ }),
496
+ );
497
+
498
+ await promise;
499
+
500
+ // Listener should be cleaned up
501
+ expect(c["listeners"].get("Debugger.paused")?.size ?? 0).toBe(0);
502
+ });
503
+
504
+ test("cleans up listener after timeout", async () => {
505
+ const c = client!;
506
+ const promise = c.waitFor("Debugger.paused", { timeoutMs: 50 });
507
+
508
+ try {
509
+ await promise;
510
+ } catch {
511
+ // expected
512
+ }
513
+
514
+ expect(c["listeners"].get("Debugger.paused")?.size ?? 0).toBe(0);
515
+ });
516
+
517
+ test("works with untyped events", async () => {
518
+ const c = client!;
519
+ const promise = c.waitFor("Inspector.initialized", { timeoutMs: 1_000 });
520
+
521
+ c.handleMessage(
522
+ JSON.stringify({
523
+ method: "Inspector.initialized",
524
+ params: {},
525
+ }),
526
+ );
527
+
528
+ const result = await promise;
529
+ expect(result).toEqual({});
530
+ });
531
+ });
532
+
409
533
  describe("malformed messages", () => {
410
534
  test("invalid JSON is silently ignored", () => {
411
535
  const c = client!;
@@ -0,0 +1,165 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { CdpClient } from "../../src/cdp/client.ts";
3
+ import { JscClient } from "../../src/cdp/jsc-client.ts";
4
+
5
+ let server: ReturnType<typeof Bun.serve> | null = null;
6
+ let cdp: CdpClient | null = null;
7
+ let jsc: JscClient | null = null;
8
+
9
+ async function createTestClient(): Promise<{ cdp: CdpClient; jsc: JscClient }> {
10
+ server = Bun.serve({
11
+ port: 0,
12
+ fetch(req, srv) {
13
+ if (srv.upgrade(req, { data: undefined })) {
14
+ return undefined;
15
+ }
16
+ return new Response("Not found", { status: 404 });
17
+ },
18
+ websocket: {
19
+ message() {},
20
+ },
21
+ });
22
+ const port = server.port;
23
+ const c = await CdpClient.connect(`ws://127.0.0.1:${port}`);
24
+ return { cdp: c, jsc: new JscClient(c) };
25
+ }
26
+
27
+ beforeEach(async () => {
28
+ const clients = await createTestClient();
29
+ cdp = clients.cdp;
30
+ jsc = clients.jsc;
31
+ });
32
+
33
+ afterEach(() => {
34
+ if (cdp?.connected) {
35
+ cdp.disconnect();
36
+ }
37
+ cdp = null;
38
+ jsc = null;
39
+ if (server) {
40
+ server.stop(true);
41
+ server = null;
42
+ }
43
+ });
44
+
45
+ describe("JscClient", () => {
46
+ test("send routes through cdp.sendRaw", async () => {
47
+ const j = jsc!;
48
+ const c = cdp!;
49
+
50
+ const sentMethods: string[] = [];
51
+ const originalSend = c["ws"].send.bind(c["ws"]);
52
+ c["ws"].send = (data: unknown) => {
53
+ if (typeof data === "string") {
54
+ const parsed = JSON.parse(data);
55
+ sentMethods.push(parsed.method);
56
+ }
57
+ return originalSend(data as string);
58
+ };
59
+
60
+ const promise = j.send("Inspector.enable");
61
+ c.handleMessage(JSON.stringify({ id: 1, result: {} }));
62
+ await promise;
63
+
64
+ expect(sentMethods).toEqual(["Inspector.enable"]);
65
+ });
66
+
67
+ test("send with params passes them through", async () => {
68
+ const j = jsc!;
69
+ const c = cdp!;
70
+
71
+ const sentParams: unknown[] = [];
72
+ const originalSend = c["ws"].send.bind(c["ws"]);
73
+ c["ws"].send = (data: unknown) => {
74
+ if (typeof data === "string") {
75
+ const parsed = JSON.parse(data);
76
+ sentParams.push(parsed.params);
77
+ }
78
+ return originalSend(data as string);
79
+ };
80
+
81
+ const promise = j.send("Debugger.setBreakpointsActive", { active: true });
82
+ c.handleMessage(JSON.stringify({ id: 1, result: {} }));
83
+ await promise;
84
+
85
+ expect(sentParams[0]).toEqual({ active: true });
86
+ });
87
+
88
+ test("send returns typed result", async () => {
89
+ const j = jsc!;
90
+ const c = cdp!;
91
+
92
+ const promise = j.send("Debugger.setBreakpointByUrl", {
93
+ urlRegex: "test\\.js$",
94
+ lineNumber: 1,
95
+ });
96
+
97
+ c.handleMessage(
98
+ JSON.stringify({
99
+ id: 1,
100
+ result: {
101
+ breakpointId: "bp-1",
102
+ locations: [{ scriptId: "1", lineNumber: 1 }],
103
+ },
104
+ }),
105
+ );
106
+
107
+ const result = await promise;
108
+ expect(result.breakpointId).toBe("bp-1");
109
+ expect(result.locations).toHaveLength(1);
110
+ });
111
+
112
+ test("exposes cdp property for shared commands", () => {
113
+ const j = jsc!;
114
+ expect(j.cdp).toBe(cdp!);
115
+ });
116
+
117
+ test("connected delegates to cdp", () => {
118
+ const j = jsc!;
119
+ expect(j.connected).toBe(true);
120
+ j.disconnect();
121
+ expect(j.connected).toBe(false);
122
+ });
123
+
124
+ test("on/off delegate to cdp", () => {
125
+ const j = jsc!;
126
+ const c = cdp!;
127
+
128
+ const results: unknown[] = [];
129
+ const handler = (params: unknown) => results.push(params);
130
+
131
+ j.on("Debugger.paused", handler);
132
+ c.handleMessage(
133
+ JSON.stringify({
134
+ method: "Debugger.paused",
135
+ params: { reason: "breakpoint" },
136
+ }),
137
+ );
138
+ expect(results).toHaveLength(1);
139
+
140
+ j.off("Debugger.paused", handler);
141
+ c.handleMessage(
142
+ JSON.stringify({
143
+ method: "Debugger.paused",
144
+ params: { reason: "step" },
145
+ }),
146
+ );
147
+ expect(results).toHaveLength(1); // no new event
148
+ });
149
+
150
+ test("waitFor delegates to cdp", async () => {
151
+ const j = jsc!;
152
+ const c = cdp!;
153
+
154
+ const promise = j.waitFor("Debugger.paused", { timeoutMs: 1_000 });
155
+ c.handleMessage(
156
+ JSON.stringify({
157
+ method: "Debugger.paused",
158
+ params: { reason: "Breakpoint", callFrames: [] },
159
+ }),
160
+ );
161
+
162
+ const result = await promise;
163
+ expect(result).toEqual({ reason: "Breakpoint", callFrames: [] });
164
+ });
165
+ });