@ulpi/browse 0.2.1 → 0.2.4

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
@@ -92,7 +92,7 @@ $ browse snapshot -i -C
92
92
 
93
93
  Every detected element gets a ref. `browse click @e3` just works.
94
94
 
95
- ### 4. 40+ Purpose-Built Commands vs Generic Tools
95
+ ### 4. 58+ Purpose-Built Commands vs Generic Tools
96
96
 
97
97
  @playwright/mcp has ~15 tools. For anything beyond navigate/click/type, you write JavaScript via `browser_evaluate`. `browse` has purpose-built commands that return structured, minimal output:
98
98
 
@@ -108,6 +108,17 @@ Every detected element gets a ref. `browse click @e3` just works.
108
108
  | Snapshot diff | Not available | `snapshot-diff` |
109
109
  | Responsive screenshots | Not available | `responsive` |
110
110
  | Device emulation | Not available | `emulate iphone` |
111
+ | Input value | `browser_evaluate` + custom JS | `value <sel>` |
112
+ | Element count | `browser_evaluate` + custom JS | `count <sel>` |
113
+ | iframe targeting | Not available | `frame <sel>` / `frame main` |
114
+ | Network mocking | Not available | `route <pattern> block\|fulfill` |
115
+ | Offline mode | Not available | `offline on\|off` |
116
+ | State persistence | Not available | `state save\|load` |
117
+ | Credential vault | Not available | `auth save\|login\|list` |
118
+ | HAR recording | Not available | `har start\|stop` |
119
+ | Domain restriction | Not available | `--allowed-domains` |
120
+ | Prompt injection defense | Not available | `--content-boundaries` |
121
+ | JSON output mode | Not available | `--json` |
111
122
 
112
123
  ### 5. Persistent Daemon — 100ms Commands
113
124
 
@@ -167,10 +178,10 @@ For full process isolation (separate Chromium instances), use `BROWSE_PORT` to r
167
178
  ## Install
168
179
 
169
180
  ```bash
170
- bun install -g @ulpi/browse
181
+ npm install -g @ulpi/browse
171
182
  ```
172
183
 
173
- Requires [Bun](https://bun.sh). Chromium is installed automatically via Playwright.
184
+ Requires [Bun](https://bun.sh) runtime. Chromium is installed automatically via Playwright.
174
185
 
175
186
  ### Claude Code Skill
176
187
 
@@ -222,7 +233,7 @@ browse click @e52
222
233
  `text` | `html [sel]` | `links` | `forms` | `accessibility`
223
234
 
224
235
  ### Interaction
225
- `click <sel>` | `fill <sel> <val>` | `select <sel> <val>` | `hover <sel>` | `type <text>` | `press <key>` | `scroll [sel]` | `wait <sel>` | `viewport <WxH>`
236
+ `click <sel>` | `dblclick <sel>` | `fill <sel> <val>` | `select <sel> <val>` | `hover <sel>` | `focus <sel>` | `check <sel>` | `uncheck <sel>` | `drag <src> <tgt>` | `type <text>` | `press <key>` | `keydown <key>` | `keyup <key>` | `scroll [sel|up|down]` | `wait <sel|--url|--network-idle>` | `viewport <WxH>` | `highlight <sel>` | `download <sel> [path]`
226
237
 
227
238
  ### Snapshot & Refs
228
239
  ```
@@ -244,7 +255,7 @@ After snapshot, use `@e1`, `@e2`... as selectors in any command.
244
255
  100+ devices: iPhone 12-17, Pixel 5-7, iPad, Galaxy, and all Playwright built-ins.
245
256
 
246
257
  ### Inspection
247
- `js <expr>` | `eval <file>` | `css <sel> <prop>` | `attrs <sel>` | `state <sel>` | `console [--clear]` | `network [--clear]` | `cookies` | `storage [set <k> <v>]` | `perf`
258
+ `js <expr>` | `eval <file>` | `css <sel> <prop>` | `attrs <sel>` | `element-state <sel>` | `value <sel>` | `count <sel>` | `console [--clear]` | `network [--clear]` | `cookies` | `storage [set <k> <v>]` | `perf`
248
259
 
249
260
  ### Visual
250
261
  `screenshot [path]` | `screenshot --annotate` | `pdf [path]` | `responsive [prefix]`
@@ -260,9 +271,21 @@ echo '[["goto","https://example.com"],["text"]]' | browse chain
260
271
  ### Tabs
261
272
  `tabs` | `tab <id>` | `newtab [url]` | `closetab [id]`
262
273
 
274
+ ### Frames
275
+ `frame <sel>` | `frame main`
276
+
263
277
  ### Sessions
264
278
  `sessions` | `session-close <id>`
265
279
 
280
+ ### Network
281
+ `route <pattern> block` | `route <pattern> fulfill <status> [body]` | `route clear` | `offline [on|off]`
282
+
283
+ ### State & Auth
284
+ `state save [name]` | `state load [name]` | `auth save <name> <url> <user> <pass>` | `auth login <name>` | `auth list` | `auth delete <name>`
285
+
286
+ ### Recording
287
+ `har start` | `har stop [path]`
288
+
266
289
  ### Server Control
267
290
  `status` | `cookie <n>=<v>` | `header <n>:<v>` | `useragent <str>` | `stop` | `restart`
268
291
 
@@ -293,8 +316,16 @@ browse [--session <id>] <command>
293
316
  |----------|---------|-------------|
294
317
  | `BROWSE_PORT` | auto 9400-10400 | Fixed server port |
295
318
  | `BROWSE_SESSION` | (none) | Default session ID for all commands |
319
+ | `BROWSE_INSTANCE` | auto (PPID) | Instance ID for multi-Claude isolation |
296
320
  | `BROWSE_IDLE_TIMEOUT` | 1800000 (30m) | Idle shutdown in ms |
321
+ | `BROWSE_TIMEOUT` | (none) | Override all command timeouts (ms) |
297
322
  | `BROWSE_LOCAL_DIR` | `.browse/` or `/tmp` | State/log directory |
323
+ | `BROWSE_JSON` | (none) | Set to `1` for JSON output mode |
324
+ | `BROWSE_CONTENT_BOUNDARIES` | (none) | Set to `1` for nonce-delimited output |
325
+ | `BROWSE_ALLOWED_DOMAINS` | (none) | Comma-separated domain allowlist |
326
+ | `BROWSE_PROXY` | (none) | Proxy server URL |
327
+ | `BROWSE_PROXY_BYPASS` | (none) | Proxy bypass list |
328
+ | `BROWSE_CDP_URL` | (none) | Connect to remote Chrome via CDP |
298
329
 
299
330
  ## Acknowledgments
300
331
 
@@ -302,28 +333,44 @@ Inspired by and originally derived from the `/browse` skill in [gstack](https://
302
333
 
303
334
  ### Added beyond gstack
304
335
 
305
- **New commands:**
306
- - `emulate` / `devices` — device emulation with 100+ devices (iPhone, Pixel, iPad, custom descriptors)
307
- - `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
308
339
  - `snapshot-diff` — before/after comparison with ref-number stripping
309
- - `dialog` / `dialog-accept` / `dialog-dismiss` — dialog handling with prompt value support
310
- - `state` — element state inspection (visible, enabled, checked, focused, bounding box)
311
- - `upload` — file upload to input elements
312
- - `sessions` / `session-close` — multi-agent session multiplexing
340
+ - `dialog` / `dialog-accept` / `dialog-dismiss` — dialog handling
341
+ - `upload` — file upload
313
342
  - `screenshot --annotate` — numbered badge overlay with legend
314
-
315
- **Architectural improvements:**
316
- - Session multiplexingmultiple agents share one Chromium via isolated BrowserContexts
317
- - Per-tab ref scoping — refs belong to the tab that created them, cross-tab usage throws clear error
318
- - Per-tab snapshot baselines `snapshot-diff` compares the correct baseline after tab switches
319
- - Safe retry classification read commands auto-retry after crash, write commands don't (prevents double form submissions)
320
- - Concurrency-safe server spawning file lock with stale detection prevents race conditions
321
- - Network correlation via WeakMap accurate request/response pairing even with duplicate URLs
322
- - Content-Length based sizing avoids reading response bodies into memory
323
- - TreeWalker text extraction `text` command never triggers MutationObservers
324
- - Tab creation rollback failed `newTab(url)` closes the page instead of leaving orphan tabs
325
- - Context recreation with rollback `emulate`/`useragent` preserve cookies and all tab URLs, rollback on failure
326
- - 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
327
374
 
328
375
  ## License
329
376
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulpi/browse",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/ulpi-io/browse"
@@ -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
@@ -36,7 +36,10 @@ const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : (BROWSE_INSTANCE ? `-$
36
36
  * Falls back to /tmp/ if not found (e.g. running outside a project).
37
37
  */
38
38
  function resolveLocalDir(): string {
39
- if (process.env.BROWSE_LOCAL_DIR) return process.env.BROWSE_LOCAL_DIR;
39
+ if (process.env.BROWSE_LOCAL_DIR) {
40
+ try { fs.mkdirSync(process.env.BROWSE_LOCAL_DIR, { recursive: true }); } catch {}
41
+ return process.env.BROWSE_LOCAL_DIR;
42
+ }
40
43
 
41
44
  let dir = process.cwd();
42
45
  for (let i = 0; i < 20; i++) {
@@ -121,6 +124,16 @@ function isProcessAlive(pid: number): boolean {
121
124
  }
122
125
  }
123
126
 
127
+ function isBrowseProcess(pid: number): boolean {
128
+ try {
129
+ const { execSync } = require('child_process');
130
+ const cmd = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf-8' }).trim();
131
+ return cmd.includes('browse') || cmd.includes('__BROWSE_SERVER_MODE');
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
124
137
  // ─── Server Lifecycle ──────────────────────────────────────────
125
138
 
126
139
  /**
@@ -249,16 +262,18 @@ async function ensureServer(): Promise<ServerState> {
249
262
  }
250
263
 
251
264
  // 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);
265
+ // Kill it so we can start fresh — but only if it's actually a browse process.
266
+ if (isBrowseProcess(state.pid)) {
267
+ try { process.kill(state.pid, 'SIGTERM'); } catch {}
268
+ // Brief wait for graceful exit
269
+ const deadline = Date.now() + 3000;
270
+ while (Date.now() < deadline && isProcessAlive(state.pid)) {
271
+ await Bun.sleep(100);
272
+ }
273
+ if (isProcessAlive(state.pid)) {
274
+ try { process.kill(state.pid, 'SIGKILL'); } catch {}
275
+ await Bun.sleep(200);
276
+ }
262
277
  }
263
278
  }
264
279
 
@@ -287,13 +302,23 @@ function cleanOrphanedServers(): void {
287
302
  if (!file.startsWith('browse-server') || !file.endsWith('.json') || file.endsWith('.lock')) continue;
288
303
  const filePath = path.join(LOCAL_DIR, file);
289
304
  if (filePath === STATE_FILE) continue; // Don't touch our own state file
305
+ // Only clean files with PID-based suffixes. Skip port-based and non-numeric.
306
+ // Port-based files have a port number from a BROWSE_PORT env var.
307
+ // PID-based files have a process ID (typically >10000, never <1000).
308
+ // To distinguish: read the state file and check if the suffix matches the PID inside.
309
+ const suffixMatch = file.match(/browse-server-(\d+)\.json$/);
310
+ if (!suffixMatch) continue;
311
+ const suffix = parseInt(suffixMatch[1], 10);
290
312
  try {
291
313
  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
- }
314
+ if (!data.pid) continue;
315
+ // Port-based file: suffix matches the port inside (intentional BROWSE_PORT instance)
316
+ if (data.port === suffix) continue;
317
+ // PID-based file: suffix was a PPID from the spawning CLI
318
+ if (isProcessAlive(data.pid) && isBrowseProcess(data.pid)) {
319
+ try { process.kill(data.pid, 'SIGTERM'); } catch {}
320
+ }
321
+ if (!isProcessAlive(data.pid)) {
297
322
  fs.unlinkSync(filePath);
298
323
  }
299
324
  } catch {}
@@ -314,7 +339,7 @@ export const SAFE_TO_RETRY = new Set([
314
339
  'css', 'attrs', 'element-state', 'dialog',
315
340
  'console', 'network', 'cookies', 'perf', 'value', 'count',
316
341
  // Meta commands that are read-only or idempotent
317
- 'tabs', 'status', 'url', 'snapshot', 'snapshot-diff', 'devices', 'sessions', 'frame',
342
+ 'tabs', 'status', 'url', 'snapshot', 'snapshot-diff', 'devices', 'sessions', 'frame', 'find',
318
343
  ]);
319
344
 
320
345
  // Commands that return static data independent of page state.
@@ -443,41 +468,52 @@ export async function main() {
443
468
  // Load project config (browse.json) — values serve as defaults
444
469
  const config = loadConfig();
445
470
 
446
- // Extract --session flag before command parsing
471
+ // Find the first non-flag arg (the command) to limit global flag scanning.
472
+ // Only extract global flags from args BEFORE the command position.
473
+ function findCommandIndex(a: string[]): number {
474
+ for (let i = 0; i < a.length; i++) {
475
+ if (!a[i].startsWith('-')) return i;
476
+ // Skip flag values for known value-flags
477
+ if (a[i] === '--session' || a[i] === '--allowed-domains') i++;
478
+ }
479
+ return a.length;
480
+ }
481
+
482
+ // Extract --session flag (only before command)
447
483
  let sessionId: string | undefined;
448
484
  const sessionIdx = args.indexOf('--session');
449
- if (sessionIdx !== -1) {
485
+ if (sessionIdx !== -1 && sessionIdx < findCommandIndex(args)) {
450
486
  sessionId = args[sessionIdx + 1];
451
487
  if (!sessionId || sessionId.startsWith('-')) {
452
488
  console.error('Usage: browse --session <id> <command> [args...]');
453
489
  process.exit(1);
454
490
  }
455
- args.splice(sessionIdx, 2); // remove --session and its value
491
+ args.splice(sessionIdx, 2);
456
492
  }
457
493
  sessionId = sessionId || process.env.BROWSE_SESSION || config.session || undefined;
458
494
 
459
- // Extract --json flag
495
+ // Extract --json flag (only before command)
460
496
  let jsonMode = false;
461
497
  const jsonIdx = args.indexOf('--json');
462
- if (jsonIdx !== -1) {
498
+ if (jsonIdx !== -1 && jsonIdx < findCommandIndex(args)) {
463
499
  jsonMode = true;
464
500
  args.splice(jsonIdx, 1);
465
501
  }
466
502
  jsonMode = jsonMode || process.env.BROWSE_JSON === '1' || config.json === true;
467
503
 
468
- // Extract --content-boundaries flag
504
+ // Extract --content-boundaries flag (only before command)
469
505
  let contentBoundaries = false;
470
506
  const boundIdx = args.indexOf('--content-boundaries');
471
- if (boundIdx !== -1) {
507
+ if (boundIdx !== -1 && boundIdx < findCommandIndex(args)) {
472
508
  contentBoundaries = true;
473
509
  args.splice(boundIdx, 1);
474
510
  }
475
511
  contentBoundaries = contentBoundaries || process.env.BROWSE_CONTENT_BOUNDARIES === '1' || config.contentBoundaries === true;
476
512
 
477
- // Extract --allowed-domains flag
513
+ // Extract --allowed-domains flag (only before command)
478
514
  let allowedDomains: string | undefined;
479
515
  const domIdx = args.indexOf('--allowed-domains');
480
- if (domIdx !== -1) {
516
+ if (domIdx !== -1 && domIdx < findCommandIndex(args)) {
481
517
  allowedDomains = args[domIdx + 1];
482
518
  if (!allowedDomains || allowedDomains.startsWith('-')) {
483
519
  console.error('Usage: browse --allowed-domains domain1,domain2 <command> [args...]');
@@ -519,7 +555,8 @@ Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
519
555
  value <sel> | count <sel>
520
556
  Visual: screenshot [path] | pdf [path] | responsive [prefix]
521
557
  Snapshot: snapshot [-i] [-c] [-C] [-d N] [-s sel]
522
- Compare: diff <url1> <url2>
558
+ Find: find role|text|label|placeholder|testid <query> [name]
559
+ Compare: diff <url1> <url2> | screenshot-diff <baseline> [current]
523
560
  Multi-step: chain (reads JSON from stdin)
524
561
  Network: offline [on|off] | route <pattern> block|fulfill
525
562
  Recording: har start | har stop [path]
@@ -528,7 +565,7 @@ Frames: frame <sel> | frame main
528
565
  Sessions: sessions | session-close <id>
529
566
  Auth: auth save <name> <url> <user> <pass|--password-stdin>
530
567
  auth login <name> | auth list | auth delete <name>
531
- State: state save [name] | state load [name]
568
+ State: state save|load|list|show [name]
532
569
  Server: status | cookie <n>=<v> | header <n>:<v>
533
570
  useragent <str> | stop | restart
534
571
  Setup: install-skill [path]
@@ -561,6 +598,13 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
561
598
  commandArgs.push(stdin.trim());
562
599
  }
563
600
 
601
+ // Special case: auth --password-stdin reads in CLI before sending to server
602
+ if (command === 'auth' && commandArgs.includes('--password-stdin')) {
603
+ const stdinIdx = commandArgs.indexOf('--password-stdin');
604
+ const password = (await Bun.stdin.text()).trim();
605
+ commandArgs.splice(stdinIdx, 1, password);
606
+ }
607
+
564
608
  const state = await ensureServer();
565
609
  await sendCommand(state, command, commandArgs, 0, sessionId);
566
610
  }
@@ -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);