pi-browser-harness 0.4.0 → 0.6.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.
@@ -0,0 +1,263 @@
1
+ /**
2
+ * In-memory aggregator for CDP Runtime.consoleAPICalled and Log.entryAdded events.
3
+ *
4
+ * Mirrors network-buffer.ts: the single CDP event loop in session.ts hands raw
5
+ * params to ingestConsoleApi / ingestLogEntry; tools later read records via drain().
6
+ *
7
+ * Bounded by capacity (default 500). On overflow the oldest record is evicted FIFO,
8
+ * and the overflow flag is reported once per drain so tools can warn the LLM that
9
+ * data may be missing.
10
+ *
11
+ * Each record carries a monotonic `seq`. Tools expose the last drained seq as
12
+ * `nextCursor`; callers pass it back as `sinceSeq` to read only what's new since
13
+ * the previous drain. This is the cursor pattern that makes "what did this action
14
+ * cause?" answerable in one tool call.
15
+ *
16
+ * Pure module (no CDP dependency) — joining/filtering logic stays trivially testable.
17
+ */
18
+
19
+ export type ConsoleLevel = "log" | "info" | "warn" | "error" | "debug";
20
+
21
+ export type ConsoleRecord = {
22
+ seq: number;
23
+ /** Wall-clock ms when the event was ingested, in Date.now() units. */
24
+ timestampMs: number;
25
+ level: ConsoleLevel;
26
+ /** Joined args (console-api) or the entry text (log-entry). Per-arg truncated. */
27
+ text: string;
28
+ source: "console-api" | "log-entry";
29
+ url?: string;
30
+ lineNumber?: number;
31
+ /** Top frames only, joined with newlines. Populated for error/warn when CDP provides one. */
32
+ stackTrace?: string;
33
+ };
34
+
35
+ export type ConsoleFilter = {
36
+ levels?: ConsoleLevel[];
37
+ /** Substring match by default. Wrap in slashes (`/foo/`) for regex. */
38
+ textPattern?: string;
39
+ /** Only records with seq strictly greater than this. */
40
+ sinceSeq?: number;
41
+ /** Only records with timestamp at most N ms ago. */
42
+ sinceMs?: number;
43
+ /** Cap on returned records (default 50, max 500). */
44
+ limit?: number;
45
+ };
46
+
47
+ export type ConsoleDrainResult = {
48
+ readonly records: ConsoleRecord[];
49
+ /** Total matches before `limit` was applied. */
50
+ readonly total: number;
51
+ /** True if the ring buffer evicted at least one record since the last drain. */
52
+ readonly bufferOverflowed: boolean;
53
+ };
54
+
55
+ export type ConsoleBuffer = {
56
+ ingestConsoleApi(p: unknown): void;
57
+ ingestLogEntry(p: unknown): void;
58
+ drain(filter: ConsoleFilter): ConsoleDrainResult;
59
+ clear(): void;
60
+ };
61
+
62
+ /** Per-arg cap to stop a single console.log(hugeBlob) from blowing the buffer. */
63
+ const PER_ARG_CAP = 2000;
64
+ /** Max stack frames preserved per record. */
65
+ const MAX_STACK_FRAMES = 3;
66
+
67
+ const compileTextMatcher = (pattern: string): ((text: string) => boolean) => {
68
+ if (pattern.length >= 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
69
+ try {
70
+ const re = new RegExp(pattern.slice(1, -1));
71
+ return (text) => re.test(text);
72
+ } catch {
73
+ // Fall through to substring match if the pattern is not a valid regex.
74
+ }
75
+ }
76
+ return (text) => text.includes(pattern);
77
+ };
78
+
79
+ const truncateArg = (s: string): string =>
80
+ s.length > PER_ARG_CAP ? s.slice(0, PER_ARG_CAP) + "…" : s;
81
+
82
+ /** Best-effort stringification of one Runtime.RemoteObject. */
83
+ const stringifyRemoteObject = (
84
+ arg: { type?: string; subtype?: string; value?: unknown; description?: string; unserializableValue?: string },
85
+ ): string => {
86
+ if (arg.unserializableValue !== undefined) return arg.unserializableValue;
87
+ if (arg.value !== undefined) {
88
+ if (typeof arg.value === "string") return arg.value;
89
+ try {
90
+ return JSON.stringify(arg.value);
91
+ } catch {
92
+ return String(arg.value);
93
+ }
94
+ }
95
+ if (arg.description !== undefined) return arg.description;
96
+ return arg.type ?? "";
97
+ };
98
+
99
+ /** Map CDP console-API method names to our normalized levels. */
100
+ const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => {
101
+ switch (type) {
102
+ case "error":
103
+ case "assert":
104
+ return "error";
105
+ case "warning":
106
+ return "warn";
107
+ case "debug":
108
+ case "trace":
109
+ return "debug";
110
+ case "info":
111
+ return "info";
112
+ default:
113
+ return "log";
114
+ }
115
+ };
116
+
117
+ /** Map Log.LogEntry.level values ("verbose","info","warning","error") to our levels. */
118
+ const mapLogEntryLevel = (level: string | undefined): ConsoleLevel => {
119
+ switch (level) {
120
+ case "error":
121
+ return "error";
122
+ case "warning":
123
+ return "warn";
124
+ case "verbose":
125
+ return "debug";
126
+ default:
127
+ return "info";
128
+ }
129
+ };
130
+
131
+ const formatStackTrace = (
132
+ st: { callFrames?: ReadonlyArray<{ url?: string; lineNumber?: number; functionName?: string }> } | undefined,
133
+ ): string | undefined => {
134
+ const frames = st?.callFrames;
135
+ if (!frames || frames.length === 0) return undefined;
136
+ const top = frames.slice(0, MAX_STACK_FRAMES);
137
+ const rendered = top.map((f) => {
138
+ const fn = f.functionName !== undefined && f.functionName !== "" ? f.functionName : "<anonymous>";
139
+ const loc = f.url !== undefined && f.url !== "" ? `${f.url}:${(f.lineNumber ?? 0) + 1}` : "<unknown>";
140
+ return ` at ${fn} (${loc})`;
141
+ });
142
+ return rendered.join("\n");
143
+ };
144
+
145
+ export const createConsoleBuffer = (capacity = 500): ConsoleBuffer => {
146
+ // Insertion-ordered map keyed by seq. Map preserves insertion order in JS,
147
+ // so iteration gives us oldest-first eviction without a separate index.
148
+ const records = new Map<number, ConsoleRecord>();
149
+ let nextSeq = 1;
150
+ let overflowed = false;
151
+
152
+ const evictOldestIfFull = (): void => {
153
+ while (records.size >= capacity) {
154
+ const oldest = records.keys().next();
155
+ if (oldest.done) return;
156
+ records.delete(oldest.value);
157
+ overflowed = true;
158
+ }
159
+ };
160
+
161
+ const insert = (rec: ConsoleRecord): void => {
162
+ evictOldestIfFull();
163
+ records.set(rec.seq, rec);
164
+ };
165
+
166
+ return {
167
+ ingestConsoleApi(p) {
168
+ // CDP boundary cast: Runtime.consoleAPICalled
169
+ const params = p as
170
+ | {
171
+ type?: string;
172
+ args?: ReadonlyArray<{
173
+ type?: string;
174
+ subtype?: string;
175
+ value?: unknown;
176
+ description?: string;
177
+ unserializableValue?: string;
178
+ }>;
179
+ stackTrace?: { callFrames?: ReadonlyArray<{ url?: string; lineNumber?: number; functionName?: string }> };
180
+ }
181
+ | undefined;
182
+ if (!params) return;
183
+ const level = mapConsoleApiLevel(params.type);
184
+ const argTexts = (params.args ?? []).map((a) => truncateArg(stringifyRemoteObject(a)));
185
+ const text = argTexts.join(" ");
186
+ const stack = level === "error" || level === "warn" ? formatStackTrace(params.stackTrace) : undefined;
187
+ const top = params.stackTrace?.callFrames?.[0];
188
+ const rec: ConsoleRecord = {
189
+ seq: nextSeq++,
190
+ timestampMs: Date.now(),
191
+ level,
192
+ text,
193
+ source: "console-api",
194
+ ...(top?.url !== undefined && top.url !== "" ? { url: top.url } : {}),
195
+ ...(top?.lineNumber !== undefined ? { lineNumber: top.lineNumber + 1 } : {}),
196
+ ...(stack !== undefined ? { stackTrace: stack } : {}),
197
+ };
198
+ insert(rec);
199
+ },
200
+
201
+ ingestLogEntry(p) {
202
+ // CDP boundary cast: Log.entryAdded
203
+ const params = p as
204
+ | {
205
+ entry?: {
206
+ level?: string;
207
+ text?: string;
208
+ url?: string;
209
+ lineNumber?: number;
210
+ stackTrace?: { callFrames?: ReadonlyArray<{ url?: string; lineNumber?: number; functionName?: string }> };
211
+ };
212
+ }
213
+ | undefined;
214
+ const entry = params?.entry;
215
+ if (!entry) return;
216
+ const level = mapLogEntryLevel(entry.level);
217
+ const text = truncateArg(entry.text ?? "");
218
+ const stack = level === "error" || level === "warn" ? formatStackTrace(entry.stackTrace) : undefined;
219
+ const rec: ConsoleRecord = {
220
+ seq: nextSeq++,
221
+ timestampMs: Date.now(),
222
+ level,
223
+ text,
224
+ source: "log-entry",
225
+ ...(entry.url !== undefined && entry.url !== "" ? { url: entry.url } : {}),
226
+ ...(entry.lineNumber !== undefined ? { lineNumber: entry.lineNumber + 1 } : {}),
227
+ ...(stack !== undefined ? { stackTrace: stack } : {}),
228
+ };
229
+ insert(rec);
230
+ },
231
+
232
+ drain(filter) {
233
+ const matchText = filter.textPattern !== undefined ? compileTextMatcher(filter.textPattern) : undefined;
234
+ const levels = filter.levels && filter.levels.length > 0 ? new Set(filter.levels) : undefined;
235
+ const cutoff = filter.sinceMs !== undefined ? Date.now() - filter.sinceMs : undefined;
236
+ const sinceSeq = filter.sinceSeq;
237
+
238
+ const matched: ConsoleRecord[] = [];
239
+ for (const r of records.values()) {
240
+ if (sinceSeq !== undefined && r.seq <= sinceSeq) continue;
241
+ if (levels && !levels.has(r.level)) continue;
242
+ if (cutoff !== undefined && r.timestampMs < cutoff) continue;
243
+ if (matchText && !matchText(r.text)) continue;
244
+ matched.push({ ...r });
245
+ }
246
+
247
+ const total = matched.length;
248
+ const limit = Math.min(filter.limit ?? 50, 500);
249
+ const limited = matched.slice(-limit);
250
+
251
+ const bufferOverflowed = overflowed;
252
+ overflowed = false;
253
+
254
+ return { records: limited, total, bufferOverflowed };
255
+ },
256
+
257
+ clear() {
258
+ records.clear();
259
+ nextSeq = 1;
260
+ overflowed = false;
261
+ },
262
+ };
263
+ };
@@ -0,0 +1,277 @@
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
+ import { createConnection, type Socket } from "node:net";
15
+ import { createInterface } from "node:readline";
16
+ import { type Result, err, ok } from "../util/result";
17
+ import { type CdpError, cdpError } from "../cdp/errors";
18
+ import type { CdpTransport } from "../cdp/transport";
19
+ import type { CdpEvent } from "../cdp/types";
20
+ import {
21
+ DAEMON_SOCKET_PATH,
22
+ type WireRequest,
23
+ type WireControl,
24
+ deserialize,
25
+ serialize,
26
+ } from "../daemon/protocol";
27
+
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
+ // ── Implementation ─────────────────────────────────────────────────────────────
70
+
71
+ export const createDaemonTransport = (clientId: string): CdpTransport => {
72
+ let socket: Socket | null = null;
73
+ let rl: ReturnType<typeof createInterface> | null = null;
74
+ let queue = makeEventQueue();
75
+ 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
+ }>();
81
+ let registered = false;
82
+ let nextRequestId = 1;
83
+
84
+ const cleanup = (reason: string): void => {
85
+ for (const [, p] of pending) {
86
+ clearTimeout(p.timer);
87
+ p.resolve(err(cdpError("transport_closed", reason, p.method)));
88
+ }
89
+ pending.clear();
90
+ queue.end();
91
+ queue = makeEventQueue();
92
+ registered = false;
93
+
94
+ if (rl) { rl.close(); rl = null; }
95
+ if (socket) { try { socket.destroy(); } catch {} socket = null; }
96
+
97
+ for (const cb of closeListeners) cb();
98
+ };
99
+
100
+ // ── Connect ────────────────────────────────────────────────────────────
101
+
102
+ const connect = (_url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>> => {
103
+ // url is ignored — we always connect to the daemon socket
104
+ const timeoutMs = opts?.timeoutMs ?? 10_000;
105
+
106
+ if (socket && !socket.destroyed && registered) {
107
+ return Promise.resolve(ok(undefined));
108
+ }
109
+
110
+ return new Promise((resolve) => {
111
+ let settled = false;
112
+ const settle = (r: Result<void, CdpError>): void => {
113
+ if (settled) return;
114
+ settled = true;
115
+ resolve(r);
116
+ };
117
+
118
+ try {
119
+ socket = createConnection(DAEMON_SOCKET_PATH);
120
+ } catch (e) {
121
+ settle(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e))));
122
+ return;
123
+ }
124
+
125
+ const connectTimer = setTimeout(() => {
126
+ cleanup("Connection timeout");
127
+ settle(err(cdpError("timeout", `Daemon connection timed out after ${timeoutMs}ms`)));
128
+ }, timeoutMs);
129
+
130
+ socket.on("connect", () => {
131
+ clearTimeout(connectTimer);
132
+ rl = createInterface({ input: socket!, crlfDelay: Infinity });
133
+
134
+ rl.on("line", (line: string) => {
135
+ const msg = deserialize(line);
136
+ if (!msg) return;
137
+
138
+ if (msg.type === "control" && msg.action === "registered" && msg.clientId === clientId) {
139
+ registered = true;
140
+ settle(ok(undefined));
141
+ return;
142
+ }
143
+
144
+ if (msg.type === "control" && msg.action === "shutdown") {
145
+ cleanup(`Daemon shutting down: ${msg.reason ?? "unknown"}`);
146
+ return;
147
+ }
148
+
149
+ if (!registered) return;
150
+
151
+ if (msg.type === "response") {
152
+ const p = pending.get(msg.id);
153
+ if (!p) return;
154
+ pending.delete(msg.id);
155
+ clearTimeout(p.timer);
156
+
157
+ if (msg.error) {
158
+ p.resolve(err(cdpError(
159
+ msg.error.message.includes("Session with given id not found")
160
+ ? "session_not_found"
161
+ : "remote_error",
162
+ msg.error.message,
163
+ p.method,
164
+ )));
165
+ } else {
166
+ p.resolve(ok(msg.result));
167
+ }
168
+ return;
169
+ }
170
+
171
+ if (msg.type === "event") {
172
+ queue.push({
173
+ method: msg.method,
174
+ params: msg.params,
175
+ ...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}),
176
+ });
177
+ return;
178
+ }
179
+ });
180
+
181
+ socket!.on("error", () => {
182
+ // Errors are surfaced via 'close'
183
+ });
184
+
185
+ socket!.on("close", () => {
186
+ clearTimeout(connectTimer);
187
+ cleanup("Daemon socket closed");
188
+ // If connect hasn't settled yet, settle with error
189
+ settle(err(cdpError("transport_closed", "Daemon socket closed before registration")));
190
+ });
191
+
192
+ // Send registration
193
+ const regMsg: WireControl = { type: "control", action: "register", clientId };
194
+ socket!.write(serialize(regMsg) + "\n");
195
+ });
196
+
197
+ socket.on("error", (e: NodeJS.ErrnoException) => {
198
+ clearTimeout(connectTimer);
199
+ const msg = e.code === "ENOENT"
200
+ ? "Daemon not running — socket not found"
201
+ : e.message;
202
+ settle(err(cdpError("transport_closed", msg)));
203
+ });
204
+ });
205
+ };
206
+
207
+ // ── Close ──────────────────────────────────────────────────────────────
208
+
209
+ const close = async (): Promise<void> => {
210
+ if (registered && socket && !socket.destroyed) {
211
+ const dereg: WireControl = { type: "control", action: "deregister", clientId };
212
+ try { socket.write(serialize(dereg) + "\n"); } catch {}
213
+ }
214
+ cleanup("close() called");
215
+ };
216
+
217
+ // ── Request ────────────────────────────────────────────────────────────
218
+
219
+ const request = (
220
+ method: string,
221
+ params: Record<string, unknown>,
222
+ opts?: { sessionId?: string | null; timeoutMs?: number },
223
+ ): Promise<Result<unknown, CdpError>> => {
224
+ const timeoutMs = opts?.timeoutMs ?? 15_000;
225
+ const sessionId = opts?.sessionId ?? null;
226
+
227
+ if (!socket || socket.destroyed || !registered) {
228
+ return Promise.resolve(err(cdpError("transport_closed", "Daemon not connected", method)));
229
+ }
230
+
231
+ const id = nextRequestId++;
232
+ const req: WireRequest = {
233
+ type: "request",
234
+ id,
235
+ method,
236
+ params,
237
+ ...(sessionId !== null && sessionId !== undefined ? { sessionId } : {}),
238
+ };
239
+
240
+ return new Promise((resolve) => {
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
+ });
256
+ };
257
+
258
+ // ── Events ─────────────────────────────────────────────────────────────
259
+
260
+ const events = (): AsyncIterable<CdpEvent> => queue.iter;
261
+
262
+ // ── State ──────────────────────────────────────────────────────────────
263
+
264
+ const state = (): "open" | "closed" | "connecting" => {
265
+ if (!socket) return "closed";
266
+ if (!registered) return "connecting";
267
+ if (socket.destroyed) return "closed";
268
+ return "open";
269
+ };
270
+
271
+ const onClose = (cb: () => void): (() => void) => {
272
+ closeListeners.add(cb);
273
+ return () => closeListeners.delete(cb);
274
+ };
275
+
276
+ return { connect, close, request, events, state, onClose };
277
+ };