@ulpi/browse 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.
- package/package.json +7 -2
- package/skill/SKILL.md +35 -10
- package/src/browser-manager.ts +5 -3
- package/src/bun.d.ts +23 -0
- package/src/cli.ts +19 -3
- package/src/commands/meta.ts +15 -12
- package/src/commands/write.ts +10 -1
- package/src/config.ts +1 -0
- package/src/rebrowser.d.ts +7 -0
- package/src/runtime.ts +161 -0
- package/src/server.ts +22 -3
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ulpi/browse",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/ulpi-io/browse"
|
|
7
7
|
},
|
|
8
8
|
"dependencies": {
|
|
9
|
+
"@lightpanda/browser": "^1.2.0",
|
|
9
10
|
"@ulpi/browse": "^0.3.0",
|
|
10
11
|
"diff": "^7.0.0",
|
|
11
|
-
"playwright": "^1.58.2"
|
|
12
|
+
"playwright": "^1.58.2",
|
|
13
|
+
"playwright-core": "^1.58.2"
|
|
12
14
|
},
|
|
13
15
|
"bin": {
|
|
14
16
|
"browse": "bin/browse.ts"
|
|
@@ -52,5 +54,8 @@
|
|
|
52
54
|
"devDependencies": {
|
|
53
55
|
"@types/node": "^25.5.0",
|
|
54
56
|
"typescript": "^5.9.3"
|
|
57
|
+
},
|
|
58
|
+
"optionalDependencies": {
|
|
59
|
+
"rebrowser-playwright": "^1.52.0"
|
|
55
60
|
}
|
|
56
61
|
}
|
package/skill/SKILL.md
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: browse
|
|
3
|
-
version: 2.
|
|
3
|
+
version: 2.4.0
|
|
4
4
|
description: |
|
|
5
|
-
Fast web browsing for
|
|
5
|
+
Fast web browsing for AI coding agents via persistent headless Chromium daemon. Navigate to any URL,
|
|
6
6
|
read page content, click elements, fill forms, run JavaScript, take screenshots,
|
|
7
7
|
inspect CSS/DOM, capture console/network logs, and more. ~100ms per command after
|
|
8
|
-
first call.
|
|
9
|
-
|
|
8
|
+
first call. Works with Claude Code, Cursor, Cline, Windsurf, and any agent that can run Bash.
|
|
9
|
+
No MCP, no Chrome extension — just fast CLI.
|
|
10
10
|
allowed-tools:
|
|
11
11
|
- Bash
|
|
12
12
|
- Read
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
# browse: Persistent Browser for
|
|
16
|
+
# browse: Persistent Browser for AI Coding Agents
|
|
17
17
|
|
|
18
18
|
Persistent headless Chromium daemon. First call auto-starts the server (~3s).
|
|
19
19
|
Every subsequent call: ~100-200ms. Auto-shuts down after 30 min idle.
|
|
@@ -74,7 +74,7 @@ If the file is missing or does not contain browse permission rules in `permissio
|
|
|
74
74
|
"Bash(browse newtab:*)", "Bash(browse closetab:*)",
|
|
75
75
|
"Bash(browse frame:*)",
|
|
76
76
|
"Bash(browse sessions:*)", "Bash(browse session-close:*)",
|
|
77
|
-
"Bash(browse state:*)", "Bash(browse auth:*)", "Bash(browse har:*)",
|
|
77
|
+
"Bash(browse state:*)", "Bash(browse auth:*)", "Bash(browse har:*)", "Bash(browse video:*)",
|
|
78
78
|
"Bash(browse route:*)", "Bash(browse offline:*)",
|
|
79
79
|
"Bash(browse status:*)", "Bash(browse stop:*)", "Bash(browse restart:*)",
|
|
80
80
|
"Bash(browse cookie:*)", "Bash(browse header:*)",
|
|
@@ -89,6 +89,8 @@ If the file is missing or does not contain browse permission rules in `permissio
|
|
|
89
89
|
- Always call `browse` as a bare command (it's on PATH via global install).
|
|
90
90
|
- Do NOT use shell variables like `B=...` or full paths — they break Claude Code's permission matching.
|
|
91
91
|
- NEVER use `#` in CSS selectors — use `[id=foo]` instead of `#foo`. The `#` character breaks Claude Code's permission matching and triggers approval prompts.
|
|
92
|
+
- After `goto`, always run `browse wait --network-idle` before reading content or taking screenshots. Pages with dynamic content, SPAs, and lazy-loaded assets need time to fully render.
|
|
93
|
+
- Screenshots MUST be saved to `.browse/sessions/default/` (or `.browse/sessions/<session-id>/` when using `--session`). Use descriptive filenames like `browse screenshot .browse/sessions/default/homepage.png`. NEVER save screenshots to `/tmp` or any other location.
|
|
92
94
|
- The browser persists between calls — cookies, tabs, and state carry over.
|
|
93
95
|
- The server auto-starts on first command. No manual setup needed.
|
|
94
96
|
- Use `--session <id>` for parallel agent isolation. Each session gets its own tabs, refs, cookies.
|
|
@@ -105,8 +107,8 @@ browse goto https://example.com
|
|
|
105
107
|
# Read cleaned page text
|
|
106
108
|
browse text
|
|
107
109
|
|
|
108
|
-
# Take a screenshot (then Read the image)
|
|
109
|
-
browse screenshot
|
|
110
|
+
# Take a screenshot (then Read the image — saved to .browse/sessions/default/screenshot.png)
|
|
111
|
+
browse screenshot
|
|
110
112
|
|
|
111
113
|
# Snapshot: accessibility tree with refs
|
|
112
114
|
browse snapshot -i
|
|
@@ -197,6 +199,12 @@ browse har start
|
|
|
197
199
|
browse goto https://example.com
|
|
198
200
|
browse har stop ./recording.har
|
|
199
201
|
|
|
202
|
+
# Video recording
|
|
203
|
+
browse video start ./videos
|
|
204
|
+
browse goto https://example.com
|
|
205
|
+
browse click @e3
|
|
206
|
+
browse video stop
|
|
207
|
+
|
|
200
208
|
# Device emulation
|
|
201
209
|
browse emulate iphone
|
|
202
210
|
browse emulate reset
|
|
@@ -220,6 +228,10 @@ browse screenshot-diff baseline.png current.png
|
|
|
220
228
|
# Headed mode (visible browser)
|
|
221
229
|
browse --headed goto https://example.com
|
|
222
230
|
|
|
231
|
+
# Stealth mode (bypasses bot detection)
|
|
232
|
+
# Requires: bun add rebrowser-playwright && npx rebrowser-playwright install chromium
|
|
233
|
+
browse --runtime rebrowser goto https://example.com
|
|
234
|
+
|
|
223
235
|
# State list / show
|
|
224
236
|
browse state list
|
|
225
237
|
browse state show mysite
|
|
@@ -271,6 +283,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
|
|
271
283
|
### Interaction
|
|
272
284
|
```
|
|
273
285
|
browse click <selector> Click element (CSS selector or @ref)
|
|
286
|
+
browse click <x>,<y> Click at page coordinates (e.g. 590,461)
|
|
274
287
|
browse dblclick <selector> Double-click element
|
|
275
288
|
browse fill <selector> <value> Fill input field
|
|
276
289
|
browse select <selector> <val> Select dropdown value
|
|
@@ -325,7 +338,8 @@ browse clipboard write <text> Write text to system clipboard
|
|
|
325
338
|
|
|
326
339
|
### Visual
|
|
327
340
|
```
|
|
328
|
-
browse screenshot [path]
|
|
341
|
+
browse screenshot [path] Viewport screenshot (default: .browse/sessions/{id}/screenshot.png)
|
|
342
|
+
browse screenshot --full [path] Full-page screenshot (entire scrollable page)
|
|
329
343
|
browse screenshot --annotate [path] Screenshot with numbered badges + legend
|
|
330
344
|
browse pdf [path] Save as PDF
|
|
331
345
|
browse responsive [prefix] Screenshots at mobile/tablet/desktop
|
|
@@ -394,6 +408,13 @@ browse har start Start recording network traffic
|
|
|
394
408
|
browse har stop [path] Stop and save HAR file
|
|
395
409
|
```
|
|
396
410
|
|
|
411
|
+
### Video recording
|
|
412
|
+
```
|
|
413
|
+
browse video start [dir] Start recording video (WebM, compositor-level)
|
|
414
|
+
browse video stop Stop recording and save video files
|
|
415
|
+
browse video status Check if recording is active
|
|
416
|
+
```
|
|
417
|
+
|
|
397
418
|
### Server management
|
|
398
419
|
```
|
|
399
420
|
browse status Server health, uptime, session count
|
|
@@ -412,6 +433,7 @@ browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
|
|
|
412
433
|
| `--content-boundaries` | Wrap page content in nonce-delimited markers (prompt injection defense) |
|
|
413
434
|
| `--allowed-domains <d,d>` | Block navigation/resources outside allowlist |
|
|
414
435
|
| `--headed` | Run browser in headed (visible) mode |
|
|
436
|
+
| `--runtime <name>` | Browser engine: playwright (default), rebrowser (stealth) |
|
|
415
437
|
|
|
416
438
|
## Speed Rules
|
|
417
439
|
|
|
@@ -436,7 +458,7 @@ browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
|
|
|
436
458
|
| Check if element exists | `count ".thing"` |
|
|
437
459
|
| Get input value | `value "[id=email]"` |
|
|
438
460
|
| Extract specific data | `js "document.querySelector('.price').textContent"` |
|
|
439
|
-
| Visual check | `screenshot .browse/sessions/default/
|
|
461
|
+
| Visual check | `screenshot` then `Read .browse/sessions/default/screenshot.png` |
|
|
440
462
|
| Fill and submit form | `snapshot -i` → `fill @e4 "val"` → `click @e5` |
|
|
441
463
|
| Check/uncheck boxes | `check @e7` / `uncheck @e7` |
|
|
442
464
|
| Check CSS | `css "selector" "property"` or `css @e3 "property"` |
|
|
@@ -454,6 +476,7 @@ browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
|
|
|
454
476
|
| Save/restore session | `state save mysite` / `state load mysite` |
|
|
455
477
|
| Auto-login | `auth save gh https://github.com/login user pass` → `auth login gh` |
|
|
456
478
|
| Record network | `har start` → browse around → `har stop ./out.har` |
|
|
479
|
+
| Record video | `video start ./vids` → browse around → `video stop` |
|
|
457
480
|
| Parallel agents | `--session agent-a <cmd>` / `--session agent-b <cmd>` |
|
|
458
481
|
| Multi-step flow | `echo '[...]' \| browse chain` |
|
|
459
482
|
| Secure browsing | `--allowed-domains example.com goto https://example.com` |
|
|
@@ -464,6 +487,7 @@ browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
|
|
|
464
487
|
| Visual regression | `screenshot-diff baseline.png` |
|
|
465
488
|
| Debug with DevTools | `inspect` (set BROWSE_DEBUG_PORT first) |
|
|
466
489
|
| See the browser | `browse --headed goto <url>` |
|
|
490
|
+
| Bypass bot detection | `--runtime rebrowser goto <url>` |
|
|
467
491
|
|
|
468
492
|
## Architecture
|
|
469
493
|
|
|
@@ -482,3 +506,4 @@ browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
|
|
|
482
506
|
- AI-friendly error messages: Playwright errors rewritten to actionable hints
|
|
483
507
|
- CDP remote connection: `BROWSE_CDP_URL` to connect to existing Chrome
|
|
484
508
|
- Policy enforcement: `browse-policy.json` for allow/deny/confirm rules
|
|
509
|
+
- Two browser engines: playwright (default) and rebrowser (stealth, bypasses bot detection)
|
package/src/browser-manager.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* We do NOT try to self-heal — don't hide failure.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { devices as playwrightDevices, type Browser, type BrowserContext, type Page, type Locator, type Frame, type FrameLocator, type Request as PlaywrightRequest } from 'playwright';
|
|
11
|
+
import { getRuntime } from './runtime';
|
|
11
12
|
import { SessionBuffers, type LogEntry, type NetworkEntry } from './buffers';
|
|
12
13
|
import type { HarRecording } from './har';
|
|
13
14
|
import type { DomainFilter } from './domain-filter';
|
|
@@ -202,8 +203,9 @@ export class BrowserManager {
|
|
|
202
203
|
* Launch a new Chromium browser (single-session / multi-process mode).
|
|
203
204
|
* This instance owns the browser and will close it on close().
|
|
204
205
|
*/
|
|
205
|
-
async launch(onCrash?: () => void) {
|
|
206
|
-
|
|
206
|
+
async launch(onCrash?: () => void, runtimeName?: string) {
|
|
207
|
+
const runtime = await getRuntime(runtimeName);
|
|
208
|
+
this.browser = await runtime.chromium.launch({ headless: true });
|
|
207
209
|
this.ownsBrowser = true;
|
|
208
210
|
|
|
209
211
|
// Chromium crash → notify caller (server uses this to exit; tests ignore it)
|
package/src/bun.d.ts
CHANGED
|
@@ -14,9 +14,23 @@ declare module 'bun' {
|
|
|
14
14
|
|
|
15
15
|
export function spawn(cmd: string[], options?: {
|
|
16
16
|
stdio?: Array<'ignore' | 'pipe' | 'inherit'>;
|
|
17
|
+
stdout?: 'pipe' | 'ignore' | 'inherit';
|
|
18
|
+
stderr?: 'pipe' | 'ignore' | 'inherit';
|
|
19
|
+
stdin?: 'pipe' | 'ignore' | 'inherit';
|
|
17
20
|
env?: Record<string, string | undefined>;
|
|
18
21
|
}): BunSubprocess;
|
|
19
22
|
|
|
23
|
+
export function listen(options: {
|
|
24
|
+
hostname: string;
|
|
25
|
+
port: number;
|
|
26
|
+
socket: {
|
|
27
|
+
data: (...args: any[]) => void;
|
|
28
|
+
open?: (...args: any[]) => void;
|
|
29
|
+
close?: (...args: any[]) => void;
|
|
30
|
+
error?: (...args: any[]) => void;
|
|
31
|
+
};
|
|
32
|
+
}): BunTCPServer;
|
|
33
|
+
|
|
20
34
|
export function sleep(ms: number): Promise<void>;
|
|
21
35
|
|
|
22
36
|
export const stdin: { text(): Promise<string> };
|
|
@@ -26,10 +40,18 @@ declare module 'bun' {
|
|
|
26
40
|
stop(): void;
|
|
27
41
|
}
|
|
28
42
|
|
|
43
|
+
interface BunTCPServer {
|
|
44
|
+
port: number;
|
|
45
|
+
stop(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
29
48
|
interface BunSubprocess {
|
|
30
49
|
pid: number;
|
|
50
|
+
exitCode: number | null;
|
|
31
51
|
stderr: ReadableStream<Uint8Array> | null;
|
|
32
52
|
stdout: ReadableStream<Uint8Array> | null;
|
|
53
|
+
exited: Promise<number>;
|
|
54
|
+
kill(signal?: number): void;
|
|
33
55
|
unref(): void;
|
|
34
56
|
}
|
|
35
57
|
}
|
|
@@ -37,6 +59,7 @@ declare module 'bun' {
|
|
|
37
59
|
declare var Bun: {
|
|
38
60
|
serve: typeof import('bun').serve;
|
|
39
61
|
spawn: typeof import('bun').spawn;
|
|
62
|
+
listen: typeof import('bun').listen;
|
|
40
63
|
sleep: typeof import('bun').sleep;
|
|
41
64
|
stdin: typeof import('bun').stdin;
|
|
42
65
|
};
|
package/src/cli.ts
CHANGED
|
@@ -20,6 +20,7 @@ const cliFlags = {
|
|
|
20
20
|
contentBoundaries: false,
|
|
21
21
|
allowedDomains: '' as string,
|
|
22
22
|
headed: false,
|
|
23
|
+
runtime: '' as string,
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
|
|
@@ -252,7 +253,7 @@ async function startServer(): Promise<ServerState> {
|
|
|
252
253
|
: ['bun', 'run', SERVER_SCRIPT];
|
|
253
254
|
const proc = Bun.spawn(spawnCmd, {
|
|
254
255
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
255
|
-
env: { ...process.env, __BROWSE_SERVER_MODE: '1', BROWSE_LOCAL_DIR: LOCAL_DIR, BROWSE_INSTANCE, ...(cliFlags.headed ? { BROWSE_HEADED: '1' } : {}) },
|
|
256
|
+
env: { ...process.env, __BROWSE_SERVER_MODE: '1', BROWSE_LOCAL_DIR: LOCAL_DIR, BROWSE_INSTANCE, ...(cliFlags.headed ? { BROWSE_HEADED: '1' } : {}), ...(cliFlags.runtime ? { BROWSE_RUNTIME: cliFlags.runtime } : {}) },
|
|
256
257
|
});
|
|
257
258
|
|
|
258
259
|
// Don't hold the CLI open
|
|
@@ -511,7 +512,7 @@ export async function main() {
|
|
|
511
512
|
for (let i = 0; i < a.length; i++) {
|
|
512
513
|
if (!a[i].startsWith('-')) return i;
|
|
513
514
|
// Skip flag values for known value-flags
|
|
514
|
-
if (a[i] === '--session' || a[i] === '--allowed-domains') i++;
|
|
515
|
+
if (a[i] === '--session' || a[i] === '--allowed-domains' || a[i] === '--runtime') i++;
|
|
515
516
|
}
|
|
516
517
|
return a.length;
|
|
517
518
|
}
|
|
@@ -569,11 +570,25 @@ export async function main() {
|
|
|
569
570
|
}
|
|
570
571
|
headed = headed || process.env.BROWSE_HEADED === '1';
|
|
571
572
|
|
|
573
|
+
// Extract --runtime flag (only before command)
|
|
574
|
+
let runtimeName: string | undefined;
|
|
575
|
+
const runtimeIdx = args.indexOf('--runtime');
|
|
576
|
+
if (runtimeIdx !== -1 && runtimeIdx < findCommandIndex(args)) {
|
|
577
|
+
runtimeName = args[runtimeIdx + 1];
|
|
578
|
+
if (!runtimeName || runtimeName.startsWith('-')) {
|
|
579
|
+
console.error('Usage: browse --runtime <name> <command> [args...]');
|
|
580
|
+
process.exit(1);
|
|
581
|
+
}
|
|
582
|
+
args.splice(runtimeIdx, 2);
|
|
583
|
+
}
|
|
584
|
+
runtimeName = runtimeName || process.env.BROWSE_RUNTIME || config.runtime || undefined;
|
|
585
|
+
|
|
572
586
|
// Set global flags for sendCommand()
|
|
573
587
|
cliFlags.json = jsonMode;
|
|
574
588
|
cliFlags.contentBoundaries = contentBoundaries;
|
|
575
589
|
cliFlags.allowedDomains = allowedDomains || '';
|
|
576
590
|
cliFlags.headed = headed;
|
|
591
|
+
cliFlags.runtime = runtimeName || '';
|
|
577
592
|
|
|
578
593
|
// ─── Local commands (no server needed) ─────────────────────
|
|
579
594
|
if (args[0] === 'instances') {
|
|
@@ -605,7 +620,7 @@ Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
|
|
|
605
620
|
element-state <sel> | console [--clear] | network [--clear]
|
|
606
621
|
cookies | storage [set <k> <v>] | perf
|
|
607
622
|
value <sel> | count <sel> | clipboard [write <text>]
|
|
608
|
-
Visual: screenshot [path] | pdf [path] | responsive [prefix]
|
|
623
|
+
Visual: screenshot [path] [--full] [--annotate] | pdf [path] | responsive [prefix]
|
|
609
624
|
Snapshot: snapshot [-i] [-c] [-C] [-d N] [-s sel]
|
|
610
625
|
Find: find role|text|label|placeholder|testid <query> [name]
|
|
611
626
|
Compare: diff <url1> <url2> | screenshot-diff <baseline> [current]
|
|
@@ -630,6 +645,7 @@ Options:
|
|
|
630
645
|
--content-boundaries Wrap page content in nonce-delimited markers
|
|
631
646
|
--allowed-domains <d,d> Block navigation/resources outside allowlist
|
|
632
647
|
--headed Run browser in headed (visible) mode
|
|
648
|
+
--runtime <name> Browser runtime (playwright, rebrowser, lightpanda)
|
|
633
649
|
|
|
634
650
|
Snapshot flags:
|
|
635
651
|
-i Interactive elements only (buttons, links, inputs)
|
package/src/commands/meta.ts
CHANGED
|
@@ -218,7 +218,8 @@ export async function handleMetaCommand(
|
|
|
218
218
|
case 'screenshot': {
|
|
219
219
|
const page = bm.getPage();
|
|
220
220
|
const annotate = args.includes('--annotate');
|
|
221
|
-
const
|
|
221
|
+
const isFullPage = args.includes('--full');
|
|
222
|
+
const filteredArgs = args.filter(a => a !== '--annotate' && a !== '--full');
|
|
222
223
|
const screenshotPath = filteredArgs[0] || (currentSession ? `${currentSession.outputDir}/screenshot.png` : `${LOCAL_DIR}/browse-screenshot.png`);
|
|
223
224
|
|
|
224
225
|
if (annotate) {
|
|
@@ -278,7 +279,7 @@ export async function handleMetaCommand(
|
|
|
278
279
|
document.body.appendChild(container);
|
|
279
280
|
}, badges);
|
|
280
281
|
|
|
281
|
-
await page.screenshot({ path: screenshotPath, fullPage:
|
|
282
|
+
await page.screenshot({ path: screenshotPath, fullPage: isFullPage });
|
|
282
283
|
} finally {
|
|
283
284
|
await page.evaluate(() => {
|
|
284
285
|
document.getElementById('__browse_annotate__')?.remove();
|
|
@@ -288,7 +289,7 @@ export async function handleMetaCommand(
|
|
|
288
289
|
return `Screenshot saved: ${screenshotPath}\n\nLegend:\n${legend.join('\n')}`;
|
|
289
290
|
}
|
|
290
291
|
|
|
291
|
-
await page.screenshot({ path: screenshotPath, fullPage:
|
|
292
|
+
await page.screenshot({ path: screenshotPath, fullPage: isFullPage });
|
|
292
293
|
return `Screenshot saved: ${screenshotPath}`;
|
|
293
294
|
}
|
|
294
295
|
|
|
@@ -481,14 +482,16 @@ export async function handleMetaCommand(
|
|
|
481
482
|
|
|
482
483
|
// ─── Screenshot Diff ──────────────────────────────
|
|
483
484
|
case 'screenshot-diff': {
|
|
484
|
-
const
|
|
485
|
-
|
|
485
|
+
const isFullPageDiff = args.includes('--full');
|
|
486
|
+
const diffArgs = args.filter(a => a !== '--full');
|
|
487
|
+
const baseline = diffArgs[0];
|
|
488
|
+
if (!baseline) throw new Error('Usage: browse screenshot-diff <baseline> [current] [--threshold 0.1] [--full]');
|
|
486
489
|
if (!fs.existsSync(baseline)) throw new Error(`Baseline file not found: ${baseline}`);
|
|
487
490
|
|
|
488
491
|
let thresholdPct = 0.1;
|
|
489
|
-
const threshIdx =
|
|
490
|
-
if (threshIdx !== -1 &&
|
|
491
|
-
thresholdPct = parseFloat(
|
|
492
|
+
const threshIdx = diffArgs.indexOf('--threshold');
|
|
493
|
+
if (threshIdx !== -1 && diffArgs[threshIdx + 1]) {
|
|
494
|
+
thresholdPct = parseFloat(diffArgs[threshIdx + 1]);
|
|
492
495
|
}
|
|
493
496
|
|
|
494
497
|
const baselineBuffer = fs.readFileSync(baseline);
|
|
@@ -496,16 +499,16 @@ export async function handleMetaCommand(
|
|
|
496
499
|
// Find optional current image path: any non-flag arg after baseline
|
|
497
500
|
let currentBuffer: Buffer;
|
|
498
501
|
let currentPath: string | undefined;
|
|
499
|
-
for (let i = 1; i <
|
|
500
|
-
if (
|
|
501
|
-
if (!
|
|
502
|
+
for (let i = 1; i < diffArgs.length; i++) {
|
|
503
|
+
if (diffArgs[i] === '--threshold') { i++; continue; }
|
|
504
|
+
if (!diffArgs[i].startsWith('--')) { currentPath = diffArgs[i]; break; }
|
|
502
505
|
}
|
|
503
506
|
if (currentPath) {
|
|
504
507
|
if (!fs.existsSync(currentPath)) throw new Error(`Current screenshot not found: ${currentPath}`);
|
|
505
508
|
currentBuffer = fs.readFileSync(currentPath);
|
|
506
509
|
} else {
|
|
507
510
|
const page = bm.getPage();
|
|
508
|
-
currentBuffer = await page.screenshot({ fullPage:
|
|
511
|
+
currentBuffer = await page.screenshot({ fullPage: isFullPageDiff }) as Buffer;
|
|
509
512
|
}
|
|
510
513
|
|
|
511
514
|
const { compareScreenshots } = await import('../png-compare');
|
package/src/commands/write.ts
CHANGED
|
@@ -73,7 +73,16 @@ export async function handleWriteCommand(
|
|
|
73
73
|
|
|
74
74
|
case 'click': {
|
|
75
75
|
const selector = args[0];
|
|
76
|
-
if (!selector) throw new Error('Usage: browse click <selector>');
|
|
76
|
+
if (!selector) throw new Error('Usage: browse click <selector|x,y>');
|
|
77
|
+
// Coordinate click: "590,461" or "590, 461"
|
|
78
|
+
const coordMatch = selector.match(/^(\d+)\s*,\s*(\d+)$/);
|
|
79
|
+
if (coordMatch) {
|
|
80
|
+
const x = parseInt(coordMatch[1], 10);
|
|
81
|
+
const y = parseInt(coordMatch[2], 10);
|
|
82
|
+
await page.mouse.click(x, y);
|
|
83
|
+
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
84
|
+
return `Clicked at (${x}, ${y}) → now at ${page.url()}`;
|
|
85
|
+
}
|
|
77
86
|
const resolved = bm.resolveRef(selector);
|
|
78
87
|
if ('locator' in resolved) {
|
|
79
88
|
await resolved.locator.click({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
package/src/config.ts
CHANGED
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser runtime provider registry
|
|
3
|
+
*
|
|
4
|
+
* Abstracts the browser engine so playwright, rebrowser, etc. are swappable at runtime.
|
|
5
|
+
* Each runtime is loaded lazily via dynamic import -- only the selected runtime is loaded.
|
|
6
|
+
*
|
|
7
|
+
* Two kinds of runtime:
|
|
8
|
+
* Library runtimes (playwright, rebrowser) -- return a BrowserType for launch()/connectOverCDP()
|
|
9
|
+
* Process runtimes (lightpanda) -- spawn a binary, wait for CDP, connect Playwright to it
|
|
10
|
+
* These set `browser` (pre-connected) and `close` (cleanup spawned process).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { homedir } from 'os';
|
|
14
|
+
import { existsSync } from 'fs';
|
|
15
|
+
import { execSync } from 'child_process';
|
|
16
|
+
import { join } from 'path';
|
|
17
|
+
import type { Browser, BrowserType } from 'playwright';
|
|
18
|
+
|
|
19
|
+
export interface BrowserRuntime {
|
|
20
|
+
name: string;
|
|
21
|
+
chromium: BrowserType;
|
|
22
|
+
/** Pre-connected browser (for process runtimes like lightpanda). */
|
|
23
|
+
browser?: Browser;
|
|
24
|
+
/** Cleanup function -- kills spawned process, if any. */
|
|
25
|
+
close?: () => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type RuntimeLoader = () => Promise<BrowserRuntime>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Find the lightpanda binary on this machine.
|
|
32
|
+
* Checks PATH via `which`, then well-known install locations.
|
|
33
|
+
* Returns the absolute path or null if not found.
|
|
34
|
+
*/
|
|
35
|
+
export function findLightpanda(): string | null {
|
|
36
|
+
// Check PATH
|
|
37
|
+
try {
|
|
38
|
+
const result = execSync('which lightpanda', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
39
|
+
if (result) return result;
|
|
40
|
+
} catch {
|
|
41
|
+
// not on PATH
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check well-known install locations
|
|
45
|
+
const home = homedir();
|
|
46
|
+
const candidates = [
|
|
47
|
+
join(home, '.lightpanda', 'lightpanda'),
|
|
48
|
+
join(home, '.local', 'bin', 'lightpanda'),
|
|
49
|
+
];
|
|
50
|
+
for (const candidate of candidates) {
|
|
51
|
+
if (existsSync(candidate)) return candidate;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const registry: Record<string, RuntimeLoader> = {
|
|
58
|
+
playwright: async () => {
|
|
59
|
+
const pw = await import('playwright');
|
|
60
|
+
return { name: 'playwright', chromium: pw.chromium };
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
rebrowser: async () => {
|
|
64
|
+
try {
|
|
65
|
+
const pw = await import('rebrowser-playwright');
|
|
66
|
+
return { name: 'rebrowser', chromium: pw.chromium };
|
|
67
|
+
} catch {
|
|
68
|
+
throw new Error(
|
|
69
|
+
'rebrowser-playwright not installed. Run: bun add rebrowser-playwright'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
lightpanda: async () => {
|
|
75
|
+
const binaryPath = findLightpanda();
|
|
76
|
+
if (!binaryPath) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
'Lightpanda not found. Install: https://lightpanda.io/docs/open-source/installation'
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Find a free port
|
|
83
|
+
const server = Bun.listen({
|
|
84
|
+
hostname: '127.0.0.1',
|
|
85
|
+
port: 0,
|
|
86
|
+
socket: {
|
|
87
|
+
data() {},
|
|
88
|
+
open() {},
|
|
89
|
+
close() {},
|
|
90
|
+
error() {},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
const port = server.port;
|
|
94
|
+
server.stop();
|
|
95
|
+
|
|
96
|
+
// Spawn lightpanda with 1-week session timeout (documented maximum)
|
|
97
|
+
const child = Bun.spawn(
|
|
98
|
+
[binaryPath, 'serve', '--host', '127.0.0.1', '--port', String(port), '--timeout', '604800'],
|
|
99
|
+
{ stdout: 'pipe', stderr: 'pipe' }
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Poll for CDP ready (10s timeout, 100ms interval)
|
|
103
|
+
const deadline = Date.now() + 10_000;
|
|
104
|
+
let wsUrl: string | undefined;
|
|
105
|
+
|
|
106
|
+
while (Date.now() < deadline) {
|
|
107
|
+
// Check if process exited early
|
|
108
|
+
if (child.exitCode !== null) {
|
|
109
|
+
const stderr = await new Response(child.stderr).text().catch(() => '');
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Lightpanda exited before CDP became ready (exit code: ${child.exitCode})` +
|
|
112
|
+
(stderr ? `\nstderr: ${stderr.slice(0, 2000)}` : '')
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const res = await fetch(`http://127.0.0.1:${port}/json/version`);
|
|
118
|
+
if (res.ok) {
|
|
119
|
+
const data = await res.json() as { webSocketDebuggerUrl?: string };
|
|
120
|
+
wsUrl = data.webSocketDebuggerUrl;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// Not ready yet
|
|
125
|
+
}
|
|
126
|
+
await new Promise(r => setTimeout(r, 100));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!wsUrl) {
|
|
130
|
+
child.kill();
|
|
131
|
+
throw new Error(
|
|
132
|
+
`Lightpanda failed to start on port ${port} within 10 seconds`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Bun's WebSocket client sends "Connection: keep-alive" instead of
|
|
137
|
+
// "Connection: Upgrade", breaking the WebSocket handshake with Lightpanda.
|
|
138
|
+
// Playwright's connectOverCDP relies on the bundled ws package which Bun
|
|
139
|
+
// intercepts. This is a known Bun issue (oven-sh/bun#9911).
|
|
140
|
+
// Until Bun fixes WebSocket upgrades, lightpanda cannot be used.
|
|
141
|
+
child.kill();
|
|
142
|
+
throw new Error(
|
|
143
|
+
'Lightpanda runtime is not yet supported under Bun.\n' +
|
|
144
|
+
'Bun\'s WebSocket client breaks the HTTP upgrade handshake required by connectOverCDP (oven-sh/bun#9911).\n' +
|
|
145
|
+
'This affects all CDP-based connections. Use --runtime playwright or --runtime rebrowser instead.'
|
|
146
|
+
);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const AVAILABLE_RUNTIMES: string[] = Object.keys(registry);
|
|
151
|
+
|
|
152
|
+
export async function getRuntime(name?: string): Promise<BrowserRuntime> {
|
|
153
|
+
const key = name ?? 'playwright';
|
|
154
|
+
const loader = registry[key];
|
|
155
|
+
if (!loader) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Unknown runtime: ${key}. Available: ${AVAILABLE_RUNTIMES.join(', ')}`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return loader();
|
|
161
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* Auto-shutdown when all sessions idle past BROWSE_IDLE_TIMEOUT (default 30 min)
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import type { Browser } from 'playwright';
|
|
13
|
+
import { getRuntime, type BrowserRuntime } from './runtime';
|
|
13
14
|
import { SessionManager, type Session } from './session-manager';
|
|
14
15
|
import { handleReadCommand } from './commands/read';
|
|
15
16
|
import { handleWriteCommand } from './commands/write';
|
|
@@ -99,6 +100,7 @@ function flushSessionBuffers(session: Session, final: boolean) {
|
|
|
99
100
|
// ─── Server ────────────────────────────────────────────────────
|
|
100
101
|
let sessionManager: SessionManager;
|
|
101
102
|
let browser: Browser;
|
|
103
|
+
let activeRuntime: BrowserRuntime | undefined;
|
|
102
104
|
let isShuttingDown = false;
|
|
103
105
|
let isRemoteBrowser = false;
|
|
104
106
|
const policyChecker = new PolicyChecker();
|
|
@@ -326,6 +328,9 @@ async function shutdown() {
|
|
|
326
328
|
await browser.close().catch(() => {});
|
|
327
329
|
}
|
|
328
330
|
|
|
331
|
+
// Clean up runtime resources (e.g. lightpanda child process)
|
|
332
|
+
await activeRuntime?.close?.().catch(() => {});
|
|
333
|
+
|
|
329
334
|
// Only remove state file if it still belongs to this server instance.
|
|
330
335
|
try {
|
|
331
336
|
const currentState = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
|
|
@@ -365,13 +370,27 @@ const sessionCleanupInterval = setInterval(async () => {
|
|
|
365
370
|
async function start() {
|
|
366
371
|
const port = await findPort();
|
|
367
372
|
|
|
373
|
+
// Resolve browser runtime (playwright, rebrowser, etc.)
|
|
374
|
+
const runtimeName = process.env.BROWSE_RUNTIME;
|
|
375
|
+
const runtime = await getRuntime(runtimeName);
|
|
376
|
+
activeRuntime = runtime;
|
|
377
|
+
console.log(`[browse] Runtime: ${runtime.name}`);
|
|
378
|
+
|
|
368
379
|
// Launch or connect to browser
|
|
369
380
|
const cdpUrl = process.env.BROWSE_CDP_URL;
|
|
370
381
|
if (cdpUrl) {
|
|
371
382
|
// Connect to remote Chrome via CDP
|
|
372
|
-
browser = await chromium.connectOverCDP(cdpUrl);
|
|
383
|
+
browser = await runtime.chromium.connectOverCDP(cdpUrl);
|
|
373
384
|
isRemoteBrowser = true;
|
|
374
385
|
console.log(`[browse] Connected to remote Chrome via CDP: ${cdpUrl}`);
|
|
386
|
+
} else if (runtime.browser) {
|
|
387
|
+
// Process runtime (e.g. lightpanda) -- browser already connected
|
|
388
|
+
browser = runtime.browser;
|
|
389
|
+
browser.on('disconnected', () => {
|
|
390
|
+
if (isShuttingDown) return;
|
|
391
|
+
console.error('[browse] Browser disconnected. Shutting down.');
|
|
392
|
+
shutdown();
|
|
393
|
+
});
|
|
375
394
|
} else {
|
|
376
395
|
// Launch local Chromium
|
|
377
396
|
const launchOptions: Record<string, any> = { headless: process.env.BROWSE_HEADED !== '1' };
|
|
@@ -385,7 +404,7 @@ async function start() {
|
|
|
385
404
|
launchOptions.proxy.bypass = process.env.BROWSE_PROXY_BYPASS;
|
|
386
405
|
}
|
|
387
406
|
}
|
|
388
|
-
browser = await chromium.launch(launchOptions);
|
|
407
|
+
browser = await runtime.chromium.launch(launchOptions);
|
|
389
408
|
|
|
390
409
|
// Chromium crash → clean shutdown (only for owned browser)
|
|
391
410
|
browser.on('disconnected', () => {
|