pi-browser-harness 0.12.0 → 0.14.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 (102) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +132 -0
  3. package/package.json +15 -7
  4. package/skills/pi-browser-harness/SKILL.md +89 -2
  5. package/src/cdp/commands.ts +205 -14
  6. package/src/cdp/console-buffer.ts +37 -23
  7. package/src/cdp/discovery.ts +57 -25
  8. package/src/cdp/events.ts +9 -3
  9. package/src/cdp/network-buffer.ts +38 -32
  10. package/src/cdp/ownership.ts +22 -4
  11. package/src/cdp/record-store.ts +2 -2
  12. package/src/cdp/session.ts +358 -216
  13. package/src/cdp/target-factory.ts +34 -15
  14. package/src/cdp/types.ts +17 -1
  15. package/src/client.ts +127 -54
  16. package/src/daemon/bridge.ts +110 -349
  17. package/src/daemon/connection.ts +323 -0
  18. package/src/daemon/index.ts +77 -48
  19. package/src/daemon/lines.ts +40 -0
  20. package/src/daemon/main.ts +12 -0
  21. package/src/daemon/protocol.ts +55 -25
  22. package/src/daemon/server.ts +100 -63
  23. package/src/daemon/socket.ts +10 -0
  24. package/src/daemon/spawn.ts +21 -22
  25. package/src/daemon/transport.ts +80 -49
  26. package/src/domains/ax-tree.ts +27 -8
  27. package/src/domains/cdp-call.ts +14 -7
  28. package/src/domains/click.ts +2 -2
  29. package/src/domains/concurrency.ts +50 -0
  30. package/src/domains/console.ts +20 -64
  31. package/src/domains/drag.ts +26 -28
  32. package/src/domains/element-call.ts +102 -68
  33. package/src/domains/files.ts +13 -9
  34. package/src/domains/form-batch.ts +13 -10
  35. package/src/domains/form.ts +78 -107
  36. package/src/domains/headers.ts +2 -2
  37. package/src/domains/history.ts +4 -4
  38. package/src/domains/io.ts +35 -0
  39. package/src/domains/isolated-tab.ts +6 -12
  40. package/src/domains/js.ts +67 -46
  41. package/src/domains/keyboard.ts +14 -11
  42. package/src/domains/navigate.ts +102 -66
  43. package/src/domains/network.ts +55 -95
  44. package/src/domains/page/fill.page.ts +139 -0
  45. package/src/domains/page/form.page.ts +61 -0
  46. package/src/domains/page/inject.ts +37 -0
  47. package/src/domains/page/keyboard.page.ts +16 -0
  48. package/src/domains/page/live-value.page.ts +10 -0
  49. package/src/domains/page/read-page.page.ts +51 -0
  50. package/src/domains/page/recipe.page.ts +82 -0
  51. package/src/domains/page/serp.page.ts +42 -0
  52. package/src/domains/page.ts +18 -51
  53. package/src/domains/poll.ts +42 -0
  54. package/src/domains/readpage/read-page.ts +48 -53
  55. package/src/domains/ref-resolve.ts +43 -29
  56. package/src/domains/ref-signature.ts +11 -0
  57. package/src/domains/render.ts +12 -23
  58. package/src/domains/screenshot-capture.ts +4 -2
  59. package/src/domains/screenshot.ts +13 -10
  60. package/src/domains/scroll.ts +4 -4
  61. package/src/domains/search/google-serp.ts +78 -8
  62. package/src/domains/search/redirect-resolver.ts +41 -0
  63. package/src/domains/search/web-search.ts +103 -40
  64. package/src/domains/snapshot.ts +81 -78
  65. package/src/domains/tab-resolve.ts +36 -0
  66. package/src/domains/tabs.ts +18 -69
  67. package/src/domains/target.ts +1 -1
  68. package/src/index.ts +101 -33
  69. package/src/profile/bind.ts +45 -14
  70. package/src/profile/browser-process.ts +50 -12
  71. package/src/profile/command.ts +20 -12
  72. package/src/profile/launch.ts +13 -9
  73. package/src/profile/list.ts +9 -13
  74. package/src/profile/store.ts +2 -1
  75. package/src/prompt.ts +34 -8
  76. package/src/recipes/command.ts +96 -0
  77. package/src/recipes/compile.ts +198 -0
  78. package/src/recipes/find.ts +49 -0
  79. package/src/recipes/journal.ts +125 -0
  80. package/src/recipes/recipe.ts +215 -0
  81. package/src/recipes/recordable.ts +359 -0
  82. package/src/recipes/replay.ts +354 -0
  83. package/src/recipes/run-tool.ts +132 -0
  84. package/src/recipes/save-tool.ts +247 -0
  85. package/src/recipes/store.ts +103 -0
  86. package/src/recipes/trust.ts +172 -0
  87. package/src/registry.ts +69 -4
  88. package/src/schemas/parse.ts +2 -2
  89. package/src/setup.ts +28 -19
  90. package/src/state.ts +2 -1
  91. package/src/util/debug.ts +7 -1
  92. package/src/util/guards.ts +3 -0
  93. package/src/util/js-source.ts +28 -0
  94. package/src/util/mutex.ts +1 -1
  95. package/src/util/paths.ts +3 -2
  96. package/src/util/result.ts +8 -0
  97. package/src/util/sharp-shim.ts +18 -11
  98. package/src/util/sleep.ts +18 -0
  99. package/src/util/tool.ts +185 -114
  100. package/src/util/truncate.ts +1 -1
  101. package/tsconfig.json +2 -1
  102. package/src/domains/fill-engine.ts +0 -80
@@ -1,3 +1,17 @@
1
+ // The typed CDP command table — the extension point for everything the harness sends to Chrome.
2
+ //
3
+ // To add a command:
4
+ // 1. Look the method up in the Chrome DevTools Protocol viewer (https://chromedevtools.github.io/devtools-protocol/).
5
+ // 2. Add a `"Domain.method": cmd(ParamsSchema, ResultSchema)` entry below, in its domain's block.
6
+ // - Mark optional protocol fields with `Type.Optional(...)` — never with `Type.Union([..., Type.Undefined()])`:
7
+ // the project compiles with exactOptionalPropertyTypes, so callers must omit a field rather than pass undefined.
8
+ // - Use `Type.Union([Type.Literal(...), ...])` for enumerated strings so a typo fails to compile.
9
+ // - Result schemas are `additionalProperties: true` where Chrome may add fields; param schemas are closed.
10
+ // 3. `ParamsOf<"Domain.method">` / `ResultOf<"Domain.method">` now exist, and `session.call("Domain.method", ...)`
11
+ // requires params exactly when the schema has a required key (see `CallArgs` in session.ts).
12
+ // 4. Results are validated at runtime by `decodeResult`; a payload that does not match is an `invalid_response` error.
13
+ // Events live in ./events.ts and follow the same pattern.
14
+
1
15
  import { Type, type Static, type TSchema } from "typebox";
2
16
  import { Compile } from "typebox/compile";
3
17
  import { type Result, err, ok } from "../util/result";
@@ -77,6 +91,82 @@ const AxNode = Type.Object(
77
91
 
78
92
  export type AxNodeResult = Static<typeof AxNode>;
79
93
 
94
+ // Page.Viewport: a clip rectangle in CSS pixels.
95
+ const Viewport = Type.Object({
96
+ x: Type.Number(),
97
+ y: Type.Number(),
98
+ width: Type.Number(),
99
+ height: Type.Number(),
100
+ scale: Type.Number(),
101
+ });
102
+
103
+ // Browser.setDownloadBehavior — "allowAndName" saves under the download's GUID; "default" restores Chrome's own policy.
104
+ const DownloadBehavior = Type.Union([
105
+ Type.Literal("deny"),
106
+ Type.Literal("allow"),
107
+ Type.Literal("allowAndName"),
108
+ Type.Literal("default"),
109
+ ]);
110
+
111
+ const ScreenshotFormat = Type.Union([Type.Literal("jpeg"), Type.Literal("png"), Type.Literal("webp")]);
112
+
113
+ // Input.MouseButton
114
+ const MouseButton = Type.Union([
115
+ Type.Literal("none"),
116
+ Type.Literal("left"),
117
+ Type.Literal("middle"),
118
+ Type.Literal("right"),
119
+ Type.Literal("back"),
120
+ Type.Literal("forward"),
121
+ ]);
122
+
123
+ const MouseEventType = Type.Union([
124
+ Type.Literal("mousePressed"),
125
+ Type.Literal("mouseReleased"),
126
+ Type.Literal("mouseMoved"),
127
+ Type.Literal("mouseWheel"),
128
+ ]);
129
+
130
+ const KeyEventType = Type.Union([
131
+ Type.Literal("keyDown"),
132
+ Type.Literal("keyUp"),
133
+ Type.Literal("rawKeyDown"),
134
+ Type.Literal("char"),
135
+ ]);
136
+
137
+ const DragEventType = Type.Union([
138
+ Type.Literal("dragEnter"),
139
+ Type.Literal("dragOver"),
140
+ Type.Literal("drop"),
141
+ Type.Literal("dragCancel"),
142
+ ]);
143
+
144
+ // Input.DragDataItem / Input.DragData. `data` is the payload as a string (binary payloads are base64).
145
+ const DragDataItem = Type.Object({
146
+ mimeType: Type.String(),
147
+ data: Type.String(),
148
+ title: Type.Optional(Type.String()),
149
+ baseURL: Type.Optional(Type.String()),
150
+ });
151
+
152
+ const DragData = Type.Object({
153
+ items: Type.Array(DragDataItem),
154
+ files: Type.Optional(Type.Array(Type.String())),
155
+ // Bit field of allowed operations: 1=Copy, 2=Link, 16=Move.
156
+ dragOperationsMask: Type.Integer(),
157
+ });
158
+
159
+ // Emulation.ScreenOrientation
160
+ const ScreenOrientation = Type.Object({
161
+ type: Type.Union([
162
+ Type.Literal("portraitPrimary"),
163
+ Type.Literal("portraitSecondary"),
164
+ Type.Literal("landscapePrimary"),
165
+ Type.Literal("landscapeSecondary"),
166
+ ]),
167
+ angle: Type.Integer(),
168
+ });
169
+
80
170
  export const COMMANDS = {
81
171
  "Target.attachToTarget": cmd(
82
172
  Type.Object({ targetId: Type.String(), flatten: Type.Optional(Type.Boolean()) }),
@@ -117,7 +207,8 @@ export const COMMANDS = {
117
207
  ),
118
208
  "Browser.setDownloadBehavior": cmd(
119
209
  Type.Object({
120
- behavior: Type.String(),
210
+ behavior: DownloadBehavior,
211
+ browserContextId: Type.Optional(Type.String()),
121
212
  downloadPath: Type.Optional(Type.String()),
122
213
  eventsEnabled: Type.Optional(Type.Boolean()),
123
214
  }),
@@ -147,14 +238,39 @@ export const COMMANDS = {
147
238
  "Page.navigateToHistoryEntry": cmd(Type.Object({ entryId: Type.Number() }), Empty),
148
239
  "Page.captureScreenshot": cmd(
149
240
  Type.Object({
150
- format: Type.Optional(Type.String()),
151
- quality: Type.Optional(Type.Number()),
152
- clip: Type.Optional(Type.Unknown()),
241
+ format: Type.Optional(ScreenshotFormat),
242
+ // 0–100, jpeg and webp only.
243
+ quality: Type.Optional(Type.Integer()),
244
+ clip: Type.Optional(Viewport),
245
+ fromSurface: Type.Optional(Type.Boolean()),
153
246
  captureBeyondViewport: Type.Optional(Type.Boolean()),
247
+ optimizeForSpeed: Type.Optional(Type.Boolean()),
154
248
  }),
155
249
  Type.Object({ data: Type.String() }),
156
250
  ),
157
- "Page.printToPDF": cmd(Type.Object({}, { additionalProperties: true }), Type.Object({ data: Type.String() })),
251
+ "Page.printToPDF": cmd(
252
+ Type.Object({
253
+ landscape: Type.Optional(Type.Boolean()),
254
+ displayHeaderFooter: Type.Optional(Type.Boolean()),
255
+ printBackground: Type.Optional(Type.Boolean()),
256
+ scale: Type.Optional(Type.Number()),
257
+ // Paper size and margins are in inches.
258
+ paperWidth: Type.Optional(Type.Number()),
259
+ paperHeight: Type.Optional(Type.Number()),
260
+ marginTop: Type.Optional(Type.Number()),
261
+ marginBottom: Type.Optional(Type.Number()),
262
+ marginLeft: Type.Optional(Type.Number()),
263
+ marginRight: Type.Optional(Type.Number()),
264
+ pageRanges: Type.Optional(Type.String()),
265
+ headerTemplate: Type.Optional(Type.String()),
266
+ footerTemplate: Type.Optional(Type.String()),
267
+ preferCSSPageSize: Type.Optional(Type.Boolean()),
268
+ transferMode: Type.Optional(Type.Union([Type.Literal("ReturnAsBase64"), Type.Literal("ReturnAsStream")])),
269
+ generateTaggedPDF: Type.Optional(Type.Boolean()),
270
+ generateDocumentOutline: Type.Optional(Type.Boolean()),
271
+ }),
272
+ Type.Object({ data: Type.String(), stream: Type.Optional(Type.String()) }),
273
+ ),
158
274
  "Page.handleJavaScriptDialog": cmd(
159
275
  Type.Object({ accept: Type.Boolean(), promptText: Type.Optional(Type.String()) }),
160
276
  Empty,
@@ -167,6 +283,7 @@ export const COMMANDS = {
167
283
  returnByValue: Type.Optional(Type.Boolean()),
168
284
  awaitPromise: Type.Optional(Type.Boolean()),
169
285
  userGesture: Type.Optional(Type.Boolean()),
286
+ replMode: Type.Optional(Type.Boolean()),
170
287
  },
171
288
  { additionalProperties: true },
172
289
  ),
@@ -209,6 +326,10 @@ export const COMMANDS = {
209
326
  Type.Object({ nodeId: Type.Optional(Type.Number()), backendNodeId: Type.Optional(Type.Number()) }),
210
327
  Type.Object({ object: RemoteObject }),
211
328
  ),
329
+ "DOM.describeNode": cmd(
330
+ Type.Object({ objectId: Type.String() }),
331
+ Type.Object({ node: Type.Object({ backendNodeId: Type.Number() }, { additionalProperties: true }) }),
332
+ ),
212
333
  "DOM.setFileInputFiles": cmd(
213
334
  Type.Object({
214
335
  files: Type.Array(Type.String()),
@@ -219,9 +340,60 @@ export const COMMANDS = {
219
340
  Empty,
220
341
  ),
221
342
 
222
- "Input.dispatchMouseEvent": cmd(Type.Object({}, { additionalProperties: true }), Empty),
223
- "Input.dispatchKeyEvent": cmd(Type.Object({}, { additionalProperties: true }), Empty),
224
- "Input.dispatchDragEvent": cmd(Type.Object({}, { additionalProperties: true }), Empty),
343
+ "Input.dispatchMouseEvent": cmd(
344
+ Type.Object({
345
+ type: MouseEventType,
346
+ x: Type.Number(),
347
+ y: Type.Number(),
348
+ // Bit field: 1=Alt, 2=Ctrl, 4=Meta/Command, 8=Shift.
349
+ modifiers: Type.Optional(Type.Integer()),
350
+ timestamp: Type.Optional(Type.Number()),
351
+ button: Type.Optional(MouseButton),
352
+ // Bit field of pressed buttons: 1=Left, 2=Right, 4=Middle, 8=Back, 16=Forward.
353
+ buttons: Type.Optional(Type.Integer()),
354
+ clickCount: Type.Optional(Type.Integer()),
355
+ force: Type.Optional(Type.Number()),
356
+ tangentialPressure: Type.Optional(Type.Number()),
357
+ tiltX: Type.Optional(Type.Number()),
358
+ tiltY: Type.Optional(Type.Number()),
359
+ twist: Type.Optional(Type.Integer()),
360
+ deltaX: Type.Optional(Type.Number()),
361
+ deltaY: Type.Optional(Type.Number()),
362
+ pointerType: Type.Optional(Type.Union([Type.Literal("mouse"), Type.Literal("pen")])),
363
+ }),
364
+ Empty,
365
+ ),
366
+ "Input.dispatchKeyEvent": cmd(
367
+ Type.Object({
368
+ type: KeyEventType,
369
+ modifiers: Type.Optional(Type.Integer()),
370
+ timestamp: Type.Optional(Type.Number()),
371
+ text: Type.Optional(Type.String()),
372
+ unmodifiedText: Type.Optional(Type.String()),
373
+ keyIdentifier: Type.Optional(Type.String()),
374
+ code: Type.Optional(Type.String()),
375
+ key: Type.Optional(Type.String()),
376
+ windowsVirtualKeyCode: Type.Optional(Type.Integer()),
377
+ nativeVirtualKeyCode: Type.Optional(Type.Integer()),
378
+ autoRepeat: Type.Optional(Type.Boolean()),
379
+ isKeypad: Type.Optional(Type.Boolean()),
380
+ isSystemKey: Type.Optional(Type.Boolean()),
381
+ // 0=Standard, 1=Left, 2=Right, 3=Numpad.
382
+ location: Type.Optional(Type.Integer()),
383
+ commands: Type.Optional(Type.Array(Type.String())),
384
+ }),
385
+ Empty,
386
+ ),
387
+ "Input.dispatchDragEvent": cmd(
388
+ Type.Object({
389
+ type: DragEventType,
390
+ x: Type.Number(),
391
+ y: Type.Number(),
392
+ data: DragData,
393
+ modifiers: Type.Optional(Type.Integer()),
394
+ }),
395
+ Empty,
396
+ ),
225
397
  "Input.insertText": cmd(Type.Object({ text: Type.String() }), Empty),
226
398
 
227
399
  "Network.getResponseBody": cmd(
@@ -232,9 +404,27 @@ export const COMMANDS = {
232
404
  Type.Object({ headers: Type.Record(Type.String(), Type.String()) }),
233
405
  Empty,
234
406
  ),
235
- "Emulation.setDeviceMetricsOverride": cmd(Type.Object({}, { additionalProperties: true }), Empty),
407
+ "Emulation.setDeviceMetricsOverride": cmd(
408
+ Type.Object({
409
+ // 0 disables the override for that dimension.
410
+ width: Type.Integer(),
411
+ height: Type.Integer(),
412
+ // 0 disables the override.
413
+ deviceScaleFactor: Type.Number(),
414
+ mobile: Type.Boolean(),
415
+ scale: Type.Optional(Type.Number()),
416
+ screenWidth: Type.Optional(Type.Integer()),
417
+ screenHeight: Type.Optional(Type.Integer()),
418
+ positionX: Type.Optional(Type.Integer()),
419
+ positionY: Type.Optional(Type.Integer()),
420
+ dontSetVisibleSize: Type.Optional(Type.Boolean()),
421
+ screenOrientation: Type.Optional(ScreenOrientation),
422
+ viewport: Type.Optional(Viewport),
423
+ }),
424
+ Empty,
425
+ ),
236
426
  "Accessibility.getFullAXTree": cmd(
237
- Type.Object({ depth: Type.Optional(Type.Number()) }, { additionalProperties: true }),
427
+ Type.Object({ depth: Type.Optional(Type.Number()), frameId: Type.Optional(Type.String()) }),
238
428
  Type.Object({ nodes: Type.Array(AxNode) }),
239
429
  ),
240
430
  };
@@ -247,14 +437,15 @@ export const decodeResult = <M extends CdpMethod>(
247
437
  method: M,
248
438
  raw: unknown,
249
439
  ): Result<ResultOf<M>, CdpError> => {
440
+ // `method` is a key of COMMANDS, so the lookup cannot miss.
250
441
  const spec = COMMANDS[method];
251
- if (spec === undefined) {
252
- return err(cdpError("invalid_response", `unknown CDP method`, method));
253
- }
254
442
  if (!spec.validate.Check(raw)) {
255
443
  const why = spec.validate.Errors(raw).map((e) => e.message).join("; ");
256
444
  return err(cdpError("invalid_response", why, method));
257
445
  }
258
- // Guarded assertion: the check above proves `raw` matches COMMANDS[method].result, which TS cannot correlate from a generic key.
446
+ // Guarded assertion: the check above proves `raw` matches COMMANDS[method].result. TS resolves
447
+ // `spec.validate` through a generic key to the union of every validator, so the predicate
448
+ // narrows `raw` to the union of every result type rather than to ResultOf<M>.
449
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- sanctioned decoder, see scripts/boundary-allowlist.ts
259
450
  return ok(raw as ResultOf<M>);
260
451
  };
@@ -1,32 +1,39 @@
1
+ import { Type, type Static } from "typebox";
1
2
  import { decodeEvent } from "./events";
2
- import { createRecordStore } from "./record-store";
3
+ import { createRecordStore, type DrainPage } from "./record-store";
3
4
 
4
5
  export type ConsoleLevel = "log" | "info" | "warn" | "error" | "debug";
5
6
 
6
- export type ConsoleRecord = {
7
- seq: number;
8
- timestampMs: number;
9
- level: ConsoleLevel;
10
- text: string;
11
- source: "console-api" | "log-entry";
12
- url?: string;
13
- lineNumber?: number;
14
- stackTrace?: string;
15
- };
7
+ // The one declaration of a console record: the buffer produces it and browser_console validates replayed
8
+ // details against it, so the two cannot drift.
9
+ export const ConsoleRecordSchema = Type.Object({
10
+ seq: Type.Number(),
11
+ timestampMs: Type.Number(),
12
+ level: Type.Union([
13
+ Type.Literal("log"),
14
+ Type.Literal("info"),
15
+ Type.Literal("warn"),
16
+ Type.Literal("error"),
17
+ Type.Literal("debug"),
18
+ ]),
19
+ text: Type.String(),
20
+ source: Type.Union([Type.Literal("console-api"), Type.Literal("log-entry")]),
21
+ url: Type.Optional(Type.String()),
22
+ lineNumber: Type.Optional(Type.Number()),
23
+ stackTrace: Type.Optional(Type.String()),
24
+ });
25
+
26
+ export type ConsoleRecord = Readonly<Static<typeof ConsoleRecordSchema>>;
16
27
 
17
28
  export type ConsoleFilter = {
18
- levels?: ConsoleLevel[];
19
- textPattern?: string;
20
- sinceSeq?: number;
21
- sinceMs?: number;
22
- limit?: number;
29
+ readonly levels?: ReadonlyArray<ConsoleLevel>;
30
+ readonly textPattern?: string;
31
+ readonly sinceSeq?: number;
32
+ readonly sinceMs?: number;
33
+ readonly limit?: number;
23
34
  };
24
35
 
25
- export type ConsoleDrainResult = {
26
- readonly records: ConsoleRecord[];
27
- readonly total: number;
28
- readonly bufferOverflowed: boolean;
29
- };
36
+ export type ConsoleDrainResult = DrainPage<ConsoleRecord>;
30
37
 
31
38
  export type ConsoleBuffer = {
32
39
  ingestConsoleApi(p: unknown): void;
@@ -43,7 +50,9 @@ const compileTextMatcher = (pattern: string): ((text: string) => boolean) => {
43
50
  try {
44
51
  const re = new RegExp(pattern.slice(1, -1));
45
52
  return (text) => re.test(text);
46
- } catch {}
53
+ } catch {
54
+ // Not a valid regex: fall through and match the pattern as a literal substring.
55
+ }
47
56
  }
48
57
  return (text) => text.includes(pattern);
49
58
  };
@@ -60,7 +69,8 @@ const stringifyRemoteObject = (
60
69
  try {
61
70
  return JSON.stringify(arg.value);
62
71
  } catch {
63
- return String(arg.value);
72
+ // Circular or BigInt payloads cannot be JSON-encoded; fall back to the CDP description.
73
+ return arg.description ?? arg.type ?? "";
64
74
  }
65
75
  }
66
76
  if (arg.description !== undefined) return arg.description;
@@ -69,6 +79,8 @@ const stringifyRemoteObject = (
69
79
 
70
80
  const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => {
71
81
  switch (type) {
82
+ case undefined:
83
+ return "log";
72
84
  case "error":
73
85
  case "assert":
74
86
  return "error";
@@ -86,6 +98,8 @@ const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => {
86
98
 
87
99
  const mapLogEntryLevel = (level: string | undefined): ConsoleLevel => {
88
100
  switch (level) {
101
+ case undefined:
102
+ return "info";
89
103
  case "error":
90
104
  return "error";
91
105
  case "warning":
@@ -6,6 +6,7 @@ import { type Result, err, ok } from "../util/result";
6
6
  import { userDataDirCandidates } from "../profile/paths";
7
7
  import { type CdpError, cdpError } from "./errors";
8
8
  import { asString, errnoCode, isRecord } from "../util/guards";
9
+ import { sleep } from "../util/sleep";
9
10
 
10
11
  const PORT_PROBE_DEADLINE_MS = 30_000;
11
12
  const PORT_PROBE_INTERVAL_MS = 1_000;
@@ -40,24 +41,57 @@ const probePort = (port: number): Promise<Result<void, CdpError>> =>
40
41
  sock.destroy();
41
42
  resolve(r);
42
43
  };
43
- sock.setTimeout(1000, () => finish(err(cdpError("discovery_failed", "probe timeout"))));
44
- sock.once("error", (e) => finish(err(cdpError("discovery_failed", e.message))));
45
- sock.once("connect", () => finish(ok(undefined)));
44
+ sock.setTimeout(1000, () => { finish(err(cdpError("discovery_failed", "probe timeout"))); });
45
+ sock.once("error", (e) => { finish(err(cdpError("discovery_failed", e.message))); });
46
+ sock.once("connect", () => { finish(ok(undefined)); });
46
47
  });
47
48
 
48
- const waitForPort = async (port: number): Promise<Result<void, CdpError>> => {
49
- const end = Date.now() + PORT_PROBE_DEADLINE_MS;
50
- let lastMessage = "unknown";
51
- while (Date.now() < end) {
52
- const probe = await probePort(port);
53
- if (probe.success) return probe;
54
- lastMessage = probe.error.message;
55
- await new Promise((r) => setTimeout(r, PORT_PROBE_INTERVAL_MS));
49
+ export type PortProbe = (port: number) => Promise<Result<void, CdpError>>;
50
+
51
+ export type PortRaceOptions = {
52
+ readonly probe?: PortProbe;
53
+ readonly deadlineMs?: number;
54
+ readonly intervalMs?: number;
55
+ };
56
+
57
+ // Polls every candidate port at once under ONE deadline and settles on the first that answers; a stale DevToolsActivePort can no longer hold discovery for its own full deadline before the next candidate is tried.
58
+ export const firstReadyPort = async <C extends { readonly port: number }>(
59
+ candidates: ReadonlyArray<C>,
60
+ opts: PortRaceOptions = {},
61
+ ): Promise<Result<C, CdpError>> => {
62
+ const probe = opts.probe ?? probePort;
63
+ const deadlineMs = opts.deadlineMs ?? PORT_PROBE_DEADLINE_MS;
64
+ const intervalMs = opts.intervalMs ?? PORT_PROBE_INTERVAL_MS;
65
+ if (candidates.length === 0) return err(cdpError("discovery_failed", "no DevTools port candidates to probe"));
66
+
67
+ // One controller ends every loser the moment a winner appears (or the deadline passes).
68
+ const stop = new AbortController();
69
+ const deadline = setTimeout(() => { stop.abort(); }, deadlineMs);
70
+ const lastMessages = new Map<number, string>();
71
+
72
+ const poll = async (c: C): Promise<C> => {
73
+ while (!stop.signal.aborted) {
74
+ const r = await probe(c.port);
75
+ if (r.success) return c;
76
+ lastMessages.set(c.port, r.error.message);
77
+ await sleep(intervalMs, stop.signal);
78
+ }
79
+ throw new Error(lastMessages.get(c.port) ?? "aborted");
80
+ };
81
+
82
+ try {
83
+ return ok(await Promise.any(candidates.map(poll)));
84
+ } catch {
85
+ const ports = candidates.map((k) => `127.0.0.1:${k.port}`).join(", ");
86
+ const detail = [...lastMessages.entries()].map(([port, m]) => `${port}: ${m}`).join("; ") || "unknown";
87
+ return err(cdpError(
88
+ "discovery_failed",
89
+ `Chrome's remote-debugging page is open, but DevTools is not live yet on ${ports} — if Chrome opened a profile picker, choose your normal profile first, then tick the checkbox and click Allow if shown (last error: ${detail})`,
90
+ ));
91
+ } finally {
92
+ clearTimeout(deadline);
93
+ stop.abort();
56
94
  }
57
- return err(cdpError(
58
- "discovery_failed",
59
- `Chrome's remote-debugging page is open, but DevTools is not live yet on 127.0.0.1:${port} — if Chrome opened a profile picker, choose your normal profile first, then tick the checkbox and click Allow if shown (last error: ${lastMessage})`,
60
- ));
61
95
  };
62
96
 
63
97
  const isPortLive = async (port: number): Promise<boolean> => (await probePort(port)).success;
@@ -81,7 +115,7 @@ const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null>
81
115
  },
82
116
  );
83
117
  req.on("timeout", () => { req.destroy(); resolve(null); });
84
- req.on("error", () => resolve(null));
118
+ req.on("error", () => { resolve(null); });
85
119
  req.end();
86
120
  });
87
121
 
@@ -126,7 +160,9 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
126
160
  raw = await readFile(portFile, "utf8");
127
161
  try {
128
162
  mtimeMs = (await stat(portFile)).mtimeMs;
129
- } catch {}
163
+ } catch {
164
+ // The file was readable a moment ago; without an mtime it is simply treated as fresh.
165
+ }
130
166
  } catch (e) {
131
167
  // EPERM/EACCES is common under sandboxes; remember it and fall back to network probing rather than failing the whole discovery.
132
168
  const code = errnoCode(e);
@@ -175,13 +211,9 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
175
211
  const ordered = liveCandidates.length > 0 ? liveCandidates : unique;
176
212
 
177
213
  // Some browsers disable the /json/version endpoint when remote debugging is toggled via chrome://inspect rather than a launch flag, hence the DevToolsActivePort fallback.
178
- let lastErr: Result<CdpEndpoint, CdpError> | null = null;
179
- for (const c of ordered) {
180
- const ready = await waitForPort(c.port);
181
- if (!ready.success) {
182
- lastErr = err(ready.error);
183
- continue;
184
- }
214
+ const ready = await firstReadyPort(ordered);
215
+ if (ready.success) {
216
+ const c = ready.data;
185
217
  const liveUrl = await queryLiveWsUrl(c.port);
186
218
  return ok({ wsUrl: liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`, userDataDir: c.userDataDir });
187
219
  }
@@ -192,7 +224,7 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
192
224
  if (liveUrl) return ok({ wsUrl: liveUrl });
193
225
  }
194
226
 
195
- return lastErr ?? err(cdpError("discovery_failed", "no live DevTools endpoint among discovered candidates"));
227
+ return err(ready.error);
196
228
  };
197
229
 
198
230
  export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
package/src/cdp/events.ts CHANGED
@@ -39,6 +39,11 @@ const ConsoleArg = Type.Object(
39
39
 
40
40
  export const EVENTS = {
41
41
  "Inspector.detached": ev(Anything),
42
+ // Browser-level notice that a session ended; carries the session id in params rather than on the envelope.
43
+ "Target.detachedFromTarget": ev(
44
+ Type.Object({ sessionId: Type.String(), targetId: Type.Optional(Type.String()) }, open),
45
+ ),
46
+ "Page.javascriptDialogClosed": ev(Anything),
42
47
 
43
48
  "Page.javascriptDialogOpening": ev(
44
49
  Type.Object(
@@ -52,6 +57,7 @@ export const EVENTS = {
52
57
  ),
53
58
  "Page.loadEventFired": ev(Anything),
54
59
  "Page.frameNavigated": ev(Anything),
60
+ "Page.downloadWillBegin": ev(Anything),
55
61
 
56
62
  "Runtime.consoleAPICalled": ev(
57
63
  Type.Object(
@@ -158,13 +164,13 @@ export const decodeEvent = <E extends CdpEventName>(
158
164
  name: E,
159
165
  raw: unknown,
160
166
  ): Result<EventParamsOf<E>, CdpError> => {
167
+ // `name` is a key of EVENTS, so the lookup cannot miss.
161
168
  const spec = EVENTS[name];
162
- if (spec === undefined) {
163
- return err(cdpError("invalid_response", "unknown CDP event", name));
164
- }
165
169
  if (!spec.validate.Check(raw)) {
166
170
  const why = spec.validate.Errors(raw).map((e) => e.message).join("; ");
167
171
  return err(cdpError("invalid_response", why, name));
168
172
  }
173
+ // Guarded assertion: same generic-key correlation limit as decodeResult in cdp/commands.ts.
174
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- sanctioned decoder, see scripts/boundary-allowlist.ts
169
175
  return ok(raw as EventParamsOf<E>);
170
176
  };
@@ -1,37 +1,40 @@
1
+ import { Type, type Static } from "typebox";
1
2
  import { decodeEvent, type EventParamsOf } from "./events";
2
- import { createRecordStore } from "./record-store";
3
-
4
- export type NetworkRecord = {
5
- requestId: string;
6
- method: string;
7
- url: string;
8
- status?: number;
9
- statusText?: string;
10
- type: string;
11
- mimeType?: string;
12
- requestStartedMs: number;
13
- durationMs?: number;
14
- requestBodySize: number;
15
- responseBodySize?: number;
16
- failed?: boolean;
17
- errorText?: string;
18
- body?: string | null;
19
- };
3
+ import { createRecordStore, type DrainPage } from "./record-store";
4
+
5
+ // Only the buffer itself may update a record in place as follow-up events arrive; everything it hands out is readonly.
6
+ // The one declaration of a network record: the buffer produces it and browser_network_requests validates
7
+ // replayed details against it, so the two cannot drift.
8
+ export const NetworkRecordSchema = Type.Object({
9
+ requestId: Type.String(),
10
+ method: Type.String(),
11
+ url: Type.String(),
12
+ status: Type.Optional(Type.Number()),
13
+ statusText: Type.Optional(Type.String()),
14
+ type: Type.String(),
15
+ mimeType: Type.Optional(Type.String()),
16
+ requestStartedMs: Type.Number(),
17
+ durationMs: Type.Optional(Type.Number()),
18
+ requestBodySize: Type.Number(),
19
+ responseBodySize: Type.Optional(Type.Number()),
20
+ failed: Type.Optional(Type.Boolean()),
21
+ errorText: Type.Optional(Type.String()),
22
+ body: Type.Optional(Type.Union([Type.String(), Type.Null()])),
23
+ });
24
+
25
+ type MutableNetworkRecord = Static<typeof NetworkRecordSchema>;
26
+ export type NetworkRecord = Readonly<MutableNetworkRecord>;
20
27
 
21
28
  export type NetworkFilter = {
22
- urlPattern?: string;
23
- methodFilter?: string[];
24
- statusFilter?: { min?: number; max?: number };
25
- resourceTypes?: string[];
26
- sinceMs?: number;
27
- limit?: number;
29
+ readonly urlPattern?: string;
30
+ readonly methodFilter?: ReadonlyArray<string>;
31
+ readonly statusFilter?: { readonly min?: number; readonly max?: number };
32
+ readonly resourceTypes?: ReadonlyArray<string>;
33
+ readonly sinceMs?: number;
34
+ readonly limit?: number;
28
35
  };
29
36
 
30
- export type DrainResult = {
31
- readonly records: NetworkRecord[];
32
- readonly total: number;
33
- readonly bufferOverflowed: boolean;
34
- };
37
+ export type DrainResult = DrainPage<NetworkRecord>;
35
38
 
36
39
  export type NetworkBuffer = {
37
40
  ingestRequestWillBeSent(p: unknown): void;
@@ -47,7 +50,9 @@ const compileUrlMatcher = (pattern: string): ((url: string) => boolean) => {
47
50
  try {
48
51
  const re = new RegExp(pattern.slice(1, -1));
49
52
  return (url) => re.test(url);
50
- } catch {}
53
+ } catch {
54
+ // Not a valid regex: fall through and match the pattern as a literal substring.
55
+ }
51
56
  }
52
57
  return (url) => url.includes(pattern);
53
58
  };
@@ -55,13 +60,13 @@ const compileUrlMatcher = (pattern: string): ((url: string) => boolean) => {
55
60
  type UpdateEvent = "Network.responseReceived" | "Network.loadingFinished" | "Network.loadingFailed";
56
61
 
57
62
  export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
58
- const records = createRecordStore<string, NetworkRecord>(capacity);
63
+ const records = createRecordStore<string, MutableNetworkRecord>(capacity);
59
64
 
60
65
  // All three follow-up events say the same thing first: decode, find the request they belong to, give up quietly if it is gone.
61
66
  const update = <E extends UpdateEvent>(
62
67
  method: E,
63
68
  p: unknown,
64
- apply: (r: NetworkRecord, params: EventParamsOf<E>) => void,
69
+ apply: (r: MutableNetworkRecord, params: EventParamsOf<E>) => void,
65
70
  ): void => {
66
71
  const decoded = decodeEvent(method, p);
67
72
  if (!decoded.success) return;
@@ -80,6 +85,7 @@ export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
80
85
  const url = params?.request.url;
81
86
  const method = params?.request.method;
82
87
  if (!id || !url || !method) return;
88
+ // Delete before set: the store evicts by insertion order, so a request id that is re-sent (redirects reuse it) must move to the newest slot rather than keep its old age.
83
89
  records.delete(id);
84
90
  const postData = params.request.postData;
85
91
  records.set(id, {