pi-browser-harness 0.6.0 → 0.8.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/package.json +1 -1
- package/src/cdp/daemon-transport.ts +5 -71
- package/src/cdp/event-queue.ts +128 -0
- package/src/cdp/session.ts +31 -0
- package/src/cdp/transport.ts +4 -69
- package/src/domains/click.ts +60 -24
- package/src/domains/files.ts +33 -4
- package/src/domains/form.ts +270 -0
- package/src/domains/keyboard.ts +61 -5
- package/src/domains/page.ts +45 -0
- package/src/domains/ref-resolve.ts +152 -0
- package/src/domains/snapshot.ts +87 -11
- package/src/prompt.ts +26 -7
- package/src/registry.ts +6 -1
package/package.json
CHANGED
|
@@ -17,6 +17,7 @@ import { type Result, err, ok } from "../util/result";
|
|
|
17
17
|
import { type CdpError, cdpError } from "../cdp/errors";
|
|
18
18
|
import type { CdpTransport } from "../cdp/transport";
|
|
19
19
|
import type { CdpEvent } from "../cdp/types";
|
|
20
|
+
import { type Pending, makeEventQueue, makeOnClose, rejectAllPending, sendWithTimeout } from "./event-queue";
|
|
20
21
|
import {
|
|
21
22
|
DAEMON_SOCKET_PATH,
|
|
22
23
|
type WireRequest,
|
|
@@ -25,47 +26,6 @@ import {
|
|
|
25
26
|
serialize,
|
|
26
27
|
} from "../daemon/protocol";
|
|
27
28
|
|
|
28
|
-
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
29
|
-
|
|
30
|
-
type EventQueue = {
|
|
31
|
-
readonly push: (e: CdpEvent) => void;
|
|
32
|
-
readonly end: () => void;
|
|
33
|
-
readonly iter: AsyncIterable<CdpEvent>;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
37
|
-
|
|
38
|
-
const makeEventQueue = (): EventQueue => {
|
|
39
|
-
const buf: CdpEvent[] = [];
|
|
40
|
-
const waiters: Array<(v: IteratorResult<CdpEvent>) => void> = [];
|
|
41
|
-
let ended = false;
|
|
42
|
-
return {
|
|
43
|
-
push(e) {
|
|
44
|
-
if (ended) return;
|
|
45
|
-
const w = waiters.shift();
|
|
46
|
-
if (w) w({ value: e, done: false });
|
|
47
|
-
else buf.push(e);
|
|
48
|
-
},
|
|
49
|
-
end() {
|
|
50
|
-
ended = true;
|
|
51
|
-
for (const w of waiters.splice(0)) w({ value: undefined as unknown as CdpEvent, done: true });
|
|
52
|
-
},
|
|
53
|
-
iter: {
|
|
54
|
-
[Symbol.asyncIterator]() {
|
|
55
|
-
return {
|
|
56
|
-
next: (): Promise<IteratorResult<CdpEvent>> =>
|
|
57
|
-
new Promise((resolve) => {
|
|
58
|
-
const next = buf.shift();
|
|
59
|
-
if (next) resolve({ value: next, done: false });
|
|
60
|
-
else if (ended) resolve({ value: undefined as unknown as CdpEvent, done: true });
|
|
61
|
-
else waiters.push(resolve);
|
|
62
|
-
}),
|
|
63
|
-
};
|
|
64
|
-
},
|
|
65
|
-
},
|
|
66
|
-
};
|
|
67
|
-
};
|
|
68
|
-
|
|
69
29
|
// ── Implementation ─────────────────────────────────────────────────────────────
|
|
70
30
|
|
|
71
31
|
export const createDaemonTransport = (clientId: string): CdpTransport => {
|
|
@@ -73,20 +33,12 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
|
|
|
73
33
|
let rl: ReturnType<typeof createInterface> | null = null;
|
|
74
34
|
let queue = makeEventQueue();
|
|
75
35
|
const closeListeners = new Set<() => void>();
|
|
76
|
-
const pending = new Map<number,
|
|
77
|
-
resolve: (v: Result<unknown, CdpError>) => void;
|
|
78
|
-
timer: ReturnType<typeof setTimeout>;
|
|
79
|
-
method: string;
|
|
80
|
-
}>();
|
|
36
|
+
const pending = new Map<number, Pending>();
|
|
81
37
|
let registered = false;
|
|
82
38
|
let nextRequestId = 1;
|
|
83
39
|
|
|
84
40
|
const cleanup = (reason: string): void => {
|
|
85
|
-
|
|
86
|
-
clearTimeout(p.timer);
|
|
87
|
-
p.resolve(err(cdpError("transport_closed", reason, p.method)));
|
|
88
|
-
}
|
|
89
|
-
pending.clear();
|
|
41
|
+
rejectAllPending(pending, reason);
|
|
90
42
|
queue.end();
|
|
91
43
|
queue = makeEventQueue();
|
|
92
44
|
registered = false;
|
|
@@ -237,22 +189,7 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
|
|
|
237
189
|
...(sessionId !== null && sessionId !== undefined ? { sessionId } : {}),
|
|
238
190
|
};
|
|
239
191
|
|
|
240
|
-
return
|
|
241
|
-
const timer = setTimeout(() => {
|
|
242
|
-
pending.delete(id);
|
|
243
|
-
resolve(err(cdpError("timeout", `Daemon timeout after ${timeoutMs}ms: ${method}`, method)));
|
|
244
|
-
}, timeoutMs);
|
|
245
|
-
|
|
246
|
-
pending.set(id, { resolve, timer, method });
|
|
247
|
-
|
|
248
|
-
try {
|
|
249
|
-
socket!.write(serialize(req) + "\n");
|
|
250
|
-
} catch (e) {
|
|
251
|
-
clearTimeout(timer);
|
|
252
|
-
pending.delete(id);
|
|
253
|
-
resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method)));
|
|
254
|
-
}
|
|
255
|
-
});
|
|
192
|
+
return sendWithTimeout(pending, id, method, timeoutMs, "Daemon", () => socket!.write(serialize(req) + "\n"));
|
|
256
193
|
};
|
|
257
194
|
|
|
258
195
|
// ── Events ─────────────────────────────────────────────────────────────
|
|
@@ -268,10 +205,7 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
|
|
|
268
205
|
return "open";
|
|
269
206
|
};
|
|
270
207
|
|
|
271
|
-
const onClose = (
|
|
272
|
-
closeListeners.add(cb);
|
|
273
|
-
return () => closeListeners.delete(cb);
|
|
274
|
-
};
|
|
208
|
+
const onClose = makeOnClose(closeListeners);
|
|
275
209
|
|
|
276
210
|
return { connect, close, request, events, state, onClose };
|
|
277
211
|
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared transport primitives for CdpTransport implementations.
|
|
3
|
+
*
|
|
4
|
+
* Both the WebSocket transport (transport.ts, talking to Chrome directly) and
|
|
5
|
+
* the daemon transport (daemon-transport.ts, talking to the daemon over a Unix
|
|
6
|
+
* socket) need an identical single-consumer async event queue plus the same
|
|
7
|
+
* pending-request bookkeeping. This module is the single source of truth for
|
|
8
|
+
* that shared machinery.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { type Result, err } from "../util/result";
|
|
12
|
+
import { type CdpError, cdpError } from "./errors";
|
|
13
|
+
import type { CdpEvent } from "./types";
|
|
14
|
+
|
|
15
|
+
export type CdpResult = Result<unknown, CdpError>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A single-consumer async queue of CDP events.
|
|
19
|
+
*
|
|
20
|
+
* `push` enqueues an event (dropped silently after `end`). `end` terminates the
|
|
21
|
+
* stream — any pending and future iterator `next()` calls resolve `done:true`.
|
|
22
|
+
* `iter` is the AsyncIterable consumed by one for-await loop; a second consumer
|
|
23
|
+
* would silently steal events from the first.
|
|
24
|
+
*/
|
|
25
|
+
export type EventQueue = {
|
|
26
|
+
readonly push: (e: CdpEvent) => void;
|
|
27
|
+
readonly end: () => void;
|
|
28
|
+
readonly iter: AsyncIterable<CdpEvent>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const makeEventQueue = (): EventQueue => {
|
|
32
|
+
const buf: CdpEvent[] = [];
|
|
33
|
+
const waiters: Array<(v: IteratorResult<CdpEvent>) => void> = [];
|
|
34
|
+
let ended = false;
|
|
35
|
+
return {
|
|
36
|
+
push(e) {
|
|
37
|
+
if (ended) return;
|
|
38
|
+
const w = waiters.shift();
|
|
39
|
+
if (w) w({ value: e, done: false });
|
|
40
|
+
else buf.push(e);
|
|
41
|
+
},
|
|
42
|
+
end() {
|
|
43
|
+
ended = true;
|
|
44
|
+
// value: undefined as unknown as CdpEvent is the correct iterator-protocol
|
|
45
|
+
// pattern: when done=true the value field is conventionally undefined but
|
|
46
|
+
// the TS type still requires CdpEvent.
|
|
47
|
+
for (const w of waiters.splice(0)) w({ value: undefined as unknown as CdpEvent, done: true });
|
|
48
|
+
},
|
|
49
|
+
iter: {
|
|
50
|
+
[Symbol.asyncIterator]() {
|
|
51
|
+
return {
|
|
52
|
+
next: (): Promise<IteratorResult<CdpEvent>> =>
|
|
53
|
+
new Promise((resolve) => {
|
|
54
|
+
const next = buf.shift();
|
|
55
|
+
if (next) resolve({ value: next, done: false });
|
|
56
|
+
else if (ended) resolve({ value: undefined as unknown as CdpEvent, done: true });
|
|
57
|
+
else waiters.push(resolve);
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** An in-flight CDP request awaiting its response or timeout. */
|
|
66
|
+
export type Pending = {
|
|
67
|
+
readonly resolve: (v: Result<unknown, CdpError>) => void;
|
|
68
|
+
readonly timer: ReturnType<typeof setTimeout>;
|
|
69
|
+
readonly method: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reject every in-flight request with a transport_closed error and clear the
|
|
74
|
+
* map. Called from each transport's cleanup path when the underlying connection
|
|
75
|
+
* goes away.
|
|
76
|
+
*/
|
|
77
|
+
export const rejectAllPending = (pending: Map<number, Pending>, reason: string): void => {
|
|
78
|
+
for (const [, p] of pending) {
|
|
79
|
+
clearTimeout(p.timer);
|
|
80
|
+
p.resolve(err(cdpError("transport_closed", reason, p.method)));
|
|
81
|
+
}
|
|
82
|
+
pending.clear();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Send a CDP request and return a promise that settles when the matching
|
|
87
|
+
* response arrives, the request times out, or the send throws.
|
|
88
|
+
*
|
|
89
|
+
* The transport supplies a `send` callback that puts the already-serialized
|
|
90
|
+
* payload onto its wire (WebSocket frame or Unix-socket line); everything else
|
|
91
|
+
* — registering the pending entry, the timeout timer, and synchronous send
|
|
92
|
+
* failure handling — is identical across transports and lives here.
|
|
93
|
+
*
|
|
94
|
+
* `timeoutLabel` is the transport's prefix for the timeout error message
|
|
95
|
+
* (e.g. "CDP" or "Daemon").
|
|
96
|
+
*/
|
|
97
|
+
export const sendWithTimeout = (
|
|
98
|
+
pending: Map<number, Pending>,
|
|
99
|
+
id: number,
|
|
100
|
+
method: string,
|
|
101
|
+
timeoutMs: number,
|
|
102
|
+
timeoutLabel: string,
|
|
103
|
+
send: () => void,
|
|
104
|
+
): Promise<CdpResult> =>
|
|
105
|
+
new Promise((resolve) => {
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
pending.delete(id);
|
|
108
|
+
resolve(err(cdpError("timeout", `${timeoutLabel} timeout after ${timeoutMs}ms: ${method}`, method)));
|
|
109
|
+
}, timeoutMs);
|
|
110
|
+
pending.set(id, { resolve, timer, method });
|
|
111
|
+
try {
|
|
112
|
+
send();
|
|
113
|
+
} catch (e) {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
pending.delete(id);
|
|
116
|
+
resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method)));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Register a close listener, returning an unsubscribe function. Identical
|
|
122
|
+
* across transports, so it lives here.
|
|
123
|
+
*/
|
|
124
|
+
export const makeOnClose = (closeListeners: Set<() => void>) =>
|
|
125
|
+
(cb: () => void): (() => void) => {
|
|
126
|
+
closeListeners.add(cb);
|
|
127
|
+
return () => closeListeners.delete(cb);
|
|
128
|
+
};
|
package/src/cdp/session.ts
CHANGED
|
@@ -17,12 +17,25 @@ type TabSession = {
|
|
|
17
17
|
pageInfoDirty: boolean;
|
|
18
18
|
networkBuffer: ReturnType<typeof createNetworkBuffer>;
|
|
19
19
|
consoleBuffer: ReturnType<typeof createConsoleBuffer>;
|
|
20
|
+
// Stable element refs (e1, e2, …) → CDP backendNodeId, from the latest
|
|
21
|
+
// snapshot of this tab. Replaced wholesale on every snapshot — old refs are
|
|
22
|
+
// stale anyway, so this stays bounded (~50–60 KB worst case).
|
|
23
|
+
refMap: Map<string, number>;
|
|
24
|
+
// Per-ref signature ("role|name|value") from the latest snapshot, used as the
|
|
25
|
+
// baseline for the post-mutation auto-diff. Replaced alongside refMap.
|
|
26
|
+
refSig: Map<string, string>;
|
|
20
27
|
};
|
|
21
28
|
|
|
22
29
|
export type CdpSession = {
|
|
23
30
|
attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
|
|
24
31
|
switchTo(targetId: string): Promise<Result<void, CdpError>>;
|
|
25
32
|
current(): { readonly sessionId: string; readonly targetId: string } | null;
|
|
33
|
+
/** Replace the active tab's ref map and ref signatures (from a fresh snapshot). */
|
|
34
|
+
setRefMap(refMap: Map<string, number>, refSig: Map<string, string>): void;
|
|
35
|
+
/** Resolve a ref (e.g. "e12") to its backendNodeId on the active tab, or undefined if unknown/stale. */
|
|
36
|
+
resolveRef(ref: string): number | undefined;
|
|
37
|
+
/** The active tab's prior ref signatures — baseline for the post-mutation diff. */
|
|
38
|
+
refSignatures(): ReadonlyMap<string, string>;
|
|
26
39
|
call(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
|
|
27
40
|
callOnTarget(method: string, params: Record<string, unknown>, sessionId: string, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
|
|
28
41
|
callBrowser(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
|
|
@@ -211,6 +224,8 @@ export const createCdpSession = (
|
|
|
211
224
|
pageInfoDirty: false,
|
|
212
225
|
networkBuffer: createNetworkBuffer(),
|
|
213
226
|
consoleBuffer: createConsoleBuffer(),
|
|
227
|
+
refMap: new Map(),
|
|
228
|
+
refSig: new Map(),
|
|
214
229
|
});
|
|
215
230
|
sessionIdToTargetId.set(a.sessionId, pickTargetId);
|
|
216
231
|
return ok({ targetId: pickTargetId, sessionId: a.sessionId });
|
|
@@ -230,6 +245,8 @@ export const createCdpSession = (
|
|
|
230
245
|
pageInfoDirty: true,
|
|
231
246
|
networkBuffer: createNetworkBuffer(),
|
|
232
247
|
consoleBuffer: createConsoleBuffer(),
|
|
248
|
+
refMap: new Map(),
|
|
249
|
+
refSig: new Map(),
|
|
233
250
|
};
|
|
234
251
|
if (existing) {
|
|
235
252
|
// Each Target.attachToTarget produces a new sessionId — update it.
|
|
@@ -249,6 +266,20 @@ export const createCdpSession = (
|
|
|
249
266
|
current() {
|
|
250
267
|
return sessionId && targetId ? { sessionId, targetId } : null;
|
|
251
268
|
},
|
|
269
|
+
setRefMap(refMap, refSig) {
|
|
270
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
271
|
+
if (!tab) return;
|
|
272
|
+
tab.refMap = refMap;
|
|
273
|
+
tab.refSig = refSig;
|
|
274
|
+
},
|
|
275
|
+
resolveRef(ref) {
|
|
276
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
277
|
+
return tab?.refMap.get(ref);
|
|
278
|
+
},
|
|
279
|
+
refSignatures() {
|
|
280
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
281
|
+
return tab?.refSig ?? new Map();
|
|
282
|
+
},
|
|
252
283
|
call(method, params = {}, opts = {}) {
|
|
253
284
|
return transport.request(method, params, { sessionId, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
|
|
254
285
|
},
|
package/src/cdp/transport.ts
CHANGED
|
@@ -3,15 +3,10 @@ import { type Result, err, ok } from "../util/result";
|
|
|
3
3
|
import { type CdpError, cdpError } from "./errors";
|
|
4
4
|
import { isCdpRawMessage } from "./types";
|
|
5
5
|
import type { CdpEvent, CdpRawMessage } from "./types";
|
|
6
|
+
import { type Pending, makeEventQueue, makeOnClose, rejectAllPending, sendWithTimeout } from "./event-queue";
|
|
6
7
|
|
|
7
8
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
8
9
|
|
|
9
|
-
type Pending = {
|
|
10
|
-
readonly resolve: (v: Result<unknown, CdpError>) => void;
|
|
11
|
-
readonly timer: ReturnType<typeof setTimeout>;
|
|
12
|
-
readonly method: string;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
10
|
export type CdpTransport = {
|
|
16
11
|
connect(url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>>;
|
|
17
12
|
close(): Promise<void>;
|
|
@@ -33,46 +28,6 @@ export const createCdpTransport = (): CdpTransport => {
|
|
|
33
28
|
const pending = new Map<number, Pending>();
|
|
34
29
|
const closeListeners = new Set<() => void>();
|
|
35
30
|
|
|
36
|
-
type EventQueue = {
|
|
37
|
-
readonly push: (e: CdpEvent) => void;
|
|
38
|
-
readonly end: () => void;
|
|
39
|
-
readonly iter: AsyncIterable<CdpEvent>;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const makeEventQueue = (): EventQueue => {
|
|
43
|
-
const buf: CdpEvent[] = [];
|
|
44
|
-
const waiters: Array<(v: IteratorResult<CdpEvent>) => void> = [];
|
|
45
|
-
let ended = false;
|
|
46
|
-
return {
|
|
47
|
-
push(e) {
|
|
48
|
-
if (ended) return;
|
|
49
|
-
const w = waiters.shift();
|
|
50
|
-
if (w) w({ value: e, done: false });
|
|
51
|
-
else buf.push(e);
|
|
52
|
-
},
|
|
53
|
-
end() {
|
|
54
|
-
ended = true;
|
|
55
|
-
// value: undefined as unknown as CdpEvent is the correct iterator-protocol
|
|
56
|
-
// pattern: when done=true the value field is conventionally undefined but
|
|
57
|
-
// the TS type still requires CdpEvent.
|
|
58
|
-
for (const w of waiters.splice(0)) w({ value: undefined as unknown as CdpEvent, done: true });
|
|
59
|
-
},
|
|
60
|
-
iter: {
|
|
61
|
-
[Symbol.asyncIterator]() {
|
|
62
|
-
return {
|
|
63
|
-
next: (): Promise<IteratorResult<CdpEvent>> =>
|
|
64
|
-
new Promise((resolve) => {
|
|
65
|
-
const next = buf.shift();
|
|
66
|
-
if (next) resolve({ value: next, done: false });
|
|
67
|
-
else if (ended) resolve({ value: undefined as unknown as CdpEvent, done: true });
|
|
68
|
-
else waiters.push(resolve);
|
|
69
|
-
}),
|
|
70
|
-
};
|
|
71
|
-
},
|
|
72
|
-
},
|
|
73
|
-
};
|
|
74
|
-
};
|
|
75
|
-
|
|
76
31
|
let queue = makeEventQueue();
|
|
77
32
|
|
|
78
33
|
const handleMessage = (raw: string): void => {
|
|
@@ -109,11 +64,7 @@ export const createCdpTransport = (): CdpTransport => {
|
|
|
109
64
|
};
|
|
110
65
|
|
|
111
66
|
const cleanup = (reason: string): void => {
|
|
112
|
-
|
|
113
|
-
clearTimeout(p.timer);
|
|
114
|
-
p.resolve(err(cdpError("transport_closed", reason, p.method)));
|
|
115
|
-
}
|
|
116
|
-
pending.clear();
|
|
67
|
+
rejectAllPending(pending, reason);
|
|
117
68
|
queue.end();
|
|
118
69
|
queue = makeEventQueue();
|
|
119
70
|
for (const cb of closeListeners) cb();
|
|
@@ -184,20 +135,7 @@ export const createCdpTransport = (): CdpTransport => {
|
|
|
184
135
|
const payload: Record<string, unknown> = { id, method, params };
|
|
185
136
|
if (sessionId) payload["sessionId"] = sessionId;
|
|
186
137
|
const json = JSON.stringify(payload);
|
|
187
|
-
return
|
|
188
|
-
const timer = setTimeout(() => {
|
|
189
|
-
pending.delete(id);
|
|
190
|
-
resolve(err(cdpError("timeout", `CDP timeout after ${timeoutMs}ms: ${method}`, method)));
|
|
191
|
-
}, timeoutMs);
|
|
192
|
-
pending.set(id, { resolve, timer, method });
|
|
193
|
-
try {
|
|
194
|
-
sock.send(json);
|
|
195
|
-
} catch (e) {
|
|
196
|
-
clearTimeout(timer);
|
|
197
|
-
pending.delete(id);
|
|
198
|
-
resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method)));
|
|
199
|
-
}
|
|
200
|
-
});
|
|
138
|
+
return sendWithTimeout(pending, id, method, timeoutMs, "CDP", () => sock.send(json));
|
|
201
139
|
},
|
|
202
140
|
events(): AsyncIterable<CdpEvent> {
|
|
203
141
|
// Single-consumer: each connection's event stream may be iterated by
|
|
@@ -213,9 +151,6 @@ export const createCdpTransport = (): CdpTransport => {
|
|
|
213
151
|
if (ws.readyState === WebSocket.OPEN) return "open";
|
|
214
152
|
return "closed";
|
|
215
153
|
},
|
|
216
|
-
onClose
|
|
217
|
-
closeListeners.add(cb);
|
|
218
|
-
return () => closeListeners.delete(cb);
|
|
219
|
-
},
|
|
154
|
+
onClose: makeOnClose(closeListeners),
|
|
220
155
|
};
|
|
221
156
|
};
|
package/src/domains/click.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
import { Type
|
|
1
|
+
import { Type } from "typebox";
|
|
2
2
|
import { Text } from "@mariozechner/pi-tui";
|
|
3
3
|
import type { BrowserClient } from "../client";
|
|
4
4
|
import { Coords, MouseButton } from "../schemas/common";
|
|
5
5
|
import { type Result, err, ok } from "../util/result";
|
|
6
6
|
import { defineBrowserTool, type ToolErr } from "../util/tool";
|
|
7
7
|
import { captureWithCrosshair } from "./screenshot";
|
|
8
|
+
import { interactiveDiff, resolveRefToBox } from "./ref-resolve";
|
|
8
9
|
|
|
9
10
|
const ClickArgs = Type.Object({
|
|
10
|
-
|
|
11
|
+
ref: Type.Optional(
|
|
12
|
+
Type.String({
|
|
13
|
+
description:
|
|
14
|
+
"Stable element ref from browser_snapshot (e.g. 'e12'). PREFERRED over x/y — survives re-renders. When set, x/y are ignored.",
|
|
15
|
+
}),
|
|
16
|
+
),
|
|
17
|
+
x: Type.Optional(Coords.properties.x),
|
|
18
|
+
y: Type.Optional(Coords.properties.y),
|
|
11
19
|
button: Type.Optional(MouseButton),
|
|
12
20
|
count: Type.Optional(
|
|
13
21
|
Type.Integer({
|
|
@@ -18,20 +26,27 @@ const ClickArgs = Type.Object({
|
|
|
18
26
|
}),
|
|
19
27
|
),
|
|
20
28
|
});
|
|
21
|
-
type ClickArgsT = Static<typeof ClickArgs>;
|
|
22
29
|
|
|
23
30
|
const dispatchClick = async (
|
|
24
31
|
client: BrowserClient,
|
|
25
|
-
|
|
32
|
+
x: number,
|
|
33
|
+
y: number,
|
|
34
|
+
button: "left" | "right" | "middle",
|
|
35
|
+
count: number,
|
|
26
36
|
): Promise<Result<void, ToolErr>> => {
|
|
27
|
-
|
|
28
|
-
|
|
37
|
+
// Move the pointer to the target first. Some focus/hover handlers (and React
|
|
38
|
+
// synthetic events) only fire when a mousemove precedes the press, so without
|
|
39
|
+
// it a click can land without focusing the element.
|
|
40
|
+
const moved = await client.session().call("Input.dispatchMouseEvent", {
|
|
41
|
+
type: "mouseMoved", x, y,
|
|
42
|
+
});
|
|
43
|
+
if (!moved.success) return err({ kind: "cdp_error", message: moved.error.message });
|
|
29
44
|
const pressed = await client.session().call("Input.dispatchMouseEvent", {
|
|
30
|
-
type: "mousePressed", x
|
|
45
|
+
type: "mousePressed", x, y, button, clickCount: count,
|
|
31
46
|
});
|
|
32
47
|
if (!pressed.success) return err({ kind: "cdp_error", message: pressed.error.message });
|
|
33
48
|
const released = await client.session().call("Input.dispatchMouseEvent", {
|
|
34
|
-
type: "mouseReleased", x
|
|
49
|
+
type: "mouseReleased", x, y, button, clickCount: count,
|
|
35
50
|
});
|
|
36
51
|
if (!released.success) return err({ kind: "cdp_error", message: released.error.message });
|
|
37
52
|
return ok(undefined);
|
|
@@ -41,34 +56,55 @@ export const clickTool = defineBrowserTool({
|
|
|
41
56
|
name: "browser_click",
|
|
42
57
|
label: "Browser Click",
|
|
43
58
|
description:
|
|
44
|
-
"Click at viewport CSS-pixel
|
|
45
|
-
promptSnippet: "Click
|
|
59
|
+
"Click an element. PREFERRED: pass `ref` (e.g. 'e12') from browser_snapshot — it re-resolves the element's position at click time, so it works even after the page re-renders and moves things. Fallback: pass viewport CSS-pixel `x`/`y`. Compositor-level click works through iframes, shadow DOM, and cross-origin content. After clicking, a compact diff of page changes is appended.",
|
|
60
|
+
promptSnippet: "Click an element by ref (preferred) or pixel coordinates",
|
|
46
61
|
promptGuidelines: [
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"After clicking,
|
|
51
|
-
"Compositor-level clicks pass through iframes, shadow DOM, and cross-origin content
|
|
52
|
-
"If a click doesn't register, set BH_DEBUG_CLICKS=1 to get annotated screenshots
|
|
53
|
-
"For React/Vue components that don't respond to clicks, try browser_dispatch_key to send DOM-level events.",
|
|
62
|
+
"PREFER `ref` from browser_snapshot (the outline shows '[eN]' for every interactive element) — it survives re-renders, unlike coordinates which go stale after a save/edit reflows the page.",
|
|
63
|
+
"Fallback: pass (x, y) from the snapshot's '@(x,y)' hint when there's no ref.",
|
|
64
|
+
"A 'ref is stale' error means the page changed — re-run browser_snapshot to get fresh refs.",
|
|
65
|
+
"After clicking, read the appended page-changes diff to confirm the action landed before moving on (no separate snapshot needed for a quick check).",
|
|
66
|
+
"Coordinates are viewport CSS pixels (not device pixels). Compositor-level clicks pass through iframes, shadow DOM, and cross-origin content.",
|
|
67
|
+
"If a click doesn't register, set BH_DEBUG_CLICKS=1 to get annotated screenshots (debug only). For React/Vue components ignoring clicks, try browser_dispatch_key.",
|
|
54
68
|
],
|
|
55
69
|
parameters: ClickArgs,
|
|
56
|
-
renderCall: (a) => new Text(`🖱️ Click
|
|
70
|
+
renderCall: (a) => new Text(`🖱️ Click ${a.ref ? `[${a.ref}]` : `(${a.x}, ${a.y})`}`, 0, 0),
|
|
57
71
|
async handler(args, { client }) {
|
|
58
|
-
const
|
|
72
|
+
const button = args.button ?? "left";
|
|
73
|
+
const count = args.count ?? 1;
|
|
74
|
+
|
|
75
|
+
// Resolve the click point: ref re-resolves to the element's CURRENT box
|
|
76
|
+
// (so re-renders don't matter); otherwise use the literal x/y.
|
|
77
|
+
let x: number;
|
|
78
|
+
let y: number;
|
|
79
|
+
if (args.ref !== undefined) {
|
|
80
|
+
const box = await resolveRefToBox(client, args.ref);
|
|
81
|
+
if (!box.success) return box;
|
|
82
|
+
x = box.data.cx;
|
|
83
|
+
y = box.data.cy;
|
|
84
|
+
} else if (args.x !== undefined && args.y !== undefined) {
|
|
85
|
+
x = args.x;
|
|
86
|
+
y = args.y;
|
|
87
|
+
} else {
|
|
88
|
+
return err({ kind: "invalid_state", message: "Provide either `ref` or both `x` and `y`." });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const clicked = await dispatchClick(client, x, y, button, count);
|
|
59
92
|
if (!clicked.success) return clicked;
|
|
93
|
+
|
|
94
|
+
const target = args.ref !== undefined ? `[${args.ref}] (${x}, ${y})` : `(${x}, ${y})`;
|
|
95
|
+
const diff = await interactiveDiff(client);
|
|
60
96
|
if (process.env["BH_DEBUG_CLICKS"]) {
|
|
61
|
-
const debug = await captureWithCrosshair(client, { x
|
|
97
|
+
const debug = await captureWithCrosshair(client, { x, y });
|
|
62
98
|
if (debug.success) {
|
|
63
99
|
return ok({
|
|
64
|
-
text: `Clicked at
|
|
65
|
-
details: { debugScreenshotPath: debug.data.path, x
|
|
100
|
+
text: `Clicked at ${target}\n[DEBUG] Overlay screenshot: ${debug.data.path}${diff}`,
|
|
101
|
+
details: { debugScreenshotPath: debug.data.path, x, y, ref: args.ref },
|
|
66
102
|
});
|
|
67
103
|
}
|
|
68
104
|
}
|
|
69
105
|
return ok({
|
|
70
|
-
text: `Clicked at
|
|
71
|
-
details: { x
|
|
106
|
+
text: `Clicked at ${target}${diff}`,
|
|
107
|
+
details: { x, y, ref: args.ref },
|
|
72
108
|
});
|
|
73
109
|
},
|
|
74
110
|
});
|
package/src/domains/files.ts
CHANGED
|
@@ -6,12 +6,28 @@ import { type Result, err, ok } from "../util/result";
|
|
|
6
6
|
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
7
7
|
import { safeJs } from "../util/js-template";
|
|
8
8
|
import { pdfPath } from "../util/paths";
|
|
9
|
+
import { resolveRefToBackendId } from "./ref-resolve";
|
|
9
10
|
|
|
10
11
|
const UploadArgs = Type.Object({
|
|
11
|
-
|
|
12
|
+
ref: Type.Optional(
|
|
13
|
+
Type.String({ description: "Stable ref of the file <input> from browser_snapshot. PREFERRED over selector." }),
|
|
14
|
+
),
|
|
15
|
+
selector: Type.Optional(Type.String({ description: "CSS selector of the file <input> (fallback when no ref)" })),
|
|
12
16
|
filePath: Type.String({ description: "Absolute path to the file to upload" }),
|
|
13
17
|
});
|
|
14
18
|
|
|
19
|
+
// Ref path: set files directly on the resolved backendNodeId via CDP — simplest
|
|
20
|
+
// and most reliable, no selector needed.
|
|
21
|
+
const tryRefUpload = async (
|
|
22
|
+
client: BrowserClient,
|
|
23
|
+
backendId: number,
|
|
24
|
+
filePath: string,
|
|
25
|
+
): Promise<Result<void, ToolErr>> => {
|
|
26
|
+
const set = await client.session().call("DOM.setFileInputFiles", { files: [filePath], backendNodeId: backendId });
|
|
27
|
+
if (!set.success) return err({ kind: "cdp_error", message: set.error.message });
|
|
28
|
+
return ok(undefined);
|
|
29
|
+
};
|
|
30
|
+
|
|
15
31
|
const verifyReadable = async (filePath: string): Promise<Result<void, ToolErr>> => {
|
|
16
32
|
try {
|
|
17
33
|
await access(filePath, constants.R_OK);
|
|
@@ -89,16 +105,29 @@ const jsFallbackUpload = async (
|
|
|
89
105
|
export const uploadFileTool = defineBrowserTool({
|
|
90
106
|
name: "browser_upload_file",
|
|
91
107
|
label: "Browser Upload File",
|
|
92
|
-
description: "Set files on a file <input> via CDP, with a JS-DataTransfer fallback for stubborn pages.",
|
|
93
|
-
promptSnippet: "Upload a file to a file input",
|
|
108
|
+
description: "Set files on a file <input> via CDP, with a JS-DataTransfer fallback for stubborn pages. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`.",
|
|
109
|
+
promptSnippet: "Upload a file to a file input by ref (preferred) or selector",
|
|
94
110
|
promptGuidelines: [
|
|
111
|
+
"PREFER `ref` from browser_snapshot over a CSS selector — survives re-renders. (File inputs are often hidden; snapshot still surfaces them.)",
|
|
95
112
|
"File path must be absolute and readable.",
|
|
96
|
-
"Selector must match a file input.",
|
|
97
113
|
],
|
|
98
114
|
parameters: UploadArgs,
|
|
99
115
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
100
116
|
const readable = await verifyReadable(args.filePath);
|
|
101
117
|
if (!readable.success) return readable;
|
|
118
|
+
|
|
119
|
+
// Ref path: resolve to backendNodeId and set files directly via CDP.
|
|
120
|
+
if (args.ref !== undefined) {
|
|
121
|
+
const backendId = resolveRefToBackendId(client, args.ref);
|
|
122
|
+
if (!backendId.success) return backendId;
|
|
123
|
+
const up = await tryRefUpload(client, backendId.data, args.filePath);
|
|
124
|
+
if (!up.success) return up;
|
|
125
|
+
return ok({ text: `Uploaded ${args.filePath} via CDP (ref ${args.ref})`, details: { mode: "cdp", ref: args.ref, filePath: args.filePath } });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (args.selector === undefined) {
|
|
129
|
+
return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." });
|
|
130
|
+
}
|
|
102
131
|
const cdp = await tryCdpUpload(client, args.selector, args.filePath);
|
|
103
132
|
if (cdp.success) {
|
|
104
133
|
return ok({
|