@ulpi/browse 0.2.2 → 0.2.5
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/README.md +36 -20
- package/package.json +1 -1
- package/skill/SKILL.md +5 -2
- package/src/browser-manager.ts +44 -24
- package/src/cli.ts +125 -40
- package/src/commands/meta.ts +187 -26
- package/src/commands/write.ts +29 -7
- package/src/domain-filter.ts +8 -2
- package/src/png-compare.ts +138 -0
- package/src/server.ts +3 -3
- package/src/session-manager.ts +11 -3
package/README.md
CHANGED
|
@@ -333,28 +333,44 @@ Inspired by and originally derived from the `/browse` skill in [gstack](https://
|
|
|
333
333
|
|
|
334
334
|
### Added beyond gstack
|
|
335
335
|
|
|
336
|
-
**
|
|
337
|
-
- `emulate` / `devices` — device emulation
|
|
338
|
-
- `snapshot -C` — cursor-interactive detection
|
|
336
|
+
**v0.1.0 — Foundation:**
|
|
337
|
+
- `emulate` / `devices` — device emulation (100+ devices)
|
|
338
|
+
- `snapshot -C` — cursor-interactive detection
|
|
339
339
|
- `snapshot-diff` — before/after comparison with ref-number stripping
|
|
340
|
-
- `dialog` / `dialog-accept` / `dialog-dismiss` — dialog handling
|
|
341
|
-
- `
|
|
342
|
-
- `upload` — file upload to input elements
|
|
343
|
-
- `sessions` / `session-close` — multi-agent session multiplexing
|
|
340
|
+
- `dialog` / `dialog-accept` / `dialog-dismiss` — dialog handling
|
|
341
|
+
- `upload` — file upload
|
|
344
342
|
- `screenshot --annotate` — numbered badge overlay with legend
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
-
|
|
351
|
-
-
|
|
352
|
-
-
|
|
353
|
-
-
|
|
354
|
-
-
|
|
355
|
-
-
|
|
356
|
-
-
|
|
357
|
-
-
|
|
343
|
+
- Session multiplexing — multiple agents share one Chromium
|
|
344
|
+
- Safe retry classification — read vs write commands
|
|
345
|
+
- TreeWalker text extraction — no MutationObserver triggers
|
|
346
|
+
|
|
347
|
+
**v0.2.0 — Security, Interactions, DX:**
|
|
348
|
+
- `--json` — structured output mode for agent frameworks
|
|
349
|
+
- `--content-boundaries` — CSPRNG nonce wrapping for prompt injection defense
|
|
350
|
+
- `--allowed-domains` — domain allowlist (HTTP + WebSocket/EventSource/sendBeacon)
|
|
351
|
+
- `browse-policy.json` — action policy gate (allow/deny/confirm per command)
|
|
352
|
+
- `auth save/login/list/delete` — AES-256-GCM encrypted credential vault
|
|
353
|
+
- `dblclick`, `focus`, `check`, `uncheck`, `drag`, `keydown`, `keyup` — interaction commands
|
|
354
|
+
- `frame <sel>` / `frame main` — iframe targeting
|
|
355
|
+
- `value <sel>`, `count <sel>` — element inspection
|
|
356
|
+
- `scroll up/down` — viewport-relative scrolling
|
|
357
|
+
- `wait --url`, `wait --network-idle` — navigation/network wait variants
|
|
358
|
+
- `highlight <sel>` — visual element debugging
|
|
359
|
+
- `download <sel> [path]` — file download
|
|
360
|
+
- `route <pattern> block/fulfill` — network request interception and mocking
|
|
361
|
+
- `offline on/off` — offline mode toggle
|
|
362
|
+
- `state save/load` — persist and restore cookies + localStorage (all origins)
|
|
363
|
+
- `har start/stop` — HAR recording and export
|
|
364
|
+
- `screenshot-diff` — pixel-level visual regression testing
|
|
365
|
+
- `find role/text/label/placeholder/testid` — semantic element locators
|
|
366
|
+
- Auto-instance servers via PPID — multi-Claude isolation
|
|
367
|
+
- Per-session output folders (`.browse/sessions/{id}/`)
|
|
368
|
+
- `browse.json` config file support
|
|
369
|
+
- AI-friendly error messages — Playwright errors rewritten to actionable hints
|
|
370
|
+
- CDP remote connection (`BROWSE_CDP_URL`)
|
|
371
|
+
- Proxy support (`BROWSE_PROXY`)
|
|
372
|
+
- Compiled binary self-spawn mode
|
|
373
|
+
- Orphaned server cleanup
|
|
358
374
|
|
|
359
375
|
## License
|
|
360
376
|
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -361,6 +361,7 @@ browse har stop [path] Stop and save HAR file
|
|
|
361
361
|
### Server management
|
|
362
362
|
```
|
|
363
363
|
browse status Server health, uptime, session count
|
|
364
|
+
browse instances List all running browse servers (instance, PID, port, status)
|
|
364
365
|
browse stop Shutdown server
|
|
365
366
|
browse restart Kill + restart server
|
|
366
367
|
```
|
|
@@ -425,12 +426,14 @@ browse restart Kill + restart server
|
|
|
425
426
|
|
|
426
427
|
- Persistent Chromium daemon on localhost (port 9400-10400)
|
|
427
428
|
- Bearer token auth per session
|
|
428
|
-
-
|
|
429
|
+
- One server per project directory — `--session` handles agent isolation
|
|
429
430
|
- Session multiplexing: multiple agents share one Chromium via isolated BrowserContexts
|
|
431
|
+
- For separate servers: set `BROWSE_INSTANCE` env var (e.g., fault isolation between teams)
|
|
432
|
+
- `browse instances` — discover all running servers (PID, port, status, session count)
|
|
430
433
|
- Project-local state: `.browse/` directory at project root (auto-created, self-gitignored)
|
|
431
434
|
- `sessions/{id}/` — per-session screenshots, logs, PDFs
|
|
432
435
|
- `states/{name}.json` — saved browser state (cookies + localStorage)
|
|
433
|
-
- `browse-server
|
|
436
|
+
- `browse-server.json` — server PID, port, auth token
|
|
434
437
|
- Auto-shutdown when all sessions idle past 30 min
|
|
435
438
|
- Chromium crash → server exits → auto-restarts on next command
|
|
436
439
|
- AI-friendly error messages: Playwright errors rewritten to actionable hints
|
package/src/browser-manager.ts
CHANGED
|
@@ -139,7 +139,7 @@ export class BrowserManager {
|
|
|
139
139
|
private currentDevice: DeviceDescriptor | null = null;
|
|
140
140
|
|
|
141
141
|
// ─── iframe targeting ─────────────────────────────────────
|
|
142
|
-
private
|
|
142
|
+
private activeFramePerTab: Map<number, string> = new Map();
|
|
143
143
|
|
|
144
144
|
// ─── Per-session buffers ──────────────────────────────────
|
|
145
145
|
private buffers: SessionBuffers;
|
|
@@ -357,6 +357,10 @@ export class BrowserManager {
|
|
|
357
357
|
return page;
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
+
getPageById(id: number): Page | undefined {
|
|
361
|
+
return this.pages.get(id);
|
|
362
|
+
}
|
|
363
|
+
|
|
360
364
|
getCurrentUrl(): string {
|
|
361
365
|
try {
|
|
362
366
|
return this.getPage().url();
|
|
@@ -372,21 +376,21 @@ export class BrowserManager {
|
|
|
372
376
|
* will target this frame's content instead of the main page.
|
|
373
377
|
*/
|
|
374
378
|
setFrame(selector: string) {
|
|
375
|
-
this.
|
|
379
|
+
this.activeFramePerTab.set(this.activeTabId, selector);
|
|
376
380
|
}
|
|
377
381
|
|
|
378
382
|
/**
|
|
379
|
-
* Reset to main frame — clears the active frame selector.
|
|
383
|
+
* Reset to main frame — clears the active frame selector for the current tab.
|
|
380
384
|
*/
|
|
381
385
|
resetFrame() {
|
|
382
|
-
this.
|
|
386
|
+
this.activeFramePerTab.delete(this.activeTabId);
|
|
383
387
|
}
|
|
384
388
|
|
|
385
389
|
/**
|
|
386
390
|
* Get the current active frame selector, or null if targeting main page.
|
|
387
391
|
*/
|
|
388
392
|
getActiveFrameSelector(): string | null {
|
|
389
|
-
return this.
|
|
393
|
+
return this.activeFramePerTab.get(this.activeTabId) ?? null;
|
|
390
394
|
}
|
|
391
395
|
|
|
392
396
|
/**
|
|
@@ -394,8 +398,9 @@ export class BrowserManager {
|
|
|
394
398
|
* Returns null if no frame is active (targeting main page).
|
|
395
399
|
*/
|
|
396
400
|
getFrameLocator(): FrameLocator | null {
|
|
397
|
-
|
|
398
|
-
|
|
401
|
+
const sel = this.getActiveFrameSelector();
|
|
402
|
+
if (!sel) return null;
|
|
403
|
+
return this.getPage().frameLocator(sel);
|
|
399
404
|
}
|
|
400
405
|
|
|
401
406
|
/**
|
|
@@ -404,13 +409,14 @@ export class BrowserManager {
|
|
|
404
409
|
* Unlike FrameLocator, Frame supports evaluate(), querySelector, etc.
|
|
405
410
|
*/
|
|
406
411
|
async getFrameContext(): Promise<Frame | null> {
|
|
407
|
-
|
|
412
|
+
const sel = this.getActiveFrameSelector();
|
|
413
|
+
if (!sel) return null;
|
|
408
414
|
const page = this.getPage();
|
|
409
|
-
const frameEl = page.locator(
|
|
415
|
+
const frameEl = page.locator(sel);
|
|
410
416
|
const handle = await frameEl.elementHandle({ timeout: 5000 });
|
|
411
|
-
if (!handle) throw new Error(`Frame element not found: ${
|
|
417
|
+
if (!handle) throw new Error(`Frame element not found: ${sel}`);
|
|
412
418
|
const frame = await handle.contentFrame();
|
|
413
|
-
if (!frame) throw new Error(`Cannot access content of frame: ${
|
|
419
|
+
if (!frame) throw new Error(`Cannot access content of frame: ${sel}`);
|
|
414
420
|
return frame;
|
|
415
421
|
}
|
|
416
422
|
|
|
@@ -420,8 +426,9 @@ export class BrowserManager {
|
|
|
420
426
|
* Example: bm.getLocatorRoot().locator('button.submit')
|
|
421
427
|
*/
|
|
422
428
|
getLocatorRoot(): Page | FrameLocator {
|
|
423
|
-
|
|
424
|
-
|
|
429
|
+
const sel = this.getActiveFrameSelector();
|
|
430
|
+
if (sel) {
|
|
431
|
+
return this.getPage().frameLocator(sel);
|
|
425
432
|
}
|
|
426
433
|
return this.getPage();
|
|
427
434
|
}
|
|
@@ -463,8 +470,9 @@ export class BrowserManager {
|
|
|
463
470
|
return { locator };
|
|
464
471
|
}
|
|
465
472
|
// When a frame is active, scope CSS selectors through the frame
|
|
466
|
-
|
|
467
|
-
|
|
473
|
+
const frameSel = this.getActiveFrameSelector();
|
|
474
|
+
if (frameSel) {
|
|
475
|
+
const frame = this.getPage().frameLocator(frameSel);
|
|
468
476
|
return { locator: frame.locator(selector) };
|
|
469
477
|
}
|
|
470
478
|
return { selector };
|
|
@@ -573,15 +581,7 @@ export class BrowserManager {
|
|
|
573
581
|
if (this.initScript) {
|
|
574
582
|
await newContext.addInitScript(this.initScript);
|
|
575
583
|
}
|
|
576
|
-
// Re-apply
|
|
577
|
-
if (this.domainFilter) {
|
|
578
|
-
const df = this.domainFilter;
|
|
579
|
-
await newContext.route('**/*', (route) => {
|
|
580
|
-
const url = route.request().url();
|
|
581
|
-
if (df.isAllowed(url)) { route.continue(); } else { route.abort('blockedbyclient'); }
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
// Re-apply user routes
|
|
584
|
+
// Re-apply user routes FIRST
|
|
585
585
|
for (const r of this.userRoutes) {
|
|
586
586
|
if (r.action === 'block') {
|
|
587
587
|
await newContext.route(r.pattern, (route) => route.abort('blockedbyclient'));
|
|
@@ -589,6 +589,14 @@ export class BrowserManager {
|
|
|
589
589
|
await newContext.route(r.pattern, (route) => route.fulfill({ status: r.status || 200, body: r.body || '', contentType: 'text/plain' }));
|
|
590
590
|
}
|
|
591
591
|
}
|
|
592
|
+
// Re-apply domain filter route LAST (Playwright: last registered = checked first)
|
|
593
|
+
if (this.domainFilter) {
|
|
594
|
+
const df = this.domainFilter;
|
|
595
|
+
await newContext.route('**/*', (route) => {
|
|
596
|
+
const url = route.request().url();
|
|
597
|
+
if (df.isAllowed(url)) { route.fallback(); } else { route.abort('blockedbyclient'); }
|
|
598
|
+
});
|
|
599
|
+
}
|
|
592
600
|
} catch (err) {
|
|
593
601
|
await newContext.close().catch(() => {});
|
|
594
602
|
throw err;
|
|
@@ -601,6 +609,7 @@ export class BrowserManager {
|
|
|
601
609
|
const oldNextTabId = this.nextTabId;
|
|
602
610
|
const oldTabSnapshots = new Map(this.tabSnapshots);
|
|
603
611
|
const oldRefMap = new Map(this.refMap);
|
|
612
|
+
const oldFramePerTab = new Map(this.activeFramePerTab);
|
|
604
613
|
|
|
605
614
|
// Swap to new context
|
|
606
615
|
this.context = newContext;
|
|
@@ -638,6 +647,7 @@ export class BrowserManager {
|
|
|
638
647
|
this.nextTabId = oldNextTabId;
|
|
639
648
|
this.tabSnapshots = oldTabSnapshots;
|
|
640
649
|
this.refMap = oldRefMap;
|
|
650
|
+
this.activeFramePerTab = oldFramePerTab;
|
|
641
651
|
throw err;
|
|
642
652
|
}
|
|
643
653
|
|
|
@@ -650,6 +660,16 @@ export class BrowserManager {
|
|
|
650
660
|
}
|
|
651
661
|
}
|
|
652
662
|
|
|
663
|
+
// Migrate activeFramePerTab: remap old tab IDs to new tab IDs
|
|
664
|
+
const oldFrames = new Map(this.activeFramePerTab);
|
|
665
|
+
this.activeFramePerTab.clear();
|
|
666
|
+
for (const [oldId, sel] of oldFrames) {
|
|
667
|
+
const newId = idMap.get(oldId);
|
|
668
|
+
if (newId !== undefined) {
|
|
669
|
+
this.activeFramePerTab.set(newId, sel);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
653
673
|
// Success — close old pages and context
|
|
654
674
|
for (const [, page] of oldPages) {
|
|
655
675
|
await page.close().catch(() => {});
|
package/src/cli.ts
CHANGED
|
@@ -22,11 +22,9 @@ const cliFlags = {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
const IS_COMPILED = import.meta.dir.includes('$bunfs');
|
|
29
|
-
const BROWSE_INSTANCE = process.env.BROWSE_INSTANCE || (BROWSE_PORT || IS_COMPILED ? '' : String(process.ppid));
|
|
25
|
+
// One server per project directory by default. Sessions handle agent isolation.
|
|
26
|
+
// For multiple servers on the same project: set BROWSE_INSTANCE or BROWSE_PORT.
|
|
27
|
+
const BROWSE_INSTANCE = process.env.BROWSE_INSTANCE || '';
|
|
30
28
|
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : (BROWSE_INSTANCE ? `-${BROWSE_INSTANCE}` : '');
|
|
31
29
|
|
|
32
30
|
/**
|
|
@@ -36,7 +34,10 @@ const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : (BROWSE_INSTANCE ? `-$
|
|
|
36
34
|
* Falls back to /tmp/ if not found (e.g. running outside a project).
|
|
37
35
|
*/
|
|
38
36
|
function resolveLocalDir(): string {
|
|
39
|
-
if (process.env.BROWSE_LOCAL_DIR)
|
|
37
|
+
if (process.env.BROWSE_LOCAL_DIR) {
|
|
38
|
+
try { fs.mkdirSync(process.env.BROWSE_LOCAL_DIR, { recursive: true }); } catch {}
|
|
39
|
+
return process.env.BROWSE_LOCAL_DIR;
|
|
40
|
+
}
|
|
40
41
|
|
|
41
42
|
let dir = process.cwd();
|
|
42
43
|
for (let i = 0; i < 20; i++) {
|
|
@@ -121,6 +122,60 @@ function isProcessAlive(pid: number): boolean {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
async function listInstances(): Promise<void> {
|
|
126
|
+
try {
|
|
127
|
+
const files = fs.readdirSync(LOCAL_DIR).filter(
|
|
128
|
+
f => f.startsWith('browse-server') && f.endsWith('.json') && !f.endsWith('.lock')
|
|
129
|
+
);
|
|
130
|
+
if (files.length === 0) { console.log('(no running instances)'); return; }
|
|
131
|
+
|
|
132
|
+
let found = false;
|
|
133
|
+
for (const file of files) {
|
|
134
|
+
try {
|
|
135
|
+
const data = JSON.parse(fs.readFileSync(path.join(LOCAL_DIR, file), 'utf-8'));
|
|
136
|
+
if (!data.pid || !data.port) continue;
|
|
137
|
+
|
|
138
|
+
const alive = isProcessAlive(data.pid);
|
|
139
|
+
let status = 'dead';
|
|
140
|
+
let sessions = 0;
|
|
141
|
+
if (alive) {
|
|
142
|
+
try {
|
|
143
|
+
const resp = await fetch(`http://127.0.0.1:${data.port}/health`, { signal: AbortSignal.timeout(1000) });
|
|
144
|
+
if (resp.ok) {
|
|
145
|
+
const health = await resp.json() as any;
|
|
146
|
+
status = health.status === 'healthy' ? 'healthy' : 'unhealthy';
|
|
147
|
+
sessions = health.sessions || 0;
|
|
148
|
+
}
|
|
149
|
+
} catch { status = 'unreachable'; }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Derive instance name from filename
|
|
153
|
+
const match = file.match(/^browse-server-?(.*)\.json$/);
|
|
154
|
+
const instance = match?.[1] || 'default';
|
|
155
|
+
|
|
156
|
+
console.log(` ${instance.padEnd(15)} PID ${String(data.pid).padEnd(8)} port ${data.port} ${status}${sessions ? ` ${sessions} session(s)` : ''}`);
|
|
157
|
+
found = true;
|
|
158
|
+
|
|
159
|
+
// Clean up dead entries
|
|
160
|
+
if (!alive) {
|
|
161
|
+
try { fs.unlinkSync(path.join(LOCAL_DIR, file)); } catch {}
|
|
162
|
+
}
|
|
163
|
+
} catch {}
|
|
164
|
+
}
|
|
165
|
+
if (!found) console.log('(no running instances)');
|
|
166
|
+
} catch { console.log('(no running instances)'); }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isBrowseProcess(pid: number): boolean {
|
|
170
|
+
try {
|
|
171
|
+
const { execSync } = require('child_process');
|
|
172
|
+
const cmd = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf-8' }).trim();
|
|
173
|
+
return cmd.includes('browse') || cmd.includes('__BROWSE_SERVER_MODE');
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
124
179
|
// ─── Server Lifecycle ──────────────────────────────────────────
|
|
125
180
|
|
|
126
181
|
/**
|
|
@@ -249,16 +304,18 @@ async function ensureServer(): Promise<ServerState> {
|
|
|
249
304
|
}
|
|
250
305
|
|
|
251
306
|
// Server is alive but unhealthy (shutting down, browser crashed).
|
|
252
|
-
// Kill it so we can start fresh.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
307
|
+
// Kill it so we can start fresh — but only if it's actually a browse process.
|
|
308
|
+
if (isBrowseProcess(state.pid)) {
|
|
309
|
+
try { process.kill(state.pid, 'SIGTERM'); } catch {}
|
|
310
|
+
// Brief wait for graceful exit
|
|
311
|
+
const deadline = Date.now() + 3000;
|
|
312
|
+
while (Date.now() < deadline && isProcessAlive(state.pid)) {
|
|
313
|
+
await Bun.sleep(100);
|
|
314
|
+
}
|
|
315
|
+
if (isProcessAlive(state.pid)) {
|
|
316
|
+
try { process.kill(state.pid, 'SIGKILL'); } catch {}
|
|
317
|
+
await Bun.sleep(200);
|
|
318
|
+
}
|
|
262
319
|
}
|
|
263
320
|
}
|
|
264
321
|
|
|
@@ -276,9 +333,10 @@ async function ensureServer(): Promise<ServerState> {
|
|
|
276
333
|
}
|
|
277
334
|
|
|
278
335
|
/**
|
|
279
|
-
* Clean up orphaned browse
|
|
280
|
-
*
|
|
281
|
-
*
|
|
336
|
+
* Clean up orphaned browse server state files.
|
|
337
|
+
* Removes any browse-server*.json whose PID is dead.
|
|
338
|
+
* Kills live orphans (legacy PPID-suffixed files from pre-v0.2.4) if they're browse processes.
|
|
339
|
+
* Preserves intentional BROWSE_PORT instances (suffix matches port inside the file).
|
|
282
340
|
*/
|
|
283
341
|
function cleanOrphanedServers(): void {
|
|
284
342
|
try {
|
|
@@ -286,17 +344,20 @@ function cleanOrphanedServers(): void {
|
|
|
286
344
|
for (const file of files) {
|
|
287
345
|
if (!file.startsWith('browse-server') || !file.endsWith('.json') || file.endsWith('.lock')) continue;
|
|
288
346
|
const filePath = path.join(LOCAL_DIR, file);
|
|
289
|
-
if (filePath === STATE_FILE) continue;
|
|
347
|
+
if (filePath === STATE_FILE) continue;
|
|
290
348
|
try {
|
|
291
349
|
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
292
|
-
if (data.pid) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
350
|
+
if (!data.pid) { fs.unlinkSync(filePath); continue; }
|
|
351
|
+
// Preserve intentional BROWSE_PORT instances (suffix = port number)
|
|
352
|
+
const suffixMatch = file.match(/browse-server-(\d+)\.json$/);
|
|
353
|
+
if (suffixMatch && data.port === parseInt(suffixMatch[1], 10) && isProcessAlive(data.pid)) continue;
|
|
354
|
+
// Dead process → remove state file
|
|
355
|
+
if (!isProcessAlive(data.pid)) { fs.unlinkSync(filePath); continue; }
|
|
356
|
+
// Live orphan (legacy PPID file) → kill if it's a browse process
|
|
357
|
+
if (isBrowseProcess(data.pid)) {
|
|
358
|
+
try { process.kill(data.pid, 'SIGTERM'); } catch {}
|
|
298
359
|
}
|
|
299
|
-
} catch {}
|
|
360
|
+
} catch { try { fs.unlinkSync(filePath); } catch {} }
|
|
300
361
|
}
|
|
301
362
|
} catch {}
|
|
302
363
|
}
|
|
@@ -314,7 +375,7 @@ export const SAFE_TO_RETRY = new Set([
|
|
|
314
375
|
'css', 'attrs', 'element-state', 'dialog',
|
|
315
376
|
'console', 'network', 'cookies', 'perf', 'value', 'count',
|
|
316
377
|
// Meta commands that are read-only or idempotent
|
|
317
|
-
'tabs', 'status', 'url', 'snapshot', 'snapshot-diff', 'devices', 'sessions', 'frame',
|
|
378
|
+
'tabs', 'status', 'url', 'snapshot', 'snapshot-diff', 'devices', 'sessions', 'frame', 'find',
|
|
318
379
|
]);
|
|
319
380
|
|
|
320
381
|
// Commands that return static data independent of page state.
|
|
@@ -443,41 +504,52 @@ export async function main() {
|
|
|
443
504
|
// Load project config (browse.json) — values serve as defaults
|
|
444
505
|
const config = loadConfig();
|
|
445
506
|
|
|
446
|
-
//
|
|
507
|
+
// Find the first non-flag arg (the command) to limit global flag scanning.
|
|
508
|
+
// Only extract global flags from args BEFORE the command position.
|
|
509
|
+
function findCommandIndex(a: string[]): number {
|
|
510
|
+
for (let i = 0; i < a.length; i++) {
|
|
511
|
+
if (!a[i].startsWith('-')) return i;
|
|
512
|
+
// Skip flag values for known value-flags
|
|
513
|
+
if (a[i] === '--session' || a[i] === '--allowed-domains') i++;
|
|
514
|
+
}
|
|
515
|
+
return a.length;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Extract --session flag (only before command)
|
|
447
519
|
let sessionId: string | undefined;
|
|
448
520
|
const sessionIdx = args.indexOf('--session');
|
|
449
|
-
if (sessionIdx !== -1) {
|
|
521
|
+
if (sessionIdx !== -1 && sessionIdx < findCommandIndex(args)) {
|
|
450
522
|
sessionId = args[sessionIdx + 1];
|
|
451
523
|
if (!sessionId || sessionId.startsWith('-')) {
|
|
452
524
|
console.error('Usage: browse --session <id> <command> [args...]');
|
|
453
525
|
process.exit(1);
|
|
454
526
|
}
|
|
455
|
-
args.splice(sessionIdx, 2);
|
|
527
|
+
args.splice(sessionIdx, 2);
|
|
456
528
|
}
|
|
457
529
|
sessionId = sessionId || process.env.BROWSE_SESSION || config.session || undefined;
|
|
458
530
|
|
|
459
|
-
// Extract --json flag
|
|
531
|
+
// Extract --json flag (only before command)
|
|
460
532
|
let jsonMode = false;
|
|
461
533
|
const jsonIdx = args.indexOf('--json');
|
|
462
|
-
if (jsonIdx !== -1) {
|
|
534
|
+
if (jsonIdx !== -1 && jsonIdx < findCommandIndex(args)) {
|
|
463
535
|
jsonMode = true;
|
|
464
536
|
args.splice(jsonIdx, 1);
|
|
465
537
|
}
|
|
466
538
|
jsonMode = jsonMode || process.env.BROWSE_JSON === '1' || config.json === true;
|
|
467
539
|
|
|
468
|
-
// Extract --content-boundaries flag
|
|
540
|
+
// Extract --content-boundaries flag (only before command)
|
|
469
541
|
let contentBoundaries = false;
|
|
470
542
|
const boundIdx = args.indexOf('--content-boundaries');
|
|
471
|
-
if (boundIdx !== -1) {
|
|
543
|
+
if (boundIdx !== -1 && boundIdx < findCommandIndex(args)) {
|
|
472
544
|
contentBoundaries = true;
|
|
473
545
|
args.splice(boundIdx, 1);
|
|
474
546
|
}
|
|
475
547
|
contentBoundaries = contentBoundaries || process.env.BROWSE_CONTENT_BOUNDARIES === '1' || config.contentBoundaries === true;
|
|
476
548
|
|
|
477
|
-
// Extract --allowed-domains flag
|
|
549
|
+
// Extract --allowed-domains flag (only before command)
|
|
478
550
|
let allowedDomains: string | undefined;
|
|
479
551
|
const domIdx = args.indexOf('--allowed-domains');
|
|
480
|
-
if (domIdx !== -1) {
|
|
552
|
+
if (domIdx !== -1 && domIdx < findCommandIndex(args)) {
|
|
481
553
|
allowedDomains = args[domIdx + 1];
|
|
482
554
|
if (!allowedDomains || allowedDomains.startsWith('-')) {
|
|
483
555
|
console.error('Usage: browse --allowed-domains domain1,domain2 <command> [args...]');
|
|
@@ -493,6 +565,11 @@ export async function main() {
|
|
|
493
565
|
cliFlags.allowedDomains = allowedDomains || '';
|
|
494
566
|
|
|
495
567
|
// ─── Local commands (no server needed) ─────────────────────
|
|
568
|
+
if (args[0] === 'instances') {
|
|
569
|
+
await listInstances();
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
496
573
|
if (args[0] === 'install-skill') {
|
|
497
574
|
const { installSkill } = await import('./install-skill');
|
|
498
575
|
installSkill(args[1]);
|
|
@@ -519,7 +596,8 @@ Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
|
|
|
519
596
|
value <sel> | count <sel>
|
|
520
597
|
Visual: screenshot [path] | pdf [path] | responsive [prefix]
|
|
521
598
|
Snapshot: snapshot [-i] [-c] [-C] [-d N] [-s sel]
|
|
522
|
-
|
|
599
|
+
Find: find role|text|label|placeholder|testid <query> [name]
|
|
600
|
+
Compare: diff <url1> <url2> | screenshot-diff <baseline> [current]
|
|
523
601
|
Multi-step: chain (reads JSON from stdin)
|
|
524
602
|
Network: offline [on|off] | route <pattern> block|fulfill
|
|
525
603
|
Recording: har start | har stop [path]
|
|
@@ -528,8 +606,8 @@ Frames: frame <sel> | frame main
|
|
|
528
606
|
Sessions: sessions | session-close <id>
|
|
529
607
|
Auth: auth save <name> <url> <user> <pass|--password-stdin>
|
|
530
608
|
auth login <name> | auth list | auth delete <name>
|
|
531
|
-
State: state save
|
|
532
|
-
Server: status | cookie <n>=<v> | header <n>:<v>
|
|
609
|
+
State: state save|load|list|show [name]
|
|
610
|
+
Server: status | instances | cookie <n>=<v> | header <n>:<v>
|
|
533
611
|
useragent <str> | stop | restart
|
|
534
612
|
Setup: install-skill [path]
|
|
535
613
|
|
|
@@ -561,6 +639,13 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|
|
561
639
|
commandArgs.push(stdin.trim());
|
|
562
640
|
}
|
|
563
641
|
|
|
642
|
+
// Special case: auth --password-stdin reads in CLI before sending to server
|
|
643
|
+
if (command === 'auth' && commandArgs.includes('--password-stdin')) {
|
|
644
|
+
const stdinIdx = commandArgs.indexOf('--password-stdin');
|
|
645
|
+
const password = (await Bun.stdin.text()).trim();
|
|
646
|
+
commandArgs.splice(stdinIdx, 1, password);
|
|
647
|
+
}
|
|
648
|
+
|
|
564
649
|
const state = await ensureServer();
|
|
565
650
|
await sendCommand(state, command, commandArgs, 0, sessionId);
|
|
566
651
|
}
|
package/src/commands/meta.ts
CHANGED
|
@@ -97,6 +97,33 @@ export async function handleMetaCommand(
|
|
|
97
97
|
if (!sessionManager) throw new Error('Session management not available');
|
|
98
98
|
const id = args[0];
|
|
99
99
|
if (!id) throw new Error('Usage: browse session-close <id>');
|
|
100
|
+
// Flush buffers before closing so logs aren't lost
|
|
101
|
+
const closingSession = sessionManager.getAllSessions().find(s => s.id === id);
|
|
102
|
+
if (closingSession) {
|
|
103
|
+
const buffers = closingSession.buffers;
|
|
104
|
+
const consolePath = `${closingSession.outputDir}/console.log`;
|
|
105
|
+
const networkPath = `${closingSession.outputDir}/network.log`;
|
|
106
|
+
const newConsoleCount = buffers.consoleTotalAdded - buffers.lastConsoleFlushed;
|
|
107
|
+
if (newConsoleCount > 0) {
|
|
108
|
+
const count = Math.min(newConsoleCount, buffers.consoleBuffer.length);
|
|
109
|
+
const entries = buffers.consoleBuffer.slice(-count);
|
|
110
|
+
const lines = entries.map(e =>
|
|
111
|
+
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
|
112
|
+
).join('\n') + '\n';
|
|
113
|
+
fs.appendFileSync(consolePath, lines);
|
|
114
|
+
buffers.lastConsoleFlushed = buffers.consoleTotalAdded;
|
|
115
|
+
}
|
|
116
|
+
const newNetworkCount = buffers.networkTotalAdded - buffers.lastNetworkFlushed;
|
|
117
|
+
if (newNetworkCount > 0) {
|
|
118
|
+
const count = Math.min(newNetworkCount, buffers.networkBuffer.length);
|
|
119
|
+
const entries = buffers.networkBuffer.slice(-count);
|
|
120
|
+
const lines = entries.map(e =>
|
|
121
|
+
`[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
|
|
122
|
+
).join('\n') + '\n';
|
|
123
|
+
fs.appendFileSync(networkPath, lines);
|
|
124
|
+
buffers.lastNetworkFlushed = buffers.networkTotalAdded;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
100
127
|
await sessionManager.closeSession(id);
|
|
101
128
|
return `Session "${id}" closed`;
|
|
102
129
|
}
|
|
@@ -104,13 +131,42 @@ export async function handleMetaCommand(
|
|
|
104
131
|
// ─── State Persistence ───────────────────────────────
|
|
105
132
|
case 'state': {
|
|
106
133
|
const subcommand = args[0];
|
|
107
|
-
if (!subcommand || !['save', 'load'].includes(subcommand)) {
|
|
108
|
-
throw new Error('Usage: browse state save
|
|
134
|
+
if (!subcommand || !['save', 'load', 'list', 'show'].includes(subcommand)) {
|
|
135
|
+
throw new Error('Usage: browse state save|load|list|show [name]');
|
|
109
136
|
}
|
|
110
137
|
const name = sanitizeName(args[1] || 'default');
|
|
111
138
|
const statesDir = `${LOCAL_DIR}/states`;
|
|
112
139
|
const statePath = `${statesDir}/${name}.json`;
|
|
113
140
|
|
|
141
|
+
if (subcommand === 'list') {
|
|
142
|
+
if (!fs.existsSync(statesDir)) return '(no saved states)';
|
|
143
|
+
const files = fs.readdirSync(statesDir).filter(f => f.endsWith('.json'));
|
|
144
|
+
if (files.length === 0) return '(no saved states)';
|
|
145
|
+
const lines: string[] = [];
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
const fp = `${statesDir}/${file}`;
|
|
148
|
+
const stat = fs.statSync(fp);
|
|
149
|
+
lines.push(` ${file.replace('.json', '')} ${stat.size}B ${new Date(stat.mtimeMs).toISOString()}`);
|
|
150
|
+
}
|
|
151
|
+
return lines.join('\n');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (subcommand === 'show') {
|
|
155
|
+
if (!fs.existsSync(statePath)) {
|
|
156
|
+
throw new Error(`State file not found: ${statePath}`);
|
|
157
|
+
}
|
|
158
|
+
const data = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
159
|
+
const cookieCount = data.cookies?.length || 0;
|
|
160
|
+
const originCount = data.origins?.length || 0;
|
|
161
|
+
const storageItems = (data.origins || []).reduce((sum: number, o: any) => sum + (o.localStorage?.length || 0), 0);
|
|
162
|
+
return [
|
|
163
|
+
`State: ${name}`,
|
|
164
|
+
`Cookies: ${cookieCount}`,
|
|
165
|
+
`Origins: ${originCount}`,
|
|
166
|
+
`Storage items: ${storageItems}`,
|
|
167
|
+
].join('\n');
|
|
168
|
+
}
|
|
169
|
+
|
|
114
170
|
if (subcommand === 'save') {
|
|
115
171
|
const context = bm.getContext();
|
|
116
172
|
if (!context) throw new Error('No browser context');
|
|
@@ -125,27 +181,37 @@ export async function handleMetaCommand(
|
|
|
125
181
|
throw new Error(`State file not found: ${statePath}. Run "browse state save ${name}" first.`);
|
|
126
182
|
}
|
|
127
183
|
const stateData = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
128
|
-
// Add cookies from saved state to current context
|
|
129
184
|
const context = bm.getContext();
|
|
130
185
|
if (!context) throw new Error('No browser context');
|
|
186
|
+
const warnings: string[] = [];
|
|
131
187
|
if (stateData.cookies?.length) {
|
|
132
|
-
|
|
188
|
+
try {
|
|
189
|
+
await context.addCookies(stateData.cookies);
|
|
190
|
+
} catch (err: any) {
|
|
191
|
+
warnings.push(`Cookies: ${err.message}`);
|
|
192
|
+
}
|
|
133
193
|
}
|
|
134
|
-
// Restore localStorage/sessionStorage for each origin
|
|
135
194
|
if (stateData.origins?.length) {
|
|
136
195
|
for (const origin of stateData.origins) {
|
|
137
196
|
if (origin.localStorage?.length) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
197
|
+
try {
|
|
198
|
+
const page = bm.getPage();
|
|
199
|
+
await page.goto(origin.origin, { waitUntil: 'domcontentloaded', timeout: 5000 });
|
|
200
|
+
for (const item of origin.localStorage) {
|
|
201
|
+
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [item.name, item.value]);
|
|
202
|
+
}
|
|
203
|
+
} catch (err: any) {
|
|
204
|
+
warnings.push(`Storage for ${origin.origin}: ${err.message}`);
|
|
142
205
|
}
|
|
143
206
|
}
|
|
144
207
|
}
|
|
145
208
|
}
|
|
209
|
+
if (warnings.length > 0) {
|
|
210
|
+
return `State loaded: ${statePath} (${warnings.length} warning(s))\n${warnings.join('\n')}`;
|
|
211
|
+
}
|
|
146
212
|
return `State loaded: ${statePath}`;
|
|
147
213
|
}
|
|
148
|
-
throw new Error('Usage: browse state save
|
|
214
|
+
throw new Error('Usage: browse state save|load|list|show [name]');
|
|
149
215
|
}
|
|
150
216
|
|
|
151
217
|
// ─── Visual ────────────────────────────────────────
|
|
@@ -300,7 +366,7 @@ export async function handleMetaCommand(
|
|
|
300
366
|
}
|
|
301
367
|
|
|
302
368
|
let result: string;
|
|
303
|
-
if (WRITE_SET.has(name)) result = await handleWriteCommand(name, cmdArgs, bm);
|
|
369
|
+
if (WRITE_SET.has(name)) result = await handleWriteCommand(name, cmdArgs, bm, currentSession?.domainFilter);
|
|
304
370
|
else if (READ_SET.has(name)) result = await handleReadCommand(name, cmdArgs, bm, sessionBuffers);
|
|
305
371
|
else result = await handleMetaCommand(name, cmdArgs, bm, shutdown, sessionManager, currentSession);
|
|
306
372
|
results.push(`[${name}] ${result}`);
|
|
@@ -413,6 +479,59 @@ export async function handleMetaCommand(
|
|
|
413
479
|
return output.join('\n');
|
|
414
480
|
}
|
|
415
481
|
|
|
482
|
+
// ─── Screenshot Diff ──────────────────────────────
|
|
483
|
+
case 'screenshot-diff': {
|
|
484
|
+
const baseline = args[0];
|
|
485
|
+
if (!baseline) throw new Error('Usage: browse screenshot-diff <baseline> [current] [--threshold 0.1]');
|
|
486
|
+
if (!fs.existsSync(baseline)) throw new Error(`Baseline file not found: ${baseline}`);
|
|
487
|
+
|
|
488
|
+
let thresholdPct = 0.1;
|
|
489
|
+
const threshIdx = args.indexOf('--threshold');
|
|
490
|
+
if (threshIdx !== -1 && args[threshIdx + 1]) {
|
|
491
|
+
thresholdPct = parseFloat(args[threshIdx + 1]);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const baselineBuffer = fs.readFileSync(baseline);
|
|
495
|
+
|
|
496
|
+
// Find optional current image path: any non-flag arg after baseline
|
|
497
|
+
let currentBuffer: Buffer;
|
|
498
|
+
let currentPath: string | undefined;
|
|
499
|
+
for (let i = 1; i < args.length; i++) {
|
|
500
|
+
if (args[i] === '--threshold') { i++; continue; }
|
|
501
|
+
if (!args[i].startsWith('--')) { currentPath = args[i]; break; }
|
|
502
|
+
}
|
|
503
|
+
if (currentPath) {
|
|
504
|
+
if (!fs.existsSync(currentPath)) throw new Error(`Current screenshot not found: ${currentPath}`);
|
|
505
|
+
currentBuffer = fs.readFileSync(currentPath);
|
|
506
|
+
} else {
|
|
507
|
+
const page = bm.getPage();
|
|
508
|
+
currentBuffer = await page.screenshot({ fullPage: true }) as Buffer;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const { compareScreenshots } = await import('../png-compare');
|
|
512
|
+
const result = compareScreenshots(baselineBuffer, currentBuffer, thresholdPct);
|
|
513
|
+
|
|
514
|
+
// Diff path: append -diff before extension, or add -diff.png if no extension
|
|
515
|
+
const extIdx = baseline.lastIndexOf('.');
|
|
516
|
+
const diffPath = extIdx > 0
|
|
517
|
+
? baseline.slice(0, extIdx) + '-diff' + baseline.slice(extIdx)
|
|
518
|
+
: baseline + '-diff.png';
|
|
519
|
+
if (!result.passed) {
|
|
520
|
+
// Write current screenshot as the "what changed" artifact
|
|
521
|
+
// (true pixel-diff image generation requires re-rendering differences)
|
|
522
|
+
fs.writeFileSync(diffPath, currentBuffer);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return [
|
|
526
|
+
`Pixels: ${result.totalPixels}`,
|
|
527
|
+
`Different: ${result.diffPixels}`,
|
|
528
|
+
`Mismatch: ${result.mismatchPct.toFixed(3)}%`,
|
|
529
|
+
`Threshold: ${thresholdPct}%`,
|
|
530
|
+
`Result: ${result.passed ? 'PASS' : 'FAIL'}`,
|
|
531
|
+
...(!result.passed ? [`Current saved: ${diffPath}`] : []),
|
|
532
|
+
].join('\n');
|
|
533
|
+
}
|
|
534
|
+
|
|
416
535
|
// ─── Auth Vault ─────────────────────────────────────
|
|
417
536
|
case 'auth': {
|
|
418
537
|
const subcommand = args[0];
|
|
@@ -422,15 +541,24 @@ export async function handleMetaCommand(
|
|
|
422
541
|
switch (subcommand) {
|
|
423
542
|
case 'save': {
|
|
424
543
|
const [, name, url, username] = args;
|
|
425
|
-
//
|
|
426
|
-
let
|
|
427
|
-
|
|
544
|
+
// Parse optional selector flags first (Task 9: scan flags before positional args)
|
|
545
|
+
let userSel: string | undefined;
|
|
546
|
+
let passSel: string | undefined;
|
|
547
|
+
let submitSel: string | undefined;
|
|
548
|
+
const positionalAfterUsername: string[] = [];
|
|
549
|
+
const knownFlags = new Set(['--user-sel', '--pass-sel', '--submit-sel']);
|
|
550
|
+
for (let i = 4; i < args.length; i++) {
|
|
551
|
+
if (args[i] === '--user-sel' && args[i+1]) { userSel = args[++i]; }
|
|
552
|
+
else if (args[i] === '--pass-sel' && args[i+1]) { passSel = args[++i]; }
|
|
553
|
+
else if (args[i] === '--submit-sel' && args[i+1]) { submitSel = args[++i]; }
|
|
554
|
+
else if (!knownFlags.has(args[i])) { positionalAfterUsername.push(args[i]); }
|
|
555
|
+
}
|
|
556
|
+
// Password: from positional arg (after username), or env var
|
|
557
|
+
// (--password-stdin is handled in CLI before reaching server)
|
|
558
|
+
let password: string | undefined = positionalAfterUsername[0];
|
|
428
559
|
if (!password && process.env.BROWSE_AUTH_PASSWORD) {
|
|
429
560
|
password = process.env.BROWSE_AUTH_PASSWORD;
|
|
430
561
|
}
|
|
431
|
-
if (!password && args.includes('--password-stdin')) {
|
|
432
|
-
password = (await Bun.stdin.text()).trim();
|
|
433
|
-
}
|
|
434
562
|
if (!name || !url || !username || !password) {
|
|
435
563
|
throw new Error(
|
|
436
564
|
'Usage: browse auth save <name> <url> <username> <password>\n' +
|
|
@@ -438,15 +566,6 @@ export async function handleMetaCommand(
|
|
|
438
566
|
' BROWSE_AUTH_PASSWORD=secret browse auth save <name> <url> <username>'
|
|
439
567
|
);
|
|
440
568
|
}
|
|
441
|
-
// Parse optional selector flags
|
|
442
|
-
let userSel: string | undefined;
|
|
443
|
-
let passSel: string | undefined;
|
|
444
|
-
let submitSel: string | undefined;
|
|
445
|
-
for (let i = 4; i < args.length; i++) {
|
|
446
|
-
if (args[i] === '--user-sel' && args[i+1]) { userSel = args[++i]; }
|
|
447
|
-
else if (args[i] === '--pass-sel' && args[i+1]) { passSel = args[++i]; }
|
|
448
|
-
else if (args[i] === '--submit-sel' && args[i+1]) { submitSel = args[++i]; }
|
|
449
|
-
}
|
|
450
569
|
const selectors = (userSel || passSel || submitSel) ? { username: userSel, password: passSel, submit: submitSel } : undefined;
|
|
451
570
|
vault.save(name, url, username, password, selectors);
|
|
452
571
|
return `Credentials saved: ${name}`;
|
|
@@ -502,6 +621,48 @@ export async function handleMetaCommand(
|
|
|
502
621
|
throw new Error('Usage: browse har start | browse har stop [path]');
|
|
503
622
|
}
|
|
504
623
|
|
|
624
|
+
// ─── Semantic Locator ──────────────────────────────
|
|
625
|
+
case 'find': {
|
|
626
|
+
const root = bm.getLocatorRoot();
|
|
627
|
+
const sub = args[0];
|
|
628
|
+
if (!sub) throw new Error('Usage: browse find role|text|label|placeholder|testid <query> [name]');
|
|
629
|
+
const query = args[1];
|
|
630
|
+
if (!query) throw new Error(`Usage: browse find ${sub} <query>`);
|
|
631
|
+
|
|
632
|
+
let locator;
|
|
633
|
+
switch (sub) {
|
|
634
|
+
case 'role': {
|
|
635
|
+
const nameOpt = args[2];
|
|
636
|
+
locator = nameOpt ? root.getByRole(query as any, { name: nameOpt }) : root.getByRole(query as any);
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
case 'text':
|
|
640
|
+
locator = root.getByText(query);
|
|
641
|
+
break;
|
|
642
|
+
case 'label':
|
|
643
|
+
locator = root.getByLabel(query);
|
|
644
|
+
break;
|
|
645
|
+
case 'placeholder':
|
|
646
|
+
locator = root.getByPlaceholder(query);
|
|
647
|
+
break;
|
|
648
|
+
case 'testid':
|
|
649
|
+
locator = root.getByTestId(query);
|
|
650
|
+
break;
|
|
651
|
+
default:
|
|
652
|
+
throw new Error(`Unknown find type: ${sub}. Use role|text|label|placeholder|testid`);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const count = await locator.count();
|
|
656
|
+
let firstText = '';
|
|
657
|
+
if (count > 0) {
|
|
658
|
+
try {
|
|
659
|
+
firstText = (await locator.first().textContent({ timeout: 2000 })) || '';
|
|
660
|
+
firstText = firstText.trim().slice(0, 100);
|
|
661
|
+
} catch {}
|
|
662
|
+
}
|
|
663
|
+
return `Found ${count} match(es)${firstText ? `: "${firstText}"` : ''}`;
|
|
664
|
+
}
|
|
665
|
+
|
|
505
666
|
// ─── iframe Targeting ─────────────────────────────
|
|
506
667
|
case 'frame': {
|
|
507
668
|
if (args[0] === 'main' || args[0] === 'top') {
|
package/src/commands/write.ts
CHANGED
|
@@ -6,12 +6,36 @@
|
|
|
6
6
|
* header, useragent, drag, keydown, keyup
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { BrowserContext } from 'playwright';
|
|
9
10
|
import type { BrowserManager } from '../browser-manager';
|
|
10
11
|
import { resolveDevice, listDevices } from '../browser-manager';
|
|
11
12
|
import type { DomainFilter } from '../domain-filter';
|
|
12
13
|
import { DEFAULTS } from '../constants';
|
|
13
14
|
import * as fs from 'fs';
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Clear all routes and re-register them in correct order:
|
|
18
|
+
* user routes first, domain filter last (Playwright checks last-registered first).
|
|
19
|
+
*/
|
|
20
|
+
async function rebuildRoutes(context: BrowserContext, bm: BrowserManager, domainFilter?: DomainFilter | null): Promise<void> {
|
|
21
|
+
await context.unrouteAll();
|
|
22
|
+
// User routes first (checked last by Playwright)
|
|
23
|
+
for (const r of bm.getUserRoutes()) {
|
|
24
|
+
if (r.action === 'block') {
|
|
25
|
+
await context.route(r.pattern, (route) => route.abort('blockedbyclient'));
|
|
26
|
+
} else {
|
|
27
|
+
await context.route(r.pattern, (route) => route.fulfill({ status: r.status || 200, body: r.body || '', contentType: 'text/plain' }));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Domain filter last (checked first by Playwright)
|
|
31
|
+
if (domainFilter) {
|
|
32
|
+
await context.route('**/*', (route) => {
|
|
33
|
+
const url = route.request().url();
|
|
34
|
+
if (domainFilter.isAllowed(url)) { route.fallback(); } else { route.abort('blockedbyclient'); }
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
15
39
|
export async function handleWriteCommand(
|
|
16
40
|
command: string,
|
|
17
41
|
args: string[],
|
|
@@ -64,7 +88,7 @@ export async function handleWriteCommand(
|
|
|
64
88
|
case 'fill': {
|
|
65
89
|
const [selector, ...valueParts] = args;
|
|
66
90
|
const value = valueParts.join(' ');
|
|
67
|
-
if (!selector
|
|
91
|
+
if (!selector) throw new Error('Usage: browse fill <selector> <value>');
|
|
68
92
|
const resolved = bm.resolveRef(selector);
|
|
69
93
|
if ('locator' in resolved) {
|
|
70
94
|
await resolved.locator.fill(value, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
@@ -77,7 +101,7 @@ export async function handleWriteCommand(
|
|
|
77
101
|
case 'select': {
|
|
78
102
|
const [selector, ...valueParts] = args;
|
|
79
103
|
const value = valueParts.join(' ');
|
|
80
|
-
if (!selector
|
|
104
|
+
if (!selector) throw new Error('Usage: browse select <selector> <value>');
|
|
81
105
|
const resolved = bm.resolveRef(selector);
|
|
82
106
|
if ('locator' in resolved) {
|
|
83
107
|
await resolved.locator.selectOption(value, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
@@ -420,7 +444,7 @@ export async function handleWriteCommand(
|
|
|
420
444
|
if (domainFilter) {
|
|
421
445
|
await context.route('**/*', (route) => {
|
|
422
446
|
const url = route.request().url();
|
|
423
|
-
if (domainFilter!.isAllowed(url)) { route.
|
|
447
|
+
if (domainFilter!.isAllowed(url)) { route.fallback(); } else { route.abort('blockedbyclient'); }
|
|
424
448
|
});
|
|
425
449
|
}
|
|
426
450
|
return domainFilter ? 'All routes cleared (domain filter preserved)' : 'All routes cleared';
|
|
@@ -429,18 +453,16 @@ export async function handleWriteCommand(
|
|
|
429
453
|
const action = args[1] || 'block';
|
|
430
454
|
|
|
431
455
|
if (action === 'block') {
|
|
432
|
-
await context.route(pattern, (route) => route.abort('blockedbyclient'));
|
|
433
456
|
bm.addUserRoute(pattern, 'block');
|
|
457
|
+
await rebuildRoutes(context, bm, domainFilter);
|
|
434
458
|
return `Blocking requests matching: ${pattern}`;
|
|
435
459
|
}
|
|
436
460
|
|
|
437
461
|
if (action === 'fulfill') {
|
|
438
462
|
const status = parseInt(args[2] || '200', 10);
|
|
439
463
|
const body = args[3] || '';
|
|
440
|
-
await context.route(pattern, (route) =>
|
|
441
|
-
route.fulfill({ status, body, contentType: 'text/plain' })
|
|
442
|
-
);
|
|
443
464
|
bm.addUserRoute(pattern, 'fulfill', status, body);
|
|
465
|
+
await rebuildRoutes(context, bm, domainFilter);
|
|
444
466
|
return `Mocking requests matching: ${pattern} → ${status}${body ? ` "${body}"` : ''}`;
|
|
445
467
|
}
|
|
446
468
|
|
package/src/domain-filter.ts
CHANGED
|
@@ -20,7 +20,11 @@ export class DomainFilter {
|
|
|
20
20
|
* Non-HTTP URLs (about:blank, data:, etc.) are always allowed.
|
|
21
21
|
*/
|
|
22
22
|
isAllowed(url: string): boolean {
|
|
23
|
-
//
|
|
23
|
+
// Block file:// and javascript: URLs — security risk
|
|
24
|
+
if (url.startsWith('file://') || url.startsWith('javascript:')) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
// Non-HTTP(S) URLs (about:blank, data:, blob:) are always allowed
|
|
24
28
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
25
29
|
return true;
|
|
26
30
|
}
|
|
@@ -79,7 +83,9 @@ export class DomainFilter {
|
|
|
79
83
|
// Normalize ws/wss to http/https for URL parsing
|
|
80
84
|
if (str.startsWith('ws://')) str = 'http://' + str.slice(5);
|
|
81
85
|
else if (str.startsWith('wss://')) str = 'https://' + str.slice(6);
|
|
82
|
-
//
|
|
86
|
+
// Block file:// and javascript: URLs
|
|
87
|
+
if (str.startsWith('file://') || str.startsWith('javascript:')) return false;
|
|
88
|
+
// Non-HTTP(S) always allowed (data:, blob:, about:)
|
|
83
89
|
if (!str.startsWith('http://') && !str.startsWith('https://')) return true;
|
|
84
90
|
var hostname;
|
|
85
91
|
try { hostname = new URL(str).hostname.toLowerCase(); } catch(e) { return false; }
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-contained PNG decoder + pixel comparator.
|
|
3
|
+
* No external deps — uses only zlib.inflateSync (Node/Bun built-in).
|
|
4
|
+
* Works in both dev mode (bun run) and compiled binary ($bunfs).
|
|
5
|
+
*
|
|
6
|
+
* Supports: 8-bit RGB (color type 2) and RGBA (color type 6).
|
|
7
|
+
* Handles all 5 PNG scanline filter types (None/Sub/Up/Average/Paeth).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as zlib from 'zlib';
|
|
11
|
+
|
|
12
|
+
const PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
13
|
+
|
|
14
|
+
export interface DecodedImage {
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
data: Buffer; // RGBA pixels
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CompareResult {
|
|
21
|
+
totalPixels: number;
|
|
22
|
+
diffPixels: number;
|
|
23
|
+
mismatchPct: number;
|
|
24
|
+
passed: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function decodePNG(buf: Buffer): DecodedImage {
|
|
28
|
+
for (let i = 0; i < 8; i++) {
|
|
29
|
+
if (buf[i] !== PNG_MAGIC[i]) throw new Error('Not a valid PNG file');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const width = buf.readUInt32BE(16);
|
|
33
|
+
const height = buf.readUInt32BE(20);
|
|
34
|
+
const bitDepth = buf[24];
|
|
35
|
+
const colorType = buf[25];
|
|
36
|
+
const interlace = buf[28];
|
|
37
|
+
|
|
38
|
+
if (bitDepth !== 8) throw new Error(`Unsupported PNG bit depth: ${bitDepth} (only 8-bit supported)`);
|
|
39
|
+
if (colorType !== 2 && colorType !== 6) throw new Error(`Unsupported PNG color type: ${colorType} (only RGB=2 and RGBA=6 supported)`);
|
|
40
|
+
if (interlace !== 0) throw new Error('Interlaced PNGs are not supported');
|
|
41
|
+
|
|
42
|
+
const channels = colorType === 6 ? 4 : 3;
|
|
43
|
+
const stride = width * channels;
|
|
44
|
+
|
|
45
|
+
// Collect IDAT chunks
|
|
46
|
+
const idats: Buffer[] = [];
|
|
47
|
+
let off = 8;
|
|
48
|
+
while (off < buf.length) {
|
|
49
|
+
const len = buf.readUInt32BE(off);
|
|
50
|
+
const type = buf.toString('ascii', off + 4, off + 8);
|
|
51
|
+
if (type === 'IDAT') idats.push(buf.slice(off + 8, off + 8 + len));
|
|
52
|
+
if (type === 'IEND') break;
|
|
53
|
+
off += 12 + len;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const raw = zlib.inflateSync(Buffer.concat(idats));
|
|
57
|
+
const pixels = Buffer.alloc(width * height * 4);
|
|
58
|
+
const prev = Buffer.alloc(stride);
|
|
59
|
+
|
|
60
|
+
for (let y = 0; y < height; y++) {
|
|
61
|
+
const filterType = raw[y * (stride + 1)];
|
|
62
|
+
const scanline = Buffer.from(raw.slice(y * (stride + 1) + 1, (y + 1) * (stride + 1)));
|
|
63
|
+
|
|
64
|
+
for (let x = 0; x < stride; x++) {
|
|
65
|
+
const a = x >= channels ? scanline[x - channels] : 0;
|
|
66
|
+
const b = prev[x];
|
|
67
|
+
const c = x >= channels ? prev[x - channels] : 0;
|
|
68
|
+
|
|
69
|
+
switch (filterType) {
|
|
70
|
+
case 0: break;
|
|
71
|
+
case 1: scanline[x] = (scanline[x] + a) & 0xff; break;
|
|
72
|
+
case 2: scanline[x] = (scanline[x] + b) & 0xff; break;
|
|
73
|
+
case 3: scanline[x] = (scanline[x] + ((a + b) >> 1)) & 0xff; break;
|
|
74
|
+
case 4: {
|
|
75
|
+
const p = a + b - c;
|
|
76
|
+
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
77
|
+
scanline[x] = (scanline[x] + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 0xff;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
default: throw new Error(`Unknown PNG filter type: ${filterType}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (let x = 0; x < width; x++) {
|
|
85
|
+
const si = x * channels;
|
|
86
|
+
const di = (y * width + x) * 4;
|
|
87
|
+
pixels[di] = scanline[si];
|
|
88
|
+
pixels[di + 1] = scanline[si + 1];
|
|
89
|
+
pixels[di + 2] = scanline[si + 2];
|
|
90
|
+
pixels[di + 3] = channels === 4 ? scanline[si + 3] : 255;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
scanline.copy(prev);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { width, height, data: pixels };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function compareScreenshots(
|
|
100
|
+
baselineBuf: Buffer,
|
|
101
|
+
currentBuf: Buffer,
|
|
102
|
+
thresholdPct: number = 0.1,
|
|
103
|
+
colorThreshold: number = 30,
|
|
104
|
+
): CompareResult {
|
|
105
|
+
const base = decodePNG(baselineBuf);
|
|
106
|
+
const curr = decodePNG(currentBuf);
|
|
107
|
+
|
|
108
|
+
const w = Math.max(base.width, curr.width);
|
|
109
|
+
const h = Math.max(base.height, curr.height);
|
|
110
|
+
const totalPixels = w * h;
|
|
111
|
+
let diffPixels = 0;
|
|
112
|
+
// Squared color distance threshold. 0 = exact match (any difference counts).
|
|
113
|
+
const colorThreshSq = colorThreshold * colorThreshold * 3; // across R,G,B channels
|
|
114
|
+
|
|
115
|
+
for (let y = 0; y < h; y++) {
|
|
116
|
+
for (let x = 0; x < w; x++) {
|
|
117
|
+
const inBase = x < base.width && y < base.height;
|
|
118
|
+
const inCurr = x < curr.width && y < curr.height;
|
|
119
|
+
if (!inBase || !inCurr) { diffPixels++; continue; }
|
|
120
|
+
|
|
121
|
+
const bi = (y * base.width + x) * 4;
|
|
122
|
+
const ci = (y * curr.width + x) * 4;
|
|
123
|
+
const dr = base.data[bi] - curr.data[ci];
|
|
124
|
+
const dg = base.data[bi + 1] - curr.data[ci + 1];
|
|
125
|
+
const db = base.data[bi + 2] - curr.data[ci + 2];
|
|
126
|
+
const distSq = dr * dr + dg * dg + db * db;
|
|
127
|
+
if (colorThreshold === 0 ? distSq > 0 : distSq > colorThreshSq) diffPixels++;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const mismatchPct = totalPixels > 0 ? (diffPixels / totalPixels) * 100 : 0;
|
|
132
|
+
return {
|
|
133
|
+
totalPixels,
|
|
134
|
+
diffPixels,
|
|
135
|
+
mismatchPct,
|
|
136
|
+
passed: mismatchPct <= thresholdPct,
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -125,9 +125,9 @@ const META_COMMANDS = new Set([
|
|
|
125
125
|
'status', 'stop', 'restart',
|
|
126
126
|
'screenshot', 'pdf', 'responsive',
|
|
127
127
|
'chain', 'diff',
|
|
128
|
-
'url', 'snapshot', 'snapshot-diff',
|
|
128
|
+
'url', 'snapshot', 'snapshot-diff', 'screenshot-diff',
|
|
129
129
|
'sessions', 'session-close',
|
|
130
|
-
'frame', 'state',
|
|
130
|
+
'frame', 'state', 'find',
|
|
131
131
|
'auth', 'har',
|
|
132
132
|
]);
|
|
133
133
|
|
|
@@ -349,7 +349,7 @@ const flushInterval = setInterval(() => {
|
|
|
349
349
|
const sessionCleanupInterval = setInterval(async () => {
|
|
350
350
|
if (!sessionManager || isShuttingDown) return;
|
|
351
351
|
|
|
352
|
-
const closed = await sessionManager.closeIdleSessions(IDLE_TIMEOUT_MS);
|
|
352
|
+
const closed = await sessionManager.closeIdleSessions(IDLE_TIMEOUT_MS, (session) => flushSessionBuffers(session, true));
|
|
353
353
|
for (const id of closed) {
|
|
354
354
|
console.log(`[browse] Session "${id}" idle for ${IDLE_TIMEOUT_MS / 1000}s — closed`);
|
|
355
355
|
}
|
package/src/session-manager.ts
CHANGED
|
@@ -53,7 +53,7 @@ export class SessionManager {
|
|
|
53
53
|
await context.route('**/*', (route) => {
|
|
54
54
|
const url = route.request().url();
|
|
55
55
|
if (domainFilter.isAllowed(url)) {
|
|
56
|
-
route.
|
|
56
|
+
route.fallback();
|
|
57
57
|
} else {
|
|
58
58
|
route.abort('blockedbyclient');
|
|
59
59
|
}
|
|
@@ -61,6 +61,13 @@ export class SessionManager {
|
|
|
61
61
|
const initScript = domainFilter.generateInitScript();
|
|
62
62
|
await context.addInitScript(initScript);
|
|
63
63
|
session.manager.setInitScript(initScript);
|
|
64
|
+
// Inject filter script into ALL open tabs immediately
|
|
65
|
+
for (const tab of session.manager.getTabList()) {
|
|
66
|
+
try {
|
|
67
|
+
const page = session.manager.getPageById(tab.id);
|
|
68
|
+
if (page) await page.evaluate(initScript);
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
64
71
|
}
|
|
65
72
|
session.domainFilter = domainFilter;
|
|
66
73
|
}
|
|
@@ -89,7 +96,7 @@ export class SessionManager {
|
|
|
89
96
|
await context.route('**/*', (route) => {
|
|
90
97
|
const url = route.request().url();
|
|
91
98
|
if (domainFilter!.isAllowed(url)) {
|
|
92
|
-
route.
|
|
99
|
+
route.fallback();
|
|
93
100
|
} else {
|
|
94
101
|
route.abort('blockedbyclient');
|
|
95
102
|
}
|
|
@@ -132,12 +139,13 @@ export class SessionManager {
|
|
|
132
139
|
* Close sessions idle longer than maxIdleMs.
|
|
133
140
|
* Returns list of closed session IDs.
|
|
134
141
|
*/
|
|
135
|
-
async closeIdleSessions(maxIdleMs: number): Promise<string[]> {
|
|
142
|
+
async closeIdleSessions(maxIdleMs: number, flushFn?: (session: Session) => void): Promise<string[]> {
|
|
136
143
|
const now = Date.now();
|
|
137
144
|
const closed: string[] = [];
|
|
138
145
|
|
|
139
146
|
for (const [id, session] of this.sessions) {
|
|
140
147
|
if (now - session.lastActivity > maxIdleMs) {
|
|
148
|
+
if (flushFn) flushFn(session);
|
|
141
149
|
await session.manager.close().catch(() => {});
|
|
142
150
|
this.sessions.delete(id);
|
|
143
151
|
closed.push(id);
|