pi-browser-harness 0.10.2 → 0.12.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 (91) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +21 -10
  3. package/package.json +6 -3
  4. package/skills/pi-browser-harness/SKILL.md +30 -1
  5. package/src/cdp/commands.ts +260 -0
  6. package/src/cdp/console-buffer.ts +10 -85
  7. package/src/cdp/discovery.ts +31 -71
  8. package/src/cdp/errors.ts +4 -0
  9. package/src/cdp/events.ts +170 -0
  10. package/src/cdp/network-buffer.ts +42 -109
  11. package/src/cdp/ownership.ts +0 -17
  12. package/src/cdp/record-store.ts +61 -0
  13. package/src/cdp/session.ts +205 -193
  14. package/src/cdp/target-factory.ts +10 -80
  15. package/src/cdp/types.ts +21 -12
  16. package/src/client.ts +39 -95
  17. package/src/daemon/bridge.ts +375 -265
  18. package/src/daemon/index.ts +0 -28
  19. package/src/daemon/protocol.ts +6 -68
  20. package/src/daemon/queue.ts +85 -0
  21. package/src/daemon/server.ts +24 -99
  22. package/src/daemon/spawn.ts +90 -82
  23. package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
  24. package/src/domains/ax-tree.ts +179 -0
  25. package/src/domains/box.ts +51 -0
  26. package/src/domains/cdp-call.ts +55 -0
  27. package/src/domains/click.ts +19 -37
  28. package/src/domains/console.ts +48 -4
  29. package/src/domains/dialog.ts +8 -5
  30. package/src/domains/drag.ts +8 -7
  31. package/src/domains/element-call.ts +117 -0
  32. package/src/domains/files.ts +75 -30
  33. package/src/domains/fill-engine.ts +80 -0
  34. package/src/domains/form-batch.ts +64 -0
  35. package/src/domains/form.ts +115 -112
  36. package/src/domains/headers.ts +89 -0
  37. package/src/domains/history.ts +16 -12
  38. package/src/domains/isolated-tab.ts +26 -55
  39. package/src/domains/js.ts +32 -51
  40. package/src/domains/keyboard.ts +38 -34
  41. package/src/domains/navigate.ts +16 -26
  42. package/src/domains/network.ts +61 -13
  43. package/src/domains/page.ts +25 -10
  44. package/src/domains/readpage/read-page.ts +74 -20
  45. package/src/domains/readpage/readability.ts +0 -24
  46. package/src/domains/ref-resolve.ts +24 -58
  47. package/src/domains/render.ts +6 -27
  48. package/src/domains/screenshot-capture.ts +80 -0
  49. package/src/domains/screenshot.ts +9 -90
  50. package/src/domains/scroll.ts +10 -12
  51. package/src/domains/search/google-serp.ts +0 -29
  52. package/src/domains/search/web-search.ts +48 -21
  53. package/src/domains/setup.ts +1 -13
  54. package/src/domains/snapshot.ts +36 -280
  55. package/src/domains/tabs.ts +15 -16
  56. package/src/domains/target.ts +35 -0
  57. package/src/domains/viewport.ts +5 -4
  58. package/src/index.ts +23 -69
  59. package/src/profile/bind.ts +8 -57
  60. package/src/profile/browser-process.ts +75 -126
  61. package/src/profile/command.ts +50 -29
  62. package/src/profile/launch.ts +22 -40
  63. package/src/profile/list.ts +14 -54
  64. package/src/profile/paths.ts +2 -65
  65. package/src/profile/store.ts +35 -56
  66. package/src/prompt.ts +22 -122
  67. package/src/registry.ts +33 -33
  68. package/src/schemas/parse.ts +23 -0
  69. package/src/setup.ts +0 -25
  70. package/src/state.ts +48 -18
  71. package/src/util/debug.ts +8 -0
  72. package/src/util/guards.ts +30 -0
  73. package/src/util/js-template.ts +1 -16
  74. package/src/util/mutex.ts +0 -5
  75. package/src/util/paths.ts +11 -1
  76. package/src/util/result.ts +0 -6
  77. package/src/util/sharp-shim.ts +12 -19
  78. package/src/util/tool.ts +17 -42
  79. package/src/util/truncate.ts +6 -0
  80. package/tsconfig.json +1 -16
  81. package/skills/deep-research/SKILL.md +0 -66
  82. package/src/cdp/event-queue.ts +0 -128
  83. package/src/cdp/transport.ts +0 -156
  84. package/src/cdp/window-binding.ts +0 -15
  85. package/src/deep-research.ts +0 -41
  86. package/src/domains/forms.ts +0 -355
  87. package/src/domains/readpage/capture.ts +0 -46
  88. package/src/domains/search/extract.ts +0 -44
  89. package/src/profile/picker.ts +0 -73
  90. package/src/util/keycodes.ts +0 -10
  91. package/src/util/time.ts +0 -29
@@ -1,17 +1,7 @@
1
- /**
2
- * Daemon lifecycle helpers — spawn detection and lazy launch.
3
- *
4
- * The daemon is a standalone long-lived process that owns the single WebSocket
5
- * to Chrome. pi instances connect to it via a Unix domain socket. This module
6
- * provides the plumbing to detect a running daemon and spawn one if needed.
7
- *
8
- * Reference:
9
- * - child_process.spawn detached: https://nodejs.org/docs/latest-v22.x/api/child_process.html#optionsdetached
10
- */
11
-
12
1
  import { spawn, type ChildProcess } from "node:child_process";
2
+ import { accessSync } from "node:fs";
13
3
  import { access, unlink } from "node:fs/promises";
14
- import { existsSync } from "node:fs";
4
+ import { createRequire } from "node:module";
15
5
  import { fileURLToPath } from "node:url";
16
6
  import { dirname, join } from "node:path";
17
7
  import { createConnection } from "node:net";
@@ -19,36 +9,31 @@ import { DAEMON_SOCKET_PATH, serialize, type WireControl } from "./protocol";
19
9
 
20
10
  const moduleDir = dirname(fileURLToPath(import.meta.url));
21
11
 
12
+ const requireFromHere = createRequire(import.meta.url);
13
+
22
14
  const isWindows = process.platform === "win32";
23
15
 
24
- /**
25
- * Remove a stale POSIX socket file. On Windows the daemon endpoint is a named
26
- * pipe, which is not a filesystem entry and is torn down automatically when the
27
- * owning process exits — so there is nothing to unlink, and calling unlink on a
28
- * pipe path throws. This is a no-op on Windows.
29
- */
16
+ // No-op on Windows: a named pipe is not a filesystem entry and unlinking a pipe path throws.
30
17
  const cleanupStaleSocket = (): void => {
31
18
  if (isWindows) return;
32
19
  unlink(DAEMON_SOCKET_PATH).catch(() => {});
33
20
  };
34
21
 
35
- /**
36
- * Check whether the daemon is running by probing its Unix socket.
37
- * A file-existence check is unreliable — a crashed daemon leaves a stale socket.
38
- * We connect, send a register, wait for the registered ack, then disconnect.
39
- * On failure, clean up the stale socket so ensureDaemon can spawn a fresh one.
40
- */
41
- export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
42
- // On POSIX, a missing socket file means the daemon definitely isn't running,
43
- // so we can short-circuit. Windows named pipes are not filesystem entries —
44
- // access() always fails there — so skip the pre-check and probe directly.
45
- if (!isWindows) {
46
- try {
47
- await access(DAEMON_SOCKET_PATH);
48
- } catch {
49
- return false;
50
- }
22
+ export const daemonSocketMayExist = async (
23
+ platform: NodeJS.Platform = process.platform,
24
+ socketPath: string = DAEMON_SOCKET_PATH,
25
+ ): Promise<boolean> => {
26
+ if (platform === "win32") return true;
27
+ try {
28
+ await access(socketPath);
29
+ return true;
30
+ } catch {
31
+ return false;
51
32
  }
33
+ };
34
+
35
+ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
36
+ if (!(await daemonSocketMayExist())) return false;
52
37
 
53
38
  return new Promise((resolve) => {
54
39
  let settled = false;
@@ -61,7 +46,6 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
61
46
  const sock = createConnection(DAEMON_SOCKET_PATH);
62
47
  const timer = setTimeout(() => {
63
48
  sock.destroy();
64
- // ponytail: stale socket cleanup; ENOENT = already gone, fine
65
49
  cleanupStaleSocket();
66
50
  done(false);
67
51
  }, timeoutMs);
@@ -75,7 +59,6 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
75
59
  const text = chunk.toString();
76
60
  if (text.includes('"registered"')) {
77
61
  clearTimeout(timer);
78
- // Send deregister so the probe doesn't hog a slot
79
62
  const deregMsg: WireControl = { type: "control", action: "deregister", clientId: "_liveness_probe" };
80
63
  try { sock.write(serialize(deregMsg) + "\n"); } catch {}
81
64
  sock.destroy();
@@ -86,13 +69,11 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
86
69
  sock.on("error", () => {
87
70
  clearTimeout(timer);
88
71
  sock.destroy();
89
- // ponytail: connection refused = daemon dead, clean up stale socket
90
72
  cleanupStaleSocket();
91
73
  done(false);
92
74
  });
93
75
 
94
- // close without 'registered' ack = daemon alive but rejected us (e.g. max clients);
95
- // don't unlink — the daemon is still using that socket.
76
+ // Closed without a 'registered' ack: the daemon is alive but rejected us, so do NOT unlink its socket.
96
77
  sock.on("close", () => {
97
78
  clearTimeout(timer);
98
79
  done(false);
@@ -100,66 +81,93 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
100
81
  });
101
82
  };
102
83
 
103
- /**
104
- * Spawn the daemon as a detached child process. The daemon will continue
105
- * running after the parent pi process exits.
106
- *
107
- * The daemon entry point is resolved relative to this module. When running
108
- * from source (tsx), the entry is src/daemon/index.ts. When running compiled,
109
- * it's dist/daemon/index.js.
110
- *
111
- * Returns the child process handle (caller should call child.unref()).
112
- */
113
- export const spawnDaemon = (): ChildProcess | null => {
114
- // Resolve the daemon entry point and tsx binary.
84
+ // Walks upward like Node's own node_modules lookup. Exported with an injectable start so the
85
+ // hoisted layout pi uses can be exercised against a fixture instead of this checkout.
86
+ export const resolveTsxCliFromFilesystem = (startDir: string = moduleDir): string | null => {
87
+ let dir = startDir;
88
+
89
+ while (true) {
90
+ const candidate = join(dir, "node_modules", "tsx", "dist", "cli.mjs");
91
+ try {
92
+ accessSync(candidate);
93
+ return candidate;
94
+ } catch {
95
+ // Keep walking toward the filesystem root. This mirrors Node's node_modules lookup
96
+ // without relying on createRequire().resolve(), which is broken for external modules
97
+ // loaded through jiti/static inside a Bun-compiled executable such as pi.exe.
98
+ }
99
+
100
+ const parent = dirname(dir);
101
+ if (parent === dir) return null;
102
+ dir = parent;
103
+ }
104
+ };
105
+
106
+ const resolveTsxCli = (): string | null => {
107
+ try {
108
+ const resolved = requireFromHere.resolve("tsx/cli");
109
+ // A compiled runtime may theoretically return a virtual path that a separately spawned
110
+ // Node process cannot read. Only accept a resolver result that exists on the host FS.
111
+ accessSync(resolved);
112
+ return resolved;
113
+ } catch {
114
+ return resolveTsxCliFromFilesystem();
115
+ }
116
+ };
117
+
118
+ export const daemonNodeExecutable = (
119
+ env: NodeJS.ProcessEnv = process.env,
120
+ versions: Readonly<Record<string, string | undefined>> = process.versions,
121
+ platform: NodeJS.Platform = process.platform,
122
+ execPath: string = process.execPath,
123
+ ): string => {
124
+ const override = env["PI_BROWSER_NODE"];
125
+ if (override) return override;
126
+
127
+ // Under normal Node keep the exact executable that launched pi/the extension. Under Bun,
128
+ // process.execPath is bun/pi.exe, not Node, so run the tsx CLI with the required Node runtime.
129
+ if ("bun" in versions) return platform === "win32" ? "node.exe" : "node";
130
+
131
+ return execPath;
132
+ };
133
+
134
+ export const daemonSpawnCommand = (): { readonly command: string; readonly args: ReadonlyArray<string> } | null => {
115
135
  const daemonScript = join(moduleDir, "index.ts");
136
+ const tsxCli = resolveTsxCli();
137
+ if (!tsxCli) return null;
138
+
139
+ return {
140
+ command: daemonNodeExecutable(),
141
+ args: [tsxCli, daemonScript],
142
+ };
143
+ };
116
144
 
117
- // On Windows, npm installs binaries as `.cmd` shims, not bare executables,
118
- // and the npx fallback is `npx.cmd`. A `.cmd` file can only be launched
119
- // through a shell (spawning it directly yields EINVAL), so set shell: true
120
- // whenever we invoke one.
121
- const tsxBinBase = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx");
122
- const tsxBin = isWindows ? `${tsxBinBase}.cmd` : tsxBinBase;
123
- const hasLocalTsx = existsSync(tsxBin);
124
-
125
- // Use the local tsx binary to run the daemon TypeScript source directly.
126
- // The spawned process inherits the parent's env so module resolution works.
127
- // Fall back to `npx tsx` if the local binary isn't available.
128
- const cmd = hasLocalTsx ? tsxBin : isWindows ? "npx.cmd" : "npx";
129
- const args = hasLocalTsx ? [daemonScript] : ["tsx", daemonScript];
130
-
131
- // With shell: true, Node passes the command line to the shell verbatim
132
- // without quoting, so any path containing spaces (e.g. C:\Users\Some Name)
133
- // would be split into multiple tokens. Wrap each token in double quotes on
134
- // Windows to keep space-containing paths intact.
135
- const quote = (s: string): string => (isWindows ? `"${s}"` : s);
136
-
137
- const child = spawn(quote(cmd), args.map(quote), {
145
+ export const spawnDaemon = (): ChildProcess | null => {
146
+ const spec = daemonSpawnCommand();
147
+ if (!spec) return null;
148
+
149
+ const child = spawn(spec.command, [...spec.args], {
138
150
  detached: true,
139
151
  stdio: "ignore",
140
152
  env: { ...process.env },
141
- // `.cmd` shims (local tsx or npx.cmd on Windows) must run via a shell.
142
- shell: isWindows,
143
- windowsVerbatimArguments: isWindows,
153
+ windowsHide: true,
144
154
  });
145
155
 
156
+ // `spawn` reports a missing executable (a bare `node` that is not on PATH under a Bun host)
157
+ // asynchronously via `error`; without a listener that is an uncaught exception in pi.
158
+ // ensureDaemon's poll loop reports the failure as a timeout, which is what the user sees.
159
+ child.on("error", () => {});
160
+
146
161
  child.unref();
147
162
  return child;
148
163
  };
149
164
 
150
- /**
151
- * Ensure the daemon is running. If not, spawn it and wait (up to timeoutMs)
152
- * for the socket file to appear.
153
- *
154
- * Returns true if the daemon is running (or was successfully spawned).
155
- */
156
165
  export const ensureDaemon = async (timeoutMs = 10_000): Promise<boolean> => {
157
166
  if (await isDaemonRunning()) return true;
158
167
 
159
168
  const child = spawnDaemon();
160
169
  if (!child) return false;
161
170
 
162
- // Poll for the socket file to appear
163
171
  const deadline = Date.now() + timeoutMs;
164
172
  while (Date.now() < deadline) {
165
173
  if (await isDaemonRunning()) return true;
@@ -1,34 +1,19 @@
1
- /**
2
- * DaemonTransport — CdpTransport backed by a Unix socket to the browser daemon.
3
- *
4
- * Satisfies the exact CdpTransport interface so BrowserClient, CdpSession, and
5
- * all domain tools work without changes. Internally uses newline-delimited JSON
6
- * over a Unix domain socket instead of a WebSocket to Chrome.
7
- *
8
- * References:
9
- * - CdpTransport interface: src/cdp/transport.ts
10
- * - net.createConnection IPC: https://nodejs.org/docs/latest-v22.x/api/net.html#netcreateconnection
11
- * - readline.createInterface: https://nodejs.org/docs/latest-v22.x/api/readline.html#readlinecreateinterfaceoptions
12
- */
13
-
14
1
  import { createConnection, type Socket } from "node:net";
15
2
  import { createInterface } from "node:readline";
16
3
  import { type Result, err, ok } from "../util/result";
17
- import { type CdpError, cdpError } from "../cdp/errors";
18
- import type { CdpTransport } from "../cdp/transport";
4
+ import { type CdpError, cdpError, classifyRemoteError } from "../cdp/errors";
5
+ import { DEFAULT_TIMEOUT_MS, type CdpTransport } from "../cdp/types";
19
6
  import type { CdpEvent } from "../cdp/types";
20
- import { type Pending, makeEventQueue, makeOnClose, rejectAllPending, sendWithTimeout } from "./event-queue";
7
+ import { type Pending, makeEventQueue, makeOnClose, rejectAllPending, sendWithTimeout } from "./queue";
21
8
  import {
22
9
  DAEMON_SOCKET_PATH,
23
10
  type WireRequest,
24
11
  type WireControl,
25
12
  deserialize,
26
13
  serialize,
27
- } from "../daemon/protocol";
28
-
29
- // ── Implementation ─────────────────────────────────────────────────────────────
14
+ } from "./protocol";
30
15
 
31
- export const createDaemonTransport = (clientId: string): CdpTransport => {
16
+ export const createDaemonTransport = (clientId: string, socketPath: string = DAEMON_SOCKET_PATH): CdpTransport => {
32
17
  let socket: Socket | null = null;
33
18
  let rl: ReturnType<typeof createInterface> | null = null;
34
19
  let queue = makeEventQueue();
@@ -49,10 +34,7 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
49
34
  for (const cb of closeListeners) cb();
50
35
  };
51
36
 
52
- // ── Connect ────────────────────────────────────────────────────────────
53
-
54
37
  const connect = (_url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>> => {
55
- // url is ignored — we always connect to the daemon socket
56
38
  const timeoutMs = opts?.timeoutMs ?? 10_000;
57
39
 
58
40
  if (socket && !socket.destroyed && registered) {
@@ -67,21 +49,28 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
67
49
  resolve(r);
68
50
  };
69
51
 
52
+ let sock: Socket;
70
53
  try {
71
- socket = createConnection(DAEMON_SOCKET_PATH);
54
+ sock = createConnection(socketPath);
72
55
  } catch (e) {
73
56
  settle(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e))));
74
57
  return;
75
58
  }
59
+ socket = sock;
60
+ // The client is kept connected across sessions on purpose (index.ts session_shutdown), so this
61
+ // socket must not be what keeps the process alive: in `pi -p` it was the last handle standing
62
+ // and pi never exited (#39). In-flight requests hold their own ref'd timers, so nothing a
63
+ // caller is awaiting can be dropped by this.
64
+ sock.unref();
76
65
 
77
66
  const connectTimer = setTimeout(() => {
78
67
  cleanup("Connection timeout");
79
68
  settle(err(cdpError("timeout", `Daemon connection timed out after ${timeoutMs}ms`)));
80
69
  }, timeoutMs);
81
70
 
82
- socket.on("connect", () => {
71
+ sock.on("connect", () => {
83
72
  clearTimeout(connectTimer);
84
- rl = createInterface({ input: socket!, crlfDelay: Infinity });
73
+ rl = createInterface({ input: sock, crlfDelay: Infinity });
85
74
 
86
75
  rl.on("line", (line: string) => {
87
76
  const msg = deserialize(line);
@@ -107,13 +96,7 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
107
96
  clearTimeout(p.timer);
108
97
 
109
98
  if (msg.error) {
110
- p.resolve(err(cdpError(
111
- msg.error.message.includes("Session with given id not found")
112
- ? "session_not_found"
113
- : "remote_error",
114
- msg.error.message,
115
- p.method,
116
- )));
99
+ p.resolve(err(cdpError(classifyRemoteError(msg.error.message), msg.error.message, p.method)));
117
100
  } else {
118
101
  p.resolve(ok(msg.result));
119
102
  }
@@ -130,23 +113,19 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
130
113
  }
131
114
  });
132
115
 
133
- socket!.on("error", () => {
134
- // Errors are surfaced via 'close'
135
- });
116
+ sock.on("error", () => {});
136
117
 
137
- socket!.on("close", () => {
118
+ sock.on("close", () => {
138
119
  clearTimeout(connectTimer);
139
120
  cleanup("Daemon socket closed");
140
- // If connect hasn't settled yet, settle with error
141
121
  settle(err(cdpError("transport_closed", "Daemon socket closed before registration")));
142
122
  });
143
123
 
144
- // Send registration
145
124
  const regMsg: WireControl = { type: "control", action: "register", clientId };
146
- socket!.write(serialize(regMsg) + "\n");
125
+ sock.write(serialize(regMsg) + "\n");
147
126
  });
148
127
 
149
- socket.on("error", (e: NodeJS.ErrnoException) => {
128
+ sock.on("error", (e: NodeJS.ErrnoException) => {
150
129
  clearTimeout(connectTimer);
151
130
  const msg = e.code === "ENOENT"
152
131
  ? "Daemon not running — socket not found"
@@ -156,8 +135,6 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
156
135
  });
157
136
  };
158
137
 
159
- // ── Close ──────────────────────────────────────────────────────────────
160
-
161
138
  const close = async (): Promise<void> => {
162
139
  if (registered && socket && !socket.destroyed) {
163
140
  const dereg: WireControl = { type: "control", action: "deregister", clientId };
@@ -166,17 +143,16 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
166
143
  cleanup("close() called");
167
144
  };
168
145
 
169
- // ── Request ────────────────────────────────────────────────────────────
170
-
171
146
  const request = (
172
147
  method: string,
173
148
  params: Record<string, unknown>,
174
149
  opts?: { sessionId?: string | null; timeoutMs?: number },
175
150
  ): Promise<Result<unknown, CdpError>> => {
176
- const timeoutMs = opts?.timeoutMs ?? 15_000;
151
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
177
152
  const sessionId = opts?.sessionId ?? null;
178
153
 
179
- if (!socket || socket.destroyed || !registered) {
154
+ const sock = socket;
155
+ if (sock === null || sock.destroyed || !registered) {
180
156
  return Promise.resolve(err(cdpError("transport_closed", "Daemon not connected", method)));
181
157
  }
182
158
 
@@ -189,15 +165,11 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
189
165
  ...(sessionId !== null && sessionId !== undefined ? { sessionId } : {}),
190
166
  };
191
167
 
192
- return sendWithTimeout(pending, id, method, timeoutMs, "Daemon", () => socket!.write(serialize(req) + "\n"));
168
+ return sendWithTimeout(pending, id, method, timeoutMs, "Daemon", () => sock.write(serialize(req) + "\n"));
193
169
  };
194
170
 
195
- // ── Events ─────────────────────────────────────────────────────────────
196
-
197
171
  const events = (): AsyncIterable<CdpEvent> => queue.iter;
198
172
 
199
- // ── State ──────────────────────────────────────────────────────────────
200
-
201
173
  const state = (): "open" | "closed" | "connecting" => {
202
174
  if (!socket) return "closed";
203
175
  if (!registered) return "connecting";
@@ -0,0 +1,179 @@
1
+ import type { AxNodeResult } from "../cdp/commands";
2
+ import type { BrowserClient } from "../client";
3
+ import type { Box } from "./box";
4
+ import { cdpCall } from "./cdp-call";
5
+
6
+ export type RawAxNode = AxNodeResult;
7
+ type AxValue = NonNullable<RawAxNode["role"]>;
8
+
9
+ export type SlimNode = {
10
+ role: string;
11
+ name?: string;
12
+ value?: string;
13
+ description?: string;
14
+ state?: string;
15
+ children: SlimNode[];
16
+ _backendId?: number;
17
+ ref?: string;
18
+ box?: Box;
19
+ };
20
+
21
+ export const INTERACTIVE_ROLES = new Set([
22
+ "button",
23
+ "link",
24
+ "textbox",
25
+ "checkbox",
26
+ "radio",
27
+ "combobox",
28
+ "menuitem",
29
+ "tab",
30
+ "switch",
31
+ "slider",
32
+ "searchbox",
33
+ "spinbutton",
34
+ ]);
35
+
36
+ // The AX `value` is stale for freshly-typed or controlled inputs, so for these roles the live DOM property is read instead.
37
+ export const VALUE_ROLES = new Set([
38
+ "textbox",
39
+ "searchbox",
40
+ "spinbutton",
41
+ "combobox",
42
+ "checkbox",
43
+ "radio",
44
+ "switch",
45
+ "slider",
46
+ ]);
47
+
48
+ const stringOf = (v: AxValue | undefined): string | undefined => {
49
+ const x = v?.value;
50
+ if (typeof x === "string" && x !== "") return x;
51
+ if (typeof x === "number") return String(x);
52
+ return undefined;
53
+ };
54
+
55
+ const collectState = (props: RawAxNode["properties"]): string | undefined => {
56
+ if (!props || props.length === 0) return undefined;
57
+ const flags: string[] = [];
58
+ for (const p of props) {
59
+ const name = p.name;
60
+ if (!name) continue;
61
+ const raw = p.value?.value;
62
+ if (raw === true && (name === "focused" || name === "required" || name === "disabled" || name === "checked" || name === "expanded" || name === "selected" || name === "pressed" || name === "modal")) {
63
+ flags.push(name);
64
+ }
65
+ if (typeof raw === "number" && name === "level") flags.push(`level ${raw}`);
66
+ }
67
+ return flags.length > 0 ? flags.join(", ") : undefined;
68
+ };
69
+
70
+ export const buildTree = (
71
+ rawNodes: ReadonlyArray<RawAxNode>,
72
+ opts: { interestingOnly: boolean; maxNodes: number },
73
+ ): SlimNode[] => {
74
+ const byId = new Map<string, RawAxNode>();
75
+ for (const n of rawNodes) byId.set(n.nodeId, n);
76
+
77
+ const rootIds: string[] = [];
78
+ for (const n of rawNodes) {
79
+ if (!n.parentId || !byId.has(n.parentId)) rootIds.push(n.nodeId);
80
+ }
81
+
82
+ let budget = opts.maxNodes;
83
+
84
+ const slim = (node: RawAxNode): SlimNode | undefined => {
85
+ if (budget <= 0) return undefined;
86
+ if (opts.interestingOnly && node.ignored) {
87
+ // Chrome often ignores wrapper divs while their descendants remain meaningful, so recurse through an ignored node's children.
88
+ const out: SlimNode[] = [];
89
+ for (const cid of node.childIds ?? []) {
90
+ const child = byId.get(cid);
91
+ if (!child) continue;
92
+ const slimChild = slim(child);
93
+ if (slimChild) out.push(slimChild);
94
+ }
95
+ return out.length > 0 ? { role: "_hoist", children: out } : undefined;
96
+ }
97
+ budget--;
98
+ const role = stringOf(node.role) ?? "unknown";
99
+ const name = stringOf(node.name);
100
+ const value = stringOf(node.value);
101
+ const description = stringOf(node.description);
102
+ const state = collectState(node.properties);
103
+ const children: SlimNode[] = [];
104
+ for (const cid of node.childIds ?? []) {
105
+ const child = byId.get(cid);
106
+ if (!child) continue;
107
+ const slimChild = slim(child);
108
+ if (!slimChild) continue;
109
+ if (slimChild.role === "_hoist") children.push(...slimChild.children);
110
+ else children.push(slimChild);
111
+ }
112
+ const out: SlimNode = { role, children };
113
+ if (name !== undefined) out.name = name;
114
+ if (value !== undefined) out.value = value;
115
+ if (description !== undefined) out.description = description;
116
+ if (state !== undefined) out.state = state;
117
+ if (node.backendDOMNodeId !== undefined) out._backendId = node.backendDOMNodeId;
118
+ return out;
119
+ };
120
+
121
+ const result: SlimNode[] = [];
122
+ for (const rid of rootIds) {
123
+ const root = byId.get(rid);
124
+ if (!root) continue;
125
+ const s = slim(root);
126
+ if (!s) continue;
127
+ if (s.role === "_hoist") result.push(...s.children);
128
+ else result.push(s);
129
+ }
130
+ return result;
131
+ };
132
+
133
+ export const countNodes = (nodes: ReadonlyArray<SlimNode>): number => {
134
+ let n = 0;
135
+ for (const node of nodes) n += 1 + countNodes(node.children);
136
+ return n;
137
+ };
138
+
139
+ export const stripInternals = (nodes: ReadonlyArray<SlimNode>): SlimNode[] =>
140
+ nodes.map((n) => {
141
+ const { _backendId: _bid, ...rest } = n;
142
+ return { ...rest, children: stripInternals(n.children) };
143
+ });
144
+
145
+ export const collectInteractiveTargets = (
146
+ nodes: ReadonlyArray<SlimNode>,
147
+ ): Array<{ node: SlimNode; backendId: number }> => {
148
+ const out: Array<{ node: SlimNode; backendId: number }> = [];
149
+ const walk = (ns: ReadonlyArray<SlimNode>): void => {
150
+ for (const n of ns) {
151
+ if (n._backendId !== undefined && INTERACTIVE_ROLES.has(n.role)) {
152
+ out.push({ node: n, backendId: n._backendId });
153
+ }
154
+ walk(n.children);
155
+ }
156
+ };
157
+ walk(nodes);
158
+ return out;
159
+ };
160
+
161
+ export const liveValue = async (client: BrowserClient, backendId: number): Promise<string | undefined> => {
162
+ const resolved = await cdpCall(client, "DOM.resolveNode", { backendNodeId: backendId });
163
+ if (!resolved.success) return undefined;
164
+ const objectId = resolved.data.object.objectId;
165
+ if (!objectId) return undefined;
166
+ const fn = `function () {
167
+ if (this.type === "checkbox" || this.type === "radio") return this.checked ? "checked" : "unchecked";
168
+ if (typeof this.value === "string") return this.value;
169
+ return null;
170
+ }`;
171
+ const r = await cdpCall(client, "Runtime.callFunctionOn", {
172
+ objectId,
173
+ functionDeclaration: fn,
174
+ returnByValue: true,
175
+ });
176
+ if (!r.success) return undefined;
177
+ const value = r.data.result.value;
178
+ return typeof value === "string" ? value : undefined;
179
+ };
@@ -0,0 +1,51 @@
1
+ import type { BrowserClient } from "../client";
2
+ import { type Result, err, ok } from "../util/result";
3
+ import type { ToolErr } from "../util/tool";
4
+ import { cdpCall } from "./cdp-call";
5
+
6
+ export type Box = {
7
+ readonly x: number;
8
+ readonly y: number;
9
+ readonly width: number;
10
+ readonly height: number;
11
+ readonly cx: number;
12
+ readonly cy: number;
13
+ };
14
+
15
+ type BoxModel = {
16
+ readonly content: ReadonlyArray<number>;
17
+ readonly width: number;
18
+ readonly height: number;
19
+ };
20
+
21
+ export const centreOf = (model: BoxModel): Box => {
22
+ const x = model.content[0] ?? 0;
23
+ const y = model.content[1] ?? 0;
24
+ return {
25
+ x: Math.round(x),
26
+ y: Math.round(y),
27
+ width: Math.round(model.width),
28
+ height: Math.round(model.height),
29
+ cx: Math.round(x + model.width / 2),
30
+ cy: Math.round(y + model.height / 2),
31
+ };
32
+ };
33
+
34
+ export const boxOf = async (
35
+ client: BrowserClient,
36
+ backendNodeId: number,
37
+ ): Promise<Result<Box, ToolErr>> => {
38
+ const r = await cdpCall(client, "DOM.getBoxModel", { backendNodeId });
39
+ if (!r.success) return r;
40
+ const model = r.data.model;
41
+ if (model.content.length < 8) {
42
+ return err({ kind: "invalid_state", message: "DOM.getBoxModel returned an incomplete content quad" });
43
+ }
44
+ if (model.width <= 0 || model.height <= 0) {
45
+ return err({
46
+ kind: "invalid_state",
47
+ message: "element has a zero-size box and cannot be clicked",
48
+ });
49
+ }
50
+ return ok(centreOf(model));
51
+ };