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.
- package/CHANGELOG.md +54 -0
- package/README.md +21 -10
- package/package.json +6 -3
- package/skills/pi-browser-harness/SKILL.md +30 -1
- package/src/cdp/commands.ts +260 -0
- package/src/cdp/console-buffer.ts +10 -85
- package/src/cdp/discovery.ts +31 -71
- package/src/cdp/errors.ts +4 -0
- package/src/cdp/events.ts +170 -0
- package/src/cdp/network-buffer.ts +42 -109
- package/src/cdp/ownership.ts +0 -17
- package/src/cdp/record-store.ts +61 -0
- package/src/cdp/session.ts +205 -193
- package/src/cdp/target-factory.ts +10 -80
- package/src/cdp/types.ts +21 -12
- package/src/client.ts +39 -95
- package/src/daemon/bridge.ts +375 -265
- package/src/daemon/index.ts +0 -28
- package/src/daemon/protocol.ts +6 -68
- package/src/daemon/queue.ts +85 -0
- package/src/daemon/server.ts +24 -99
- package/src/daemon/spawn.ts +90 -82
- package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
- package/src/domains/ax-tree.ts +179 -0
- package/src/domains/box.ts +51 -0
- package/src/domains/cdp-call.ts +55 -0
- package/src/domains/click.ts +19 -37
- package/src/domains/console.ts +48 -4
- package/src/domains/dialog.ts +8 -5
- package/src/domains/drag.ts +8 -7
- package/src/domains/element-call.ts +117 -0
- package/src/domains/files.ts +75 -30
- package/src/domains/fill-engine.ts +80 -0
- package/src/domains/form-batch.ts +64 -0
- package/src/domains/form.ts +115 -112
- package/src/domains/headers.ts +89 -0
- package/src/domains/history.ts +16 -12
- package/src/domains/isolated-tab.ts +26 -55
- package/src/domains/js.ts +32 -51
- package/src/domains/keyboard.ts +38 -34
- package/src/domains/navigate.ts +16 -26
- package/src/domains/network.ts +61 -13
- package/src/domains/page.ts +25 -10
- package/src/domains/readpage/read-page.ts +74 -20
- package/src/domains/readpage/readability.ts +0 -24
- package/src/domains/ref-resolve.ts +24 -58
- package/src/domains/render.ts +6 -27
- package/src/domains/screenshot-capture.ts +80 -0
- package/src/domains/screenshot.ts +9 -90
- package/src/domains/scroll.ts +10 -12
- package/src/domains/search/google-serp.ts +0 -29
- package/src/domains/search/web-search.ts +48 -21
- package/src/domains/setup.ts +1 -13
- package/src/domains/snapshot.ts +36 -280
- package/src/domains/tabs.ts +15 -16
- package/src/domains/target.ts +35 -0
- package/src/domains/viewport.ts +5 -4
- package/src/index.ts +23 -69
- package/src/profile/bind.ts +8 -57
- package/src/profile/browser-process.ts +75 -126
- package/src/profile/command.ts +50 -29
- package/src/profile/launch.ts +22 -40
- package/src/profile/list.ts +14 -54
- package/src/profile/paths.ts +2 -65
- package/src/profile/store.ts +35 -56
- package/src/prompt.ts +22 -122
- package/src/registry.ts +33 -33
- package/src/schemas/parse.ts +23 -0
- package/src/setup.ts +0 -25
- package/src/state.ts +48 -18
- package/src/util/debug.ts +8 -0
- package/src/util/guards.ts +30 -0
- package/src/util/js-template.ts +1 -16
- package/src/util/mutex.ts +0 -5
- package/src/util/paths.ts +11 -1
- package/src/util/result.ts +0 -6
- package/src/util/sharp-shim.ts +12 -19
- package/src/util/tool.ts +17 -42
- package/src/util/truncate.ts +6 -0
- package/tsconfig.json +1 -16
- package/skills/deep-research/SKILL.md +0 -66
- package/src/cdp/event-queue.ts +0 -128
- package/src/cdp/transport.ts +0 -156
- package/src/cdp/window-binding.ts +0 -15
- package/src/deep-research.ts +0 -41
- package/src/domains/forms.ts +0 -355
- package/src/domains/readpage/capture.ts +0 -46
- package/src/domains/search/extract.ts +0 -44
- package/src/profile/picker.ts +0 -73
- package/src/util/keycodes.ts +0 -10
- package/src/util/time.ts +0 -29
package/src/daemon/spawn.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
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
|
-
|
|
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
|
|
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 "./
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
71
|
+
sock.on("connect", () => {
|
|
83
72
|
clearTimeout(connectTimer);
|
|
84
|
-
rl = createInterface({ input:
|
|
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
|
-
|
|
134
|
-
// Errors are surfaced via 'close'
|
|
135
|
-
});
|
|
116
|
+
sock.on("error", () => {});
|
|
136
117
|
|
|
137
|
-
|
|
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
|
-
|
|
125
|
+
sock.write(serialize(regMsg) + "\n");
|
|
147
126
|
});
|
|
148
127
|
|
|
149
|
-
|
|
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 ??
|
|
151
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
177
152
|
const sessionId = opts?.sessionId ?? null;
|
|
178
153
|
|
|
179
|
-
|
|
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", () =>
|
|
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
|
+
};
|