@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 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
- **New commands:**
337
- - `emulate` / `devices` — device emulation with 100+ devices (iPhone, Pixel, iPad, custom descriptors)
338
- - `snapshot -C` — cursor-interactive detection (cursor:pointer, onclick, tabindex, data-action)
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 with prompt value support
341
- - `state` — element state inspection (visible, enabled, checked, focused, bounding box)
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
- **Architectural improvements:**
347
- - Session multiplexingmultiple agents share one Chromium via isolated BrowserContexts
348
- - Per-tab ref scoping — refs belong to the tab that created them, cross-tab usage throws clear error
349
- - Per-tab snapshot baselines `snapshot-diff` compares the correct baseline after tab switches
350
- - Safe retry classification read commands auto-retry after crash, write commands don't (prevents double form submissions)
351
- - Concurrency-safe server spawning file lock with stale detection prevents race conditions
352
- - Network correlation via WeakMap accurate request/response pairing even with duplicate URLs
353
- - Content-Length based sizing avoids reading response bodies into memory
354
- - TreeWalker text extraction `text` command never triggers MutationObservers
355
- - Tab creation rollback failed `newTab(url)` closes the page instead of leaving orphan tabs
356
- - Context recreation with rollback `emulate`/`useragent` preserve cookies and all tab URLs, rollback on failure
357
- - Crash callback server flushes buffers and cleans state file before exit
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.0Security, 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulpi/browse",
3
- "version": "0.2.2",
3
+ "version": "0.2.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/ulpi-io/browse"
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
- - Auto-instance: each parent process (Claude Code) gets its own server
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-{instance}.json` — server PID, port, auth token
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
@@ -139,7 +139,7 @@ export class BrowserManager {
139
139
  private currentDevice: DeviceDescriptor | null = null;
140
140
 
141
141
  // ─── iframe targeting ─────────────────────────────────────
142
- private activeFrameSelector: string | null = null;
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.activeFrameSelector = selector;
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.activeFrameSelector = null;
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.activeFrameSelector;
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
- if (!this.activeFrameSelector) return null;
398
- return this.getPage().frameLocator(this.activeFrameSelector);
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
- if (!this.activeFrameSelector) return null;
412
+ const sel = this.getActiveFrameSelector();
413
+ if (!sel) return null;
408
414
  const page = this.getPage();
409
- const frameEl = page.locator(this.activeFrameSelector);
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: ${this.activeFrameSelector}`);
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: ${this.activeFrameSelector}`);
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
- if (this.activeFrameSelector) {
424
- return this.getPage().frameLocator(this.activeFrameSelector);
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
- if (this.activeFrameSelector) {
467
- const frame = this.getPage().frameLocator(this.activeFrameSelector);
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 domain filter route
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
- // Instance isolation: each parent process (e.g., Claude Code) gets its own server.
26
- // BROWSE_PORT takes precedence (explicit), then BROWSE_INSTANCE (env override), then PPID (auto).
27
- // In compiled mode ($bunfs), PPID is unstable (shell forks per invocation) — skip it.
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) return 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
- try { process.kill(state.pid, 'SIGTERM'); } catch {}
254
- // Brief wait for graceful exit
255
- const deadline = Date.now() + 3000;
256
- while (Date.now() < deadline && isProcessAlive(state.pid)) {
257
- await Bun.sleep(100);
258
- }
259
- if (isProcessAlive(state.pid)) {
260
- try { process.kill(state.pid, 'SIGKILL'); } catch {}
261
- await Bun.sleep(200);
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 servers:
280
- * 1. Remove state files with dead PIDs
281
- * 2. Kill live servers from other instances (old PPID-suffixed state files)
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; // Don't touch our own state file
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
- if (isProcessAlive(data.pid)) {
294
- // Live orphan from a different instance — kill it to free the port
295
- try { process.kill(data.pid, 'SIGTERM'); } catch {}
296
- }
297
- fs.unlinkSync(filePath);
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
- // Extract --session flag before command parsing
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); // remove --session and its value
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
- Compare: diff <url1> <url2>
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 [name] | state load [name]
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
  }
@@ -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 [name] | browse state load [name]');
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
- await context.addCookies(stateData.cookies);
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
- const page = bm.getPage();
139
- await page.goto(origin.origin, { waitUntil: 'domcontentloaded', timeout: 5000 }).catch(() => {});
140
- for (const item of origin.localStorage) {
141
- await page.evaluate(([k, v]) => localStorage.setItem(k, v), [item.name, item.value]).catch(() => {});
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 [name] | browse state load [name]');
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
- // Password: from arg, env var, or --password-stdin flag
426
- let password: string | undefined = args[4];
427
- if (password === '--password-stdin') password = undefined;
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') {
@@ -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 || !value) throw new Error('Usage: browse fill <selector> <value>');
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 || !value) throw new Error('Usage: browse select <selector> <value>');
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.continue(); } else { route.abort('blockedbyclient'); }
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
 
@@ -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
- // Non-HTTP(S) URLs are always allowed
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
- // Non-HTTP(S) always allowed (data:, blob:, etc.)
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
  }
@@ -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.continue();
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.continue();
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);