pi-browser-harness 0.3.2 → 0.5.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 +45 -0
- package/README.md +150 -56
- package/package.json +1 -1
- package/skills/pi-browser-harness/SKILL.md +37 -248
- package/src/cdp/console-buffer.ts +263 -0
- package/src/cdp/network-buffer.ts +213 -0
- package/src/cdp/ownership.ts +72 -0
- package/src/cdp/session.ts +173 -26
- package/src/cdp/types.ts +1 -0
- package/src/client.ts +88 -12
- package/src/domains/click.ts +6 -5
- package/src/domains/console.ts +173 -0
- package/src/domains/js.ts +65 -6
- package/src/domains/navigate.ts +1 -1
- package/src/domains/network.ts +191 -20
- package/src/domains/screenshot.ts +8 -5
- package/src/domains/snapshot.ts +421 -0
- package/src/domains/tabs.ts +113 -39
- package/src/index.ts +17 -1
- package/src/prompt.ts +3 -0
- package/src/registry.ts +8 -3
- package/src/state.ts +6 -1
|
@@ -1,271 +1,60 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pi-browser-harness
|
|
3
|
-
description: Direct browser control via CDP. Use when the user wants to automate, scrape, test, or interact with web pages. Connects to the user's already-running Chrome.
|
|
3
|
+
description: Direct browser control via CDP. Use when the user wants to automate, scrape, test, or interact with web pages. Connects to the user's already-running Chrome. Default to browser_snapshot for understanding pages and browser_execute_js for surgical reads — browser_screenshot is for visual verification only.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# pi-browser-harness
|
|
7
7
|
|
|
8
|
-
Direct browser control
|
|
9
|
-
Browser tools coexist with all standard pi tools (read, bash, edit, write, etc.).
|
|
8
|
+
Direct browser control of the user's running Chrome via CDP.
|
|
10
9
|
|
|
11
|
-
##
|
|
10
|
+
## Tool hierarchy
|
|
12
11
|
|
|
13
|
-
Run `/browser-setup` to install browser-harness and connect to Chrome. Chrome must be running
|
|
14
|
-
with remote debugging enabled. Start Chrome with:
|
|
15
|
-
|
|
16
|
-
```
|
|
17
|
-
--remote-debugging-port=9222
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
Check status with `/browser-status`. Restart the daemon with `/browser-reload-daemon`.
|
|
21
|
-
|
|
22
|
-
## Core Workflow
|
|
23
|
-
|
|
24
|
-
```
|
|
25
|
-
browser_screenshot() → browser_click(x, y) → browser_screenshot()
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
1. **Screenshot first** — see what's on screen before any interaction
|
|
29
|
-
2. **Coordinate click** — read the target pixel from the screenshot, click at (x, y)
|
|
30
|
-
3. **Screenshot again** — verify the action worked
|
|
31
|
-
|
|
32
|
-
Compositor-level clicks (`Input.dispatchMouseEvent`) work through iframes, shadow DOM,
|
|
33
|
-
and cross-origin content. No selectors needed.
|
|
34
|
-
|
|
35
|
-
## Parallelization
|
|
36
|
-
|
|
37
|
-
**Always parallelize when possible.** The harness automatically serializes mutation tools so they never race on shared state. Emit independent tools in the same turn for better performance.
|
|
38
|
-
|
|
39
|
-
### What can run in parallel
|
|
40
|
-
|
|
41
|
-
- **Observation tools** (`browser_screenshot`, `browser_page_info`, `browser_execute_js`, `browser_list_tabs`, `browser_current_tab`, `browser_http_get`, `browser_print_to_pdf`) can run in parallel with each other and with mutation tools.
|
|
42
|
-
|
|
43
|
-
- **Opening result pages:** Use `browser_open_urls` to open multiple URLs in parallel tabs. After opening, use `browser_list_tabs` to see the new tabs and `browser_switch_tab` to visit each one.
|
|
44
|
-
|
|
45
|
-
- **API calls:** `browser_http_get` calls are independent of browser state — fire multiple GETs in parallel.
|
|
46
|
-
|
|
47
|
-
- **Read + browser tools:** Use `read` and `browser_*` tools in the same turn — reading files or previous results is independent of browser actions.
|
|
48
|
-
|
|
49
|
-
### What must be sequential (harness enforces this)
|
|
50
|
-
|
|
51
|
-
- **Mutation tools** (`click`, `type`, `scroll`, `navigate`, `switch_tab`, `press_key`, `drag_and_drop`, `upload_file`, `go_back`, `go_forward`, `reload`, `handle_dialog`, `viewport_resize`, `wait_for_load`, `download`) are automatically queued through a mutex. Even if you emit them in parallel, they execute serially in FIFO order. You do not need to worry about race conditions.
|
|
52
|
-
|
|
53
|
-
- **Tab switching then acting:** `browser_switch_tab` must complete before acting on the switched-to tab. This is enforced automatically.
|
|
54
|
-
|
|
55
|
-
### Pattern: Parallel research across tabs
|
|
56
|
-
|
|
57
|
-
```
|
|
58
|
-
# Step 1: Open all result pages at once
|
|
59
|
-
browser_open_urls(["url1", "url2", "url3"])
|
|
60
|
-
|
|
61
|
-
# Step 2: Wait for them to load, then extract in parallel
|
|
62
|
-
browser_list_tabs() # get targetIds
|
|
63
|
-
|
|
64
|
-
# Step 3: Extract data from each tab (parallel)
|
|
65
|
-
browser_switch_tab(targetId1) → browser_screenshot() # These are sequential per tab
|
|
66
|
-
browser_switch_tab(targetId2) → browser_execute_js(...) # but you batch switch+act
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
### Pattern: Parallel observation + mutation
|
|
70
|
-
|
|
71
|
-
```
|
|
72
|
-
# Fire observation tools while a mutation is in progress
|
|
73
|
-
browser_click(x, y) # mutation (queued)
|
|
74
|
-
browser_screenshot() # observation (runs immediately in parallel)
|
|
75
|
-
browser_page_info() # observation (runs immediately in parallel)
|
|
76
|
-
browser_http_get("https://api.example.com") # external (runs immediately in parallel)
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
## Available Tools
|
|
80
|
-
|
|
81
|
-
### Navigation
|
|
82
|
-
- `browser_navigate` — navigate to a URL (creates new tab on first call, reuses current tab after)
|
|
83
|
-
- `browser_new_tab` — open a new tab, optionally navigate (use for first navigation)
|
|
84
|
-
- `browser_open_urls` — open multiple URLs in parallel tabs (use after search to open result links)
|
|
85
|
-
- `browser_go_back` / `browser_go_forward` / `browser_reload` — history navigation
|
|
86
|
-
- `browser_page_info` — get URL, title, viewport, scroll position, or dialog info
|
|
87
|
-
- `browser_list_tabs` / `browser_current_tab` / `browser_switch_tab` — tab management
|
|
88
|
-
|
|
89
|
-
### Interaction
|
|
90
|
-
- `browser_click` — click at viewport CSS-pixel coordinates
|
|
91
|
-
- `browser_type` — type text into focused element
|
|
92
|
-
- `browser_press_key` — press a key (Enter, Tab, Escape, arrows, etc.)
|
|
93
|
-
- `browser_scroll` — scroll the page at coordinates
|
|
94
|
-
|
|
95
|
-
### Visual
|
|
96
|
-
- `browser_screenshot` — capture PNG or JPEG screenshot (rendered inline in the TUI)
|
|
97
|
-
|
|
98
|
-
### Data Extraction
|
|
99
|
-
- `browser_execute_js` — execute JavaScript and return result
|
|
100
|
-
- `browser_http_get` — direct HTTP GET outside browser (10-50x faster for APIs)
|
|
101
|
-
|
|
102
|
-
### Utility
|
|
103
|
-
- `browser_wait` — wait N seconds
|
|
104
|
-
- `browser_wait_for_load` — wait for document.readyState === 'complete'
|
|
105
|
-
- `browser_handle_dialog` — accept or dismiss JS dialogs (alert/confirm/prompt)
|
|
106
|
-
|
|
107
|
-
### Extending
|
|
108
|
-
- `browser_run_script` — execute a temporary script file with daemon access
|
|
109
|
-
|
|
110
|
-
## Pattern Reference
|
|
111
|
-
|
|
112
|
-
### Navigation
|
|
113
|
-
```
|
|
114
|
-
browser_new_tab("https://example.com") → browser_wait_for_load() → browser_screenshot()
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
### Form Filling
|
|
118
|
-
```
|
|
119
|
-
browser_screenshot() → find input coordinates
|
|
120
|
-
→ browser_click(x, y) → browser_type("text")
|
|
121
|
-
→ browser_press_key("Tab") → browser_screenshot()
|
|
122
12
|
```
|
|
13
|
+
What do you need to know?
|
|
123
14
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
browser_execute_js("document.querySelector('.price').innerText")
|
|
127
|
-
// or for APIs directly:
|
|
128
|
-
browser_http_get("https://api.example.com/data")
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
### Scrolling
|
|
132
|
-
```
|
|
133
|
-
browser_screenshot() → browser_scroll({ deltaY: 500 }) → browser_screenshot()
|
|
134
|
-
```
|
|
135
|
-
Note: deltaY follows W3C wheel-event convention: positive=down, negative=up. Default deltaY=300 scrolls down.
|
|
136
|
-
|
|
137
|
-
### Research (search engine + browser_open_urls)
|
|
138
|
-
```
|
|
139
|
-
browser_new_tab("https://google.com/search?q=query") → search engine results
|
|
140
|
-
browser_open_urls({urls: ["url1", "url2"]}) → open result pages in parallel tabs
|
|
141
|
-
browser_list_tabs() → see all open tabs with targetIds
|
|
142
|
-
browser_switch_tab({targetId: "..."}) → switch to a specific tab
|
|
143
|
-
browser_wait_for_load() → wait for the page to render
|
|
144
|
-
browser_screenshot() → visually inspect the page
|
|
145
|
-
browser_execute_js("document.querySelector('.content').innerText") → extract text
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
## Temporary Scripts
|
|
149
|
-
|
|
150
|
-
When the built-in tools aren't enough, write a temporary script to disk and execute it
|
|
151
|
-
with `browser_run_script`. Scripts run in the harness process with direct access to
|
|
152
|
-
the browser daemon and full Node.js APIs — no dynamic tool registration needed.
|
|
153
|
-
|
|
154
|
-
```
|
|
155
|
-
● agent: needs to scrape 47 paginated search result pages
|
|
15
|
+
├─ Page structure / what's clickable / labels?
|
|
16
|
+
│ → browser_snapshot (DEFAULT — AX tree with @(x,y) per interactive element)
|
|
156
17
|
│
|
|
157
|
-
|
|
18
|
+
├─ A specific element's value / attribute / coords?
|
|
19
|
+
│ → browser_execute_js (e.g. el.innerText, el.getBoundingClientRect())
|
|
158
20
|
│
|
|
159
|
-
|
|
21
|
+
├─ Network behavior on the current page?
|
|
22
|
+
│ → browser_network_requests
|
|
160
23
|
│
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
**When to use scripts:**
|
|
167
|
-
- You're about to do the same 3+ step sequence more than once
|
|
168
|
-
- You need a capability the built-in tools don't provide (e.g., bulk CSV export, structured extraction, pagination)
|
|
169
|
-
- You discover a domain-specific pattern that should be reusable
|
|
170
|
-
- The alternative is a fragile multi-turn loop of screenshots + clicks
|
|
171
|
-
|
|
172
|
-
**What NOT to do:**
|
|
173
|
-
- Don't write scripts for one-off actions — use the built-in browser_* tools directly.
|
|
174
|
-
- Don't try to call browser_* tools from within a script — sequence them as separate tool calls.
|
|
175
|
-
|
|
176
|
-
**Script bindings:**
|
|
177
|
-
- `params` — the arguments passed to browser_run_script
|
|
178
|
-
- `daemon` — browser daemon with these methods:
|
|
179
|
-
- `daemon.evaluateJs(expression)` — run JS in the current page
|
|
180
|
-
- `daemon.pageInfo()` — get current page info or dialog
|
|
181
|
-
- `daemon.listTabs()` — returns `{ success, data: [{ targetId, url, title }] }`
|
|
182
|
-
- `daemon.switchTab(targetId)` — switch to a tab by full targetId
|
|
183
|
-
- `daemon.newTab(url?)` — open and switch to a new tab
|
|
184
|
-
- `daemon.current()` — returns `{ targetId, url?, title? }`
|
|
185
|
-
- `daemon.session(targetId)` — returns a session object for raw CDP:
|
|
186
|
-
- `session.call(method, params?)` — CDP command on the page target
|
|
187
|
-
- `session.callOnTarget(method, params, sessionId)` — CDP on a specific session
|
|
188
|
-
- `session.callBrowser(method, params?)` — CDP on the browser target
|
|
189
|
-
- `session.takeDialog()` — get/dismiss pending dialog
|
|
190
|
-
- `require` — Node.js require() (use 'fs', 'path', 'crypto', etc.)
|
|
191
|
-
- `signal` — AbortSignal for cancellation
|
|
192
|
-
- `onUpdate` — progress callback: onUpdate({ content: [{ type: 'text', text: '...' }] })
|
|
193
|
-
- `ctx` — ExtensionContext (ctx.cwd, ctx.signal, etc.)
|
|
194
|
-
- `console`, `fetch`, `JSON`, `Buffer`, `setTimeout`, `clearTimeout`
|
|
195
|
-
|
|
196
|
-
**Example — a script that extracts structured data:**
|
|
197
|
-
```javascript
|
|
198
|
-
// /tmp/extract-products.js
|
|
199
|
-
const info = await daemon.pageInfo();
|
|
200
|
-
if (info && "dialog" in info) throw new Error("Dialog is blocking: " + info.dialog.message);
|
|
201
|
-
const data = await daemon.evaluateJs(`
|
|
202
|
-
JSON.stringify(Array.from(document.querySelectorAll('${params.rowSelector}')).map(el => ({
|
|
203
|
-
title: el.querySelector('${params.titleSelector}')?.textContent?.trim(),
|
|
204
|
-
price: el.querySelector('${params.priceSelector}')?.textContent?.trim(),
|
|
205
|
-
})))
|
|
206
|
-
`);
|
|
207
|
-
return { content: [{ type: 'text', text: data }], details: { raw: JSON.parse(data) } };
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
## What NOT To Do
|
|
211
|
-
|
|
212
|
-
- **Don't launch your own browser** — you're connected to the user's real Chrome
|
|
213
|
-
- **Don't type credentials** — if you hit an auth wall, stop and ask the user
|
|
214
|
-
- **Don't assume page state** — screenshot to confirm
|
|
215
|
-
- **Don't write pixel coordinates into documentation** — describe how to locate targets (selectors, text, aria-label)
|
|
216
|
-
- **Don't use browser_navigate for first navigation** — use browser_new_tab to explicitly preserve the user's active tab (browser_navigate auto-creates a new tab on first call, but explicit is clearer)
|
|
217
|
-
- **Don't ignore dialogs** — if browser_page_info returns a dialog, handle it first
|
|
218
|
-
|
|
219
|
-
## Dialogs
|
|
220
|
-
|
|
221
|
-
JS dialogs (alert, confirm, prompt, beforeunload) freeze the page's JS thread.
|
|
222
|
-
If browser_page_info returns a dialog, handle it with browser_handle_dialog
|
|
223
|
-
before any other browser action.
|
|
224
|
-
|
|
225
|
-
```
|
|
226
|
-
// Check page_info first — if it shows a dialog:
|
|
227
|
-
browser_handle_dialog({ action: "accept" }) // accept/confirm
|
|
228
|
-
browser_handle_dialog({ action: "dismiss" }) // dismiss/cancel
|
|
229
|
-
browser_handle_dialog({ action: "accept", promptText: "hello" }) // prompt with text
|
|
24
|
+
├─ JS errors / why did nothing happen after an action?
|
|
25
|
+
│ → browser_console (DIAGNOSTIC — only when something looks broken)
|
|
26
|
+
│
|
|
27
|
+
└─ Visual rendering (layout / colors / chart drew correctly)?
|
|
28
|
+
→ browser_screenshot (LAST RESORT — pixels only)
|
|
230
29
|
```
|
|
231
30
|
|
|
232
|
-
|
|
31
|
+
Pass `@(x,y)` from `browser_snapshot` straight to `browser_click`. No screenshot round-trip.
|
|
233
32
|
|
|
234
|
-
|
|
235
|
-
Single-page apps often paint content after this. After wait_for_load, use
|
|
236
|
-
`browser_execute_js` to check for specific elements:
|
|
33
|
+
## Connection
|
|
237
34
|
|
|
238
|
-
|
|
239
|
-
browser_execute_js("!!document.querySelector('.loaded-content')")
|
|
240
|
-
```
|
|
35
|
+
You're attached to the user's real Chrome — never launch your own. If auth is required, stop and ask the user. If `browser_page_info` returns a dialog, handle it first with `browser_handle_dialog`.
|
|
241
36
|
|
|
242
|
-
##
|
|
37
|
+
## Diagnosing a "nothing happened" moment
|
|
243
38
|
|
|
244
|
-
`
|
|
245
|
-
- 1 = Alt, 2 = Ctrl, 4 = Meta/Cmd, 8 = Shift
|
|
246
|
-
- Combine: Ctrl+Shift = 2|8 = 10, Cmd+Shift = 4|8 = 12
|
|
39
|
+
When an action runs but the page didn't change, capture `browser_console`'s `nextCursor` *before* the action, take the action, then call `browser_console({ sinceSeq: <cursor> })` after — this isolates what your action caused from what was already there. Pair with `browser_network_requests({ sinceMs: 5000 })` to see if an API call fired and failed. The console buffer is page-scoped: it clears on tab switch, capacity 500.
|
|
247
40
|
|
|
248
|
-
##
|
|
41
|
+
## Temporary scripts
|
|
249
42
|
|
|
250
|
-
|
|
251
|
-
- **"DevToolsActivePort not found"**: Open chrome://inspect/#remote-debugging, tick checkbox, click Allow
|
|
252
|
-
- **Stale session**: Run `/browser-reload-daemon` to restart the daemon
|
|
253
|
-
- **Dialog blocking**: Run browser_page_info to detect it, then browser_handle_dialog
|
|
254
|
-
- **Status check**: Run `/browser-status` to see daemon health and current page
|
|
255
|
-
- **Screenshot maxDim not working**: Install sharp (`npm install sharp`) for auto-resize support
|
|
256
|
-
- **Google navigation fails**: Google's anti-bot detection may reject CDP navigation; use `browser_http_get` to fetch search results instead
|
|
43
|
+
When a workflow repeats 3+ times or needs Node.js APIs, write a script to disk and run it with `browser_run_script`. Scripts get a `daemon` binding for direct CDP access — much faster than chaining tool calls.
|
|
257
44
|
|
|
258
|
-
|
|
45
|
+
**Bindings inside a script:**
|
|
259
46
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
47
|
+
- `params` — args passed to `browser_run_script`
|
|
48
|
+
- `daemon`:
|
|
49
|
+
- `daemon.evaluateJs(expression)` — run JS in the current page
|
|
50
|
+
- `daemon.pageInfo()` — `{ url, title, ... }` or `{ dialog }`
|
|
51
|
+
- `daemon.listTabs()` / `daemon.switchTab(targetId)` / `daemon.newTab(url?)` / `daemon.current()`
|
|
52
|
+
- `daemon.session(targetId)` for raw CDP: `session.call`, `session.callOnTarget`, `session.callBrowser`, `session.takeDialog`
|
|
53
|
+
- `require`, `fetch`, `JSON`, `Buffer`, `console`, `setTimeout`, `clearTimeout`
|
|
54
|
+
- `signal` — AbortSignal
|
|
55
|
+
- `onUpdate({ content: [{ type: 'text', text }] })` — progress callback
|
|
56
|
+
- `ctx` — `ExtensionContext`
|
|
57
|
+
|
|
58
|
+
**Don't:**
|
|
59
|
+
- Use scripts for one-off actions — call `browser_*` tools directly.
|
|
60
|
+
- Call `browser_*` tools from inside a script — sequence them as separate tool calls outside.
|
|
@@ -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
|
+
};
|