pi-browser-harness 0.8.0 → 0.8.3
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 +11 -0
- package/package.json +1 -1
- package/src/client.ts +8 -6
- package/src/daemon/bridge.ts +13 -3
- package/src/daemon/spawn.ts +58 -6
- package/src/domains/form.ts +2 -0
- package/src/domains/keyboard.ts +8 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-browser-harness will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.8.1 — 2026-07-05
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`browser_dispatch_key` now populates `keyCode`/`which`** on the synthesized `KeyboardEvent` (both the ref and selector paths). Legacy React/Vue key handlers commonly branch on `e.keyCode === 13` rather than `e.key`, so Enter on tag/autocomplete inputs previously did nothing. The event remains untrusted (`isTrusted === false`), so a few libraries may still ignore it.
|
|
10
|
+
- **`browser_execute_js` IIFE auto-wrap no longer false-positives.** The wrap now triggers only when the trimmed source *starts with* a `return` statement, instead of whenever the string merely contained the substring `"return "` (which silently turned bare expressions — including ones mentioning `return` inside a string literal or comment — into `undefined`).
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **`browser_fill` guidelines** now point at `browser_dispatch_key({ ref, key: 'Enter' })` for submitting tag/autocomplete inputs (not `browser_press_key`, which targets the focused element), and at the open → `browser_snapshot` → `browser_click` recipe for custom (div-based) dropdowns.
|
|
15
|
+
|
|
5
16
|
## 0.6.0 — 2026-06-21
|
|
6
17
|
|
|
7
18
|
### Added
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -170,12 +170,14 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
170
170
|
|
|
171
171
|
const evaluateJs = async (expression: string, sessionId?: string): Promise<Result<unknown, CdpError>> => {
|
|
172
172
|
// Heuristic IIFE wrap: legacy convenience that lets agents write
|
|
173
|
-
// `return foo` instead of `(() => foo)()`.
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
173
|
+
// `return foo` instead of `(() => foo)()`. Only wrap when the trimmed
|
|
174
|
+
// source *starts with* a return statement — a bare expression, or one that
|
|
175
|
+
// merely mentions `return` inside a string literal or comment, is passed
|
|
176
|
+
// through untouched (previously any substring `"return "` triggered a wrap,
|
|
177
|
+
// silently turning such expressions into `undefined`).
|
|
178
|
+
const trimmed = expression.trim();
|
|
179
|
+
const isReturnStatement = /^return[\s(]/.test(trimmed);
|
|
180
|
+
const wrapped = isReturnStatement ? `(function(){${expression}})()` : expression;
|
|
179
181
|
const r = sessionId
|
|
180
182
|
? await session.callOnTarget("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true }, sessionId)
|
|
181
183
|
: await session.call("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true });
|
package/src/daemon/bridge.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type CdpBridge = {
|
|
|
29
29
|
* are initialized (not when Chrome is connected — that may take retries). */
|
|
30
30
|
start(): Promise<void>;
|
|
31
31
|
stop(): Promise<void>;
|
|
32
|
-
handleRequest(req: WireRequest, clientId: string, send: SendToClient): void
|
|
32
|
+
handleRequest(req: WireRequest, clientId: string, send: SendToClient): Promise<void>;
|
|
33
33
|
/** True when the Chrome WebSocket is open and ready. */
|
|
34
34
|
isAlive(): boolean;
|
|
35
35
|
recordSession(clientId: string, sessionId: string): void;
|
|
@@ -339,11 +339,21 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
339
339
|
wsUrl = null;
|
|
340
340
|
};
|
|
341
341
|
|
|
342
|
-
const handleRequest = (
|
|
342
|
+
const handleRequest = async (
|
|
343
343
|
req: WireRequest,
|
|
344
344
|
clientId: string,
|
|
345
345
|
send: SendToClient,
|
|
346
|
-
): void => {
|
|
346
|
+
): Promise<void> => {
|
|
347
|
+
// ponytail: wait for Chrome to connect (user may still be clicking "Allow").
|
|
348
|
+
// Bridge retries in background; this polls so the client doesn't fail first try.
|
|
349
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
350
|
+
const deadline = Date.now() + 15_000;
|
|
351
|
+
while (Date.now() < deadline) {
|
|
352
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
353
|
+
if (ws && ws.readyState === WebSocket.OPEN) break;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
347
357
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
348
358
|
send(clientId, {
|
|
349
359
|
type: "response",
|
package/src/daemon/spawn.ts
CHANGED
|
@@ -10,25 +10,77 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
13
|
-
import { access } from "node:fs/promises";
|
|
13
|
+
import { access, unlink } from "node:fs/promises";
|
|
14
14
|
import { existsSync } from "node:fs";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { dirname, join } from "node:path";
|
|
17
|
-
import {
|
|
17
|
+
import { createConnection } from "node:net";
|
|
18
|
+
import { DAEMON_SOCKET_PATH, serialize, type WireControl } from "./protocol";
|
|
18
19
|
|
|
19
20
|
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
|
-
* Check whether the daemon is running by
|
|
23
|
-
*
|
|
23
|
+
* Check whether the daemon is running by probing its Unix socket.
|
|
24
|
+
* A file-existence check is unreliable — a crashed daemon leaves a stale socket.
|
|
25
|
+
* We connect, send a register, wait for the registered ack, then disconnect.
|
|
26
|
+
* On failure, clean up the stale socket so ensureDaemon can spawn a fresh one.
|
|
24
27
|
*/
|
|
25
|
-
export const isDaemonRunning = async (): Promise<boolean> => {
|
|
28
|
+
export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
|
|
29
|
+
// If the socket file doesn't exist, the daemon definitely isn't running.
|
|
26
30
|
try {
|
|
27
31
|
await access(DAEMON_SOCKET_PATH);
|
|
28
|
-
return true;
|
|
29
32
|
} catch {
|
|
30
33
|
return false;
|
|
31
34
|
}
|
|
35
|
+
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
let settled = false;
|
|
38
|
+
const done = (alive: boolean): void => {
|
|
39
|
+
if (settled) return;
|
|
40
|
+
settled = true;
|
|
41
|
+
resolve(alive);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const sock = createConnection(DAEMON_SOCKET_PATH);
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
sock.destroy();
|
|
47
|
+
// ponytail: stale socket cleanup; ENOENT = already gone, fine
|
|
48
|
+
unlink(DAEMON_SOCKET_PATH).catch(() => {});
|
|
49
|
+
done(false);
|
|
50
|
+
}, timeoutMs);
|
|
51
|
+
|
|
52
|
+
sock.on("connect", () => {
|
|
53
|
+
const regMsg: WireControl = { type: "control", action: "register", clientId: "_liveness_probe" };
|
|
54
|
+
sock.write(serialize(regMsg) + "\n");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
sock.on("data", (chunk: Buffer) => {
|
|
58
|
+
const text = chunk.toString();
|
|
59
|
+
if (text.includes('"registered"')) {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
// Send deregister so the probe doesn't hog a slot
|
|
62
|
+
const deregMsg: WireControl = { type: "control", action: "deregister", clientId: "_liveness_probe" };
|
|
63
|
+
try { sock.write(serialize(deregMsg) + "\n"); } catch {}
|
|
64
|
+
sock.destroy();
|
|
65
|
+
done(true);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
sock.on("error", () => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
sock.destroy();
|
|
72
|
+
// ponytail: connection refused = daemon dead, clean up stale socket
|
|
73
|
+
unlink(DAEMON_SOCKET_PATH).catch(() => {});
|
|
74
|
+
done(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// close without 'registered' ack = daemon alive but rejected us (e.g. max clients);
|
|
78
|
+
// don't unlink — the daemon is still using that socket.
|
|
79
|
+
sock.on("close", () => {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
done(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
32
84
|
};
|
|
33
85
|
|
|
34
86
|
/**
|
package/src/domains/form.ts
CHANGED
|
@@ -119,6 +119,8 @@ export const fillTool = defineBrowserTool({
|
|
|
119
119
|
"Pass the desired value; no browser_click is needed first.",
|
|
120
120
|
"A 'ref is stale' error means the page changed — re-run browser_snapshot to get fresh refs.",
|
|
121
121
|
"Use browser_type instead only for keystroke-sensitive widgets (autocomplete, masked/segmented inputs).",
|
|
122
|
+
"Fill sets the value but does not focus or submit. To submit a tag/autocomplete input, follow with browser_dispatch_key({ ref, key: 'Enter' }) — not browser_press_key, which targets the focused element and may miss this field.",
|
|
123
|
+
"For a custom (div-based) dropdown, browser_fill won't validate against its option list — open it, run browser_snapshot, then browser_click the option's ref.",
|
|
122
124
|
"The result reports the field's value after writing and an appended page-changes diff — confirm both match what you intended before moving on.",
|
|
123
125
|
],
|
|
124
126
|
parameters: FillArgs,
|
package/src/domains/keyboard.ts
CHANGED
|
@@ -129,12 +129,17 @@ export const dispatchKeyTool = defineBrowserTool({
|
|
|
129
129
|
"PREFER `ref` from browser_snapshot over a CSS selector — survives re-renders.",
|
|
130
130
|
"Dispatches a synthetic DOM KeyboardEvent — for React/Vue synthetic event listeners. Does NOT insert text (use browser_type or browser_press_key for actual typing).",
|
|
131
131
|
"Try browser_press_key first; only use browser_dispatch_key when the page ignores raw CDP key events.",
|
|
132
|
+
"The event carries keyCode/which (e.g. 13 for Enter) for legacy handlers, but is untrusted (isTrusted === false) — a few libraries may still ignore it.",
|
|
132
133
|
"eventType defaults to 'keydown'.",
|
|
133
134
|
],
|
|
134
135
|
parameters: DispatchKeyArgs,
|
|
135
136
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
136
137
|
const eventType = args.eventType ?? "keydown";
|
|
137
138
|
const target = args.ref ?? args.selector ?? "";
|
|
139
|
+
// Legacy handlers commonly branch on e.keyCode/e.which (e.g. === 13 for
|
|
140
|
+
// Enter) rather than e.key, so populate both. virtualKeyCode maps named
|
|
141
|
+
// keys and single chars; 0 for anything unknown.
|
|
142
|
+
const code = virtualKeyCode(args.key);
|
|
138
143
|
// Ref path: dispatch on the single resolved node. Selector path: dispatch on
|
|
139
144
|
// all matches (preserves the original multi-match behavior).
|
|
140
145
|
if (args.ref !== undefined) {
|
|
@@ -142,8 +147,8 @@ export const dispatchKeyTool = defineBrowserTool({
|
|
|
142
147
|
if (!objectId.success) return objectId;
|
|
143
148
|
const r = await client.session().call("Runtime.callFunctionOn", {
|
|
144
149
|
objectId: objectId.data,
|
|
145
|
-
functionDeclaration: `function (type, key) { this.dispatchEvent(new KeyboardEvent(type, { key, bubbles: true, cancelable: true })); return 1; }`,
|
|
146
|
-
arguments: [{ value: eventType }, { value: args.key }],
|
|
150
|
+
functionDeclaration: `function (type, key, keyCode) { this.dispatchEvent(new KeyboardEvent(type, { key, keyCode, which: keyCode, bubbles: true, cancelable: true })); return 1; }`,
|
|
151
|
+
arguments: [{ value: eventType }, { value: args.key }, { value: code }],
|
|
147
152
|
returnByValue: true,
|
|
148
153
|
});
|
|
149
154
|
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
|
|
@@ -160,7 +165,7 @@ export const dispatchKeyTool = defineBrowserTool({
|
|
|
160
165
|
const els = document.querySelectorAll(${args.selector});
|
|
161
166
|
if (els.length === 0) return 0;
|
|
162
167
|
for (const el of els) {
|
|
163
|
-
el.dispatchEvent(new KeyboardEvent(${eventType}, { key: ${args.key}, bubbles: true, cancelable: true }));
|
|
168
|
+
el.dispatchEvent(new KeyboardEvent(${eventType}, { key: ${args.key}, keyCode: ${code}, which: ${code}, bubbles: true, cancelable: true }));
|
|
164
169
|
}
|
|
165
170
|
return els.length;
|
|
166
171
|
})()
|