pi-browser-harness 0.12.0 → 0.13.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 (88) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +86 -0
  3. package/package.json +12 -6
  4. package/skills/pi-browser-harness/SKILL.md +57 -2
  5. package/src/cdp/commands.ts +200 -14
  6. package/src/cdp/console-buffer.ts +37 -23
  7. package/src/cdp/discovery.ts +57 -25
  8. package/src/cdp/events.ts +8 -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 +320 -217
  13. package/src/cdp/target-factory.ts +34 -15
  14. package/src/cdp/types.ts +17 -1
  15. package/src/client.ts +76 -43
  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 +8 -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 +65 -44
  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/serp.page.ts +39 -0
  51. package/src/domains/page.ts +18 -51
  52. package/src/domains/poll.ts +42 -0
  53. package/src/domains/readpage/read-page.ts +48 -53
  54. package/src/domains/ref-resolve.ts +43 -29
  55. package/src/domains/ref-signature.ts +11 -0
  56. package/src/domains/render.ts +12 -23
  57. package/src/domains/screenshot-capture.ts +4 -2
  58. package/src/domains/screenshot.ts +13 -10
  59. package/src/domains/scroll.ts +4 -4
  60. package/src/domains/search/google-serp.ts +1 -1
  61. package/src/domains/search/web-search.ts +38 -33
  62. package/src/domains/snapshot.ts +71 -73
  63. package/src/domains/tab-resolve.ts +36 -0
  64. package/src/domains/tabs.ts +18 -69
  65. package/src/domains/target.ts +1 -1
  66. package/src/index.ts +90 -33
  67. package/src/profile/bind.ts +45 -14
  68. package/src/profile/browser-process.ts +50 -12
  69. package/src/profile/command.ts +20 -12
  70. package/src/profile/launch.ts +13 -9
  71. package/src/profile/list.ts +9 -13
  72. package/src/profile/store.ts +2 -1
  73. package/src/prompt.ts +32 -7
  74. package/src/registry.ts +65 -4
  75. package/src/schemas/parse.ts +2 -2
  76. package/src/setup.ts +28 -19
  77. package/src/state.ts +2 -1
  78. package/src/util/debug.ts +7 -1
  79. package/src/util/guards.ts +3 -0
  80. package/src/util/mutex.ts +1 -1
  81. package/src/util/paths.ts +3 -2
  82. package/src/util/result.ts +8 -0
  83. package/src/util/sharp-shim.ts +18 -11
  84. package/src/util/sleep.ts +18 -0
  85. package/src/util/tool.ts +163 -114
  86. package/src/util/truncate.ts +1 -1
  87. package/tsconfig.json +2 -1
  88. package/src/domains/fill-engine.ts +0 -80
package/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  All notable changes to pi-browser-harness will be documented in this file.
4
4
 
5
+ ## 0.13.0 — 2026-10-20
6
+
7
+ ### Fixed
8
+
9
+ - **A killed daemon no longer crashes pi with `read ECONNRESET`.** The daemon's liveness probe, the client transport and the IPC server read the wire protocol through `node:readline`, which re-emits the socket's errors on its own interface with no listener. They now share a small line reader in `src/daemon/lines.ts` that only emits lines.
10
+ - **Waking from sleep no longer stacks up "Allow remote debugging" prompts.** Each dial to Chrome is one prompt, and the daemon abandoned a dial after 10s and started another on the next tool call, so an agent mid-task could open a dozen prompts before anyone was back at the keyboard. A dial now stays open for 5 minutes while callers get a "waiting for the Allow prompt" answer after 10s, every caller joins the dial in flight, and an outright failure starts a doubling cooldown (30s to 5min) during which no automatic dial happens. A dropped connection still re-dials once at the next demand. `/browser-setup` and `/browser-reload-daemon` send `Daemon.reconnect`, which bypasses the cooldown.
11
+ - **Tool failures now reach the model as errors.** `defineBrowserTool` set `isError` on the returned result, but pi only flags a result as an error when `execute` rejects, so every failed browser tool read as a success. Failed handlers now throw with the rendered error text; replayed errors still render in the UI.
12
+ - **The daemon transport no longer exits mid-handshake.** The connect timer was cleared on TCP connect, before the daemon's `registered` reply, and the socket was already unref'd, so a print-mode run could exit before registering. The timer now stays armed until the handshake settles; a daemon that accepts but never registers times out instead of hanging.
13
+ - **Disconnected clients are cleaned up in the daemon.** The idle-timer registration overwrote the bridge's disconnect handler, so router ownership and armed timers leaked per client. The IPC server now keeps a handler list; `deregister` is handled.
14
+ - **`--browser-namespace` works.** The flag was read before it was registered, so it was always undefined.
15
+ - **CDP sessions are reused or detached.** Switching tabs re-attached every time and left the old session alive; destroying the current target left `current()` pointing at it for up to 30s. Sessions are now tracked per target, detached on re-attach, and cleared on `Target.targetDestroyed` / `Inspector.detached`.
16
+ - **Connect timeouts no longer kick every client off the daemon**, overlapping `connect()` calls share one handshake, the detach release reads the session from `params`, and the duplicate-clientId reply is flushed before the socket closes.
17
+ - **Time budgets in `browser_network_requests` and `browser_snapshot` actually bound the work** through a concurrency-limited runner that honours the abort signal.
18
+ - **`browser_handle_dialog` clears the recorded dialog**, so the next `browser_page_info` no longer reports an already-handled dialog.
19
+ - **`browser_press_key` sends virtual key codes for function and modifier keys.**
20
+ - **`browser_open_urls` applies the tab-title marker after the page loads** instead of to the document being replaced.
21
+ - **`browser_print_to_pdf`, screenshots and uploads return `io_error`** on unwritable or unreadable paths instead of throwing.
22
+ - **Element refs are released** (`Runtime.releaseObject`) on every ref path; `browser_read_page` and `browser_open_urls` detach the sessions they attach.
23
+ - sharp "missing" is classified correctly under ESM; `isPathWithin` accepts siblings starting with `..`; `BH_DEBUG_CLICKS=0` disables debug; Linux `chromium-browser` process names are matched after `ps` truncation; discovery probes stale `DevToolsActivePort` candidates concurrently under one deadline.
24
+
25
+ ### Changed
26
+
27
+ - **Bad arguments return `invalid_args`** instead of `invalid_state`.
28
+ - **`browser_switch_tab` resolves targetId prefixes against owned tabs only**, matching `browser_close_tab`.
29
+ - **`browser_execute_js` details hold a bounded preview**; the full value lives in the text.
30
+ - **CDP calls require params when the method needs them**, and `Input.*`, `Emulation.setDeviceMetricsOverride`, `Page.printToPDF` and `Page.captureScreenshot` have real schemas.
31
+ - **Tool `details` are typed** via a TypeBox schema on the definition and validated on replay.
32
+ - **Page-side JS is TypeScript** under `src/domains/page/*.page.ts`, type-checked against the DOM lib and injected via `fn.toString()`. `src/domains/fill-engine.ts` is removed.
33
+ - The browser prompt's lane lists and the skill file's tool reference are generated from the registry (`npm run gen:skill`).
34
+
35
+ ### Added
36
+
37
+ - **Other pi extensions can contribute browser tools** through `registerBrowserTools` (see README, Extending).
38
+ - Type-aware ESLint (`npm run lint`) and `npm run check` running typecheck, boundaries, lint and tests. The boundary scanner gained a `raw-js-template` rule.
39
+ - Injection seams on the daemon entry, extension factory, setup and profile binding; tests for every tool in `src/domains` and the previously untested util, profile and daemon modules (365 tests to 1017).
40
+
5
41
  ## 0.12.0 — 2026-10-19
6
42
 
7
43
  ### Changed
package/README.md CHANGED
@@ -303,6 +303,91 @@ Scripts must live under the OS temp dir, the cwd, or `BH_SCRIPT_DIR`, and every
303
303
  mandatory timeout (60s default, 600s max). They land on disk first, so they're reviewable and
304
304
  re-runnable.
305
305
 
306
+ ### Contributing tools from another pi extension
307
+
308
+ Another extension can add its own `browser_*` tools to this harness. Contributed tools share the
309
+ same client, mutation mutex, tab ownership, and lane prompt as the built-ins, so a `serialized` tool
310
+ you add queues behind `browser_click` exactly as `browser_fill` does.
311
+
312
+ Define the tool with `defineBrowserTool` and hand it to `registerBrowserTools` at your extension's
313
+ load time, before pi emits `session_start`. The harness registers it with pi on the next
314
+ `session_start` alongside the built-ins.
315
+
316
+ ```ts
317
+ // my-extension/src/index.ts
318
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
319
+ import { Type } from "typebox";
320
+ import { defineBrowserTool, registerBrowserTools, ok } from "pi-browser-harness/src/index.ts";
321
+ import { evalJs } from "pi-browser-harness/src/domains/cdp-call.ts";
322
+
323
+ const pageHeightTool = defineBrowserTool({
324
+ name: "browser_page_height",
325
+ label: "Browser Page Height",
326
+ description: "Report the document's full scroll height in CSS pixels.",
327
+ promptSnippet: "Measure the page's scroll height",
328
+ promptGuidelines: [],
329
+ parameters: Type.Object({}),
330
+ concurrency: "parallel",
331
+ handler: async (_args, { client }) => {
332
+ const height = await evalJs(client, "document.documentElement.scrollHeight");
333
+ if (!height.success) return height;
334
+ return ok({ text: `Scroll height: ${String(height.data)}px` });
335
+ },
336
+ });
337
+
338
+ export default function myExtension(_pi: ExtensionAPI): void {
339
+ const added = registerBrowserTools([pageHeightTool]);
340
+ if (!added.success) console.warn(added.error.message);
341
+ }
342
+ ```
343
+
344
+ `registerBrowserTools` returns a `Result`. The whole batch is rejected, and nothing is added, when any
345
+ name is already taken by a built-in, an earlier contribution, or another tool in the same batch. Pick
346
+ `concurrency: "serialized"` for anything that changes page or tab state; `"parallel"` for reads.
347
+ `browserTools()` lists what the harness will register, built-ins plus contributions so far.
348
+
349
+ A tool that wants a custom result view declares a `details` schema next to `parameters`. The handler
350
+ then returns `details` of that shape, and `renderResult` receives them typed. pi stores details with
351
+ the session and replays them later; the harness validates replayed details against the schema first,
352
+ so a renderer never sees a stale shape from an older session (it falls back to the stored text instead).
353
+ Details must be plain JSON: no `Map`s, and omit a key rather than setting it to `undefined`.
354
+
355
+ ```ts
356
+ import { Text } from "@mariozechner/pi-tui";
357
+
358
+ const PageHeightDetails = Type.Object({ height: Type.Number(), unit: Type.Literal("px") });
359
+
360
+ const pageHeightTool = defineBrowserTool({
361
+ // ...name, label, description, promptSnippet, promptGuidelines, parameters, concurrency as above
362
+ details: PageHeightDetails,
363
+ handler: async (_args, { client }) => {
364
+ const height = await evalJs(client, "document.documentElement.scrollHeight");
365
+ if (!height.success) return height;
366
+ const px = typeof height.data === "number" ? height.data : 0;
367
+ return ok({ text: `Scroll height: ${px}px`, details: { height: px, unit: "px" } });
368
+ },
369
+ renderResult: (result, _expanded, theme) =>
370
+ new Text(theme.fg("muted", `${result.details.height}${result.details.unit}`), 0, 0),
371
+ });
372
+ ```
373
+
374
+ Tools without a `details` schema need nothing extra: they may still attach any JSON object for logs,
375
+ and their `renderResult` (if any) sees it untyped.
376
+
377
+ ### Known limits
378
+
379
+ Two extension points are documented but not implemented; the escape hatches that exist today are
380
+ listed with each.
381
+
382
+ - **No per-tab extension slot.** `TabSession` in `src/cdp/session.ts` carries only the harness's own
383
+ per-tab state (session id, dialog, page-info cache). A contributed tool cannot stash its own state
384
+ on a tab. Keep per-tab state in your extension keyed by `targetId` from `browser_current_tab` or
385
+ `browser_list_tabs`, or compute it on demand with `browser_run_script` through `daemon.session(targetId)`.
386
+ - **Hardcoded browser list.** `src/profile/paths.ts` knows the user-data directories of a fixed set
387
+ of Chromium browsers, so `/browser-profile` cannot discover one that is not on that list. Point the
388
+ harness at any Chromium profile directory with `CHROME_USER_DATA_DIR`, or skip discovery entirely
389
+ by attaching to a running browser's DevTools endpoint with `BU_CDP_WS`.
390
+
306
391
  ---
307
392
 
308
393
  ## Commands, flags, and environment
@@ -393,6 +478,7 @@ path-restricted and timeout-bounded, but that is a blast-radius limit, not a san
393
478
  ## Contributing
394
479
 
395
480
  See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, conventions, and the PR process.
481
+ `npm run check` runs the typecheck, the boundary scan, the type-aware ESLint pass and the test suite in that order; it is the one command a PR must pass.
396
482
  Release history lives in [CHANGELOG.md](CHANGELOG.md).
397
483
 
398
484
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-browser-harness",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Browser control extension for pi — navigate, click, type, screenshot, and extract data from real Chrome via CDP",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -49,18 +49,24 @@
49
49
  "typebox": "*"
50
50
  },
51
51
  "devDependencies": {
52
+ "@eslint/js": "10.0.1",
52
53
  "@mariozechner/pi-coding-agent": "*",
53
54
  "@mariozechner/pi-tui": "*",
54
55
  "@types/ws": "^8.18.1",
56
+ "eslint": "10.11.0",
55
57
  "typebox": "*",
56
- "typescript": "^5.0.0"
58
+ "typescript": "^5.0.0",
59
+ "typescript-eslint": "8.71.0"
57
60
  },
58
61
  "scripts": {
59
- "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
60
- "test": "node --conditions=import --import tsx --test \"test/{profile,domains,cdp,daemon,util}/**/*-test.ts\"",
62
+ "typecheck": "tsc -b tsconfig.page.json && tsc --noEmit && tsc -p tsconfig.test.json",
63
+ "test": "node --conditions=import --import tsx --test \"test/{profile,domains,cdp,daemon,util,types}/**/*-test.ts\"",
61
64
  "boundaries": "tsx scripts/check-boundaries.ts",
62
- "check": "npm run typecheck && npm run boundaries && npm test",
63
- "prepublishOnly": "tsc --noEmit"
65
+ "gen:skill": "node --conditions=import --import tsx scripts/gen-skill.ts",
66
+ "check": "npm run typecheck && npm run boundaries && npm run lint && npm test",
67
+ "prepublishOnly": "npm run typecheck",
68
+ "lint": "eslint .",
69
+ "lint:fix": "eslint . --fix"
64
70
  },
65
71
  "publishConfig": {
66
72
  "access": "public"
@@ -41,6 +41,61 @@ Pass `@(x,y)` from `browser_snapshot` straight to `browser_click`. No screenshot
41
41
 
42
42
  `browser_web_search` and `browser_read_page` each run in their own isolated tab and never touch the user's current tab. Pair them for a research question: search for candidate URLs, then read the promising ones.
43
43
 
44
+ ## Tool reference
45
+
46
+ Every tool, its concurrency lane, and a one-line summary. `serialized` tools queue through one
47
+ shared mutation lane; `parallel` tools may run alongside them. The decision tree above says which
48
+ to reach for first; the tool descriptions and parameter schemas carry per-tool detail.
49
+
50
+ <!-- tools:start -->
51
+ Generated from `src/registry.ts` by `npm run gen:skill`; do not edit by hand.
52
+
53
+ | Tool | Lane | Summary |
54
+ |---|---|---|
55
+ | `browser_clear_headers` | serialized | Clear extra request headers from the current owned tab |
56
+ | `browser_click` | serialized | Click an element by ref (preferred) or pixel coordinates |
57
+ | `browser_close_tab` | serialized | Close an owned tab |
58
+ | `browser_console` | parallel | Read JS errors / console output on the current tab |
59
+ | `browser_current_tab` | parallel | Get current tab info |
60
+ | `browser_dispatch_key` | serialized | Dispatch a DOM KeyboardEvent on an element by ref (preferred) or selector |
61
+ | `browser_download` | serialized | Configure download directory |
62
+ | `browser_drag_and_drop` | serialized | Drag and drop between two coordinates |
63
+ | `browser_execute_js` | parallel | Execute JS in the page — surgical DOM reads (preferred over screenshot) |
64
+ | `browser_fill` | serialized | Fill a form field by ref (preferred) or selector (works with React/Vue controlled inputs) |
65
+ | `browser_fill_form` | serialized | Fill many form fields at once by ref — React-safe, self-confirming |
66
+ | `browser_focus` | serialized | Focus an element by ref (preferred) or selector |
67
+ | `browser_go_back` | serialized | Go back one page |
68
+ | `browser_go_forward` | serialized | Go forward one page |
69
+ | `browser_handle_dialog` | serialized | Accept or dismiss a JS dialog |
70
+ | `browser_http_get` | parallel | HTTP GET (outside browser; for APIs/static pages) |
71
+ | `browser_list_tabs` | parallel | List browser tabs |
72
+ | `browser_navigate` | serialized | Navigate to a URL |
73
+ | `browser_network_requests` | parallel | Inspect recent network requests on the current page |
74
+ | `browser_new_tab` | serialized | Open a new tab |
75
+ | `browser_open_urls` | serialized | Open multiple URLs in new tabs (parallel) |
76
+ | `browser_page_info` | parallel | Get current page URL, title, viewport, and scroll position |
77
+ | `browser_press_key` | serialized | Press a key (Enter, Tab, Escape, arrows, or any character) |
78
+ | `browser_print_to_pdf` | serialized | Print the current page to PDF |
79
+ | `browser_read_page` | parallel | Read a page's main content as clean text |
80
+ | `browser_reload` | serialized | Reload the page |
81
+ | `browser_run_script` | parallel | Run a temporary script with daemon + Node access (security-bounded) |
82
+ | `browser_screenshot` | parallel | Capture a screenshot — visual verification only |
83
+ | `browser_scroll` | serialized | Scroll the page (deltaY positive=down, negative=up) |
84
+ | `browser_select_option` | serialized | Select an option in a native <select> by ref (preferred) or selector |
85
+ | `browser_set_checked` | serialized | Set a checkbox/radio checked state by ref |
86
+ | `browser_set_headers` | serialized | Set extra request headers on the current owned tab |
87
+ | `browser_setup` | parallel | Initialize browser connection |
88
+ | `browser_snapshot` | parallel | Get accessibility-tree snapshot with stable element refs (default for page inspection) |
89
+ | `browser_switch_tab` | serialized | Switch tabs |
90
+ | `browser_type` | serialized | Type text into the focused element |
91
+ | `browser_upload_file` | serialized | Upload a file to a file input by ref (preferred) or selector |
92
+ | `browser_viewport_resize` | serialized | Resize the viewport (responsive testing) |
93
+ | `browser_wait` | parallel | Wait N seconds |
94
+ | `browser_wait_for` | parallel | Wait for an element or text to appear (or an element to disappear) |
95
+ | `browser_wait_for_load` | serialized | Wait for the page to finish loading |
96
+ | `browser_web_search` | serialized | Search the web for a query |
97
+ <!-- tools:end -->
98
+
44
99
  ## Connection Setup
45
100
 
46
101
  Browser control is **on-demand** — the daemon does NOT start automatically.
@@ -111,11 +166,11 @@ When a workflow repeats 3+ times or needs Node.js APIs, write a script to disk a
111
166
 
112
167
  ## Troubleshooting: "Could not start the browser daemon"
113
168
 
114
- `browser_setup` spawns the daemon as `node <tsx cli> <pkg>/src/daemon/index.ts` with `stdio: "ignore"` (tsx is resolved from the package directory upward, so in pi's hoisted install it sits beside the package rather than inside it). A crash before the socket binds is completely silent and all you see is `Could not start the browser daemon. Check /tmp/pi-browser-daemon.sock.`
169
+ `browser_setup` spawns the daemon as `node <tsx cli> <pkg>/src/daemon/main.ts` with `stdio: "ignore"` (tsx is resolved from the package directory upward, so in pi's hoisted install it sits beside the package rather than inside it). A crash before the socket binds is completely silent and all you see is `Could not start the browser daemon. Check /tmp/pi-browser-daemon.sock.`
115
170
 
116
171
  Run it in the foreground to see the real error (it prints whether it reached Chrome):
117
172
 
118
- cd <pkg> && node "$(node -p "require.resolve('tsx/cli')")" src/daemon/index.ts
173
+ cd <pkg> && node "$(node -p "require.resolve('tsx/cli')")" src/daemon/main.ts
119
174
 
120
175
  Known cause (package 0.11.1): `Error: Cannot find module 'typebox'` at `src/cdp/discovery.ts`. The package declares `typebox` in `peerDependencies` and `devDependencies`, and pi installs packages with dev and peer dependencies omitted, so the tree has no physical copy. It still resolves inside the pi extension host, which maps `typebox` to pi's bundled copy, but not in the spawned daemon process, which resolves modules from the package directory upward. The daemon dies, no socket appears, and every browser tool reports `not_connected`.
121
176
 
@@ -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,
@@ -219,9 +335,60 @@ export const COMMANDS = {
219
335
  Empty,
220
336
  ),
221
337
 
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),
338
+ "Input.dispatchMouseEvent": cmd(
339
+ Type.Object({
340
+ type: MouseEventType,
341
+ x: Type.Number(),
342
+ y: Type.Number(),
343
+ // Bit field: 1=Alt, 2=Ctrl, 4=Meta/Command, 8=Shift.
344
+ modifiers: Type.Optional(Type.Integer()),
345
+ timestamp: Type.Optional(Type.Number()),
346
+ button: Type.Optional(MouseButton),
347
+ // Bit field of pressed buttons: 1=Left, 2=Right, 4=Middle, 8=Back, 16=Forward.
348
+ buttons: Type.Optional(Type.Integer()),
349
+ clickCount: Type.Optional(Type.Integer()),
350
+ force: Type.Optional(Type.Number()),
351
+ tangentialPressure: Type.Optional(Type.Number()),
352
+ tiltX: Type.Optional(Type.Number()),
353
+ tiltY: Type.Optional(Type.Number()),
354
+ twist: Type.Optional(Type.Integer()),
355
+ deltaX: Type.Optional(Type.Number()),
356
+ deltaY: Type.Optional(Type.Number()),
357
+ pointerType: Type.Optional(Type.Union([Type.Literal("mouse"), Type.Literal("pen")])),
358
+ }),
359
+ Empty,
360
+ ),
361
+ "Input.dispatchKeyEvent": cmd(
362
+ Type.Object({
363
+ type: KeyEventType,
364
+ modifiers: Type.Optional(Type.Integer()),
365
+ timestamp: Type.Optional(Type.Number()),
366
+ text: Type.Optional(Type.String()),
367
+ unmodifiedText: Type.Optional(Type.String()),
368
+ keyIdentifier: Type.Optional(Type.String()),
369
+ code: Type.Optional(Type.String()),
370
+ key: Type.Optional(Type.String()),
371
+ windowsVirtualKeyCode: Type.Optional(Type.Integer()),
372
+ nativeVirtualKeyCode: Type.Optional(Type.Integer()),
373
+ autoRepeat: Type.Optional(Type.Boolean()),
374
+ isKeypad: Type.Optional(Type.Boolean()),
375
+ isSystemKey: Type.Optional(Type.Boolean()),
376
+ // 0=Standard, 1=Left, 2=Right, 3=Numpad.
377
+ location: Type.Optional(Type.Integer()),
378
+ commands: Type.Optional(Type.Array(Type.String())),
379
+ }),
380
+ Empty,
381
+ ),
382
+ "Input.dispatchDragEvent": cmd(
383
+ Type.Object({
384
+ type: DragEventType,
385
+ x: Type.Number(),
386
+ y: Type.Number(),
387
+ data: DragData,
388
+ modifiers: Type.Optional(Type.Integer()),
389
+ }),
390
+ Empty,
391
+ ),
225
392
  "Input.insertText": cmd(Type.Object({ text: Type.String() }), Empty),
226
393
 
227
394
  "Network.getResponseBody": cmd(
@@ -232,9 +399,27 @@ export const COMMANDS = {
232
399
  Type.Object({ headers: Type.Record(Type.String(), Type.String()) }),
233
400
  Empty,
234
401
  ),
235
- "Emulation.setDeviceMetricsOverride": cmd(Type.Object({}, { additionalProperties: true }), Empty),
402
+ "Emulation.setDeviceMetricsOverride": cmd(
403
+ Type.Object({
404
+ // 0 disables the override for that dimension.
405
+ width: Type.Integer(),
406
+ height: Type.Integer(),
407
+ // 0 disables the override.
408
+ deviceScaleFactor: Type.Number(),
409
+ mobile: Type.Boolean(),
410
+ scale: Type.Optional(Type.Number()),
411
+ screenWidth: Type.Optional(Type.Integer()),
412
+ screenHeight: Type.Optional(Type.Integer()),
413
+ positionX: Type.Optional(Type.Integer()),
414
+ positionY: Type.Optional(Type.Integer()),
415
+ dontSetVisibleSize: Type.Optional(Type.Boolean()),
416
+ screenOrientation: Type.Optional(ScreenOrientation),
417
+ viewport: Type.Optional(Viewport),
418
+ }),
419
+ Empty,
420
+ ),
236
421
  "Accessibility.getFullAXTree": cmd(
237
- Type.Object({ depth: Type.Optional(Type.Number()) }, { additionalProperties: true }),
422
+ Type.Object({ depth: Type.Optional(Type.Number()), frameId: Type.Optional(Type.String()) }),
238
423
  Type.Object({ nodes: Type.Array(AxNode) }),
239
424
  ),
240
425
  };
@@ -247,14 +432,15 @@ export const decodeResult = <M extends CdpMethod>(
247
432
  method: M,
248
433
  raw: unknown,
249
434
  ): Result<ResultOf<M>, CdpError> => {
435
+ // `method` is a key of COMMANDS, so the lookup cannot miss.
250
436
  const spec = COMMANDS[method];
251
- if (spec === undefined) {
252
- return err(cdpError("invalid_response", `unknown CDP method`, method));
253
- }
254
437
  if (!spec.validate.Check(raw)) {
255
438
  const why = spec.validate.Errors(raw).map((e) => e.message).join("; ");
256
439
  return err(cdpError("invalid_response", why, method));
257
440
  }
258
- // Guarded assertion: the check above proves `raw` matches COMMANDS[method].result, which TS cannot correlate from a generic key.
441
+ // Guarded assertion: the check above proves `raw` matches COMMANDS[method].result. TS resolves
442
+ // `spec.validate` through a generic key to the union of every validator, so the predicate
443
+ // narrows `raw` to the union of every result type rather than to ResultOf<M>.
444
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- sanctioned decoder, see scripts/boundary-allowlist.ts
259
445
  return ok(raw as ResultOf<M>);
260
446
  };
@@ -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":