@ulpi/browse 0.1.0 → 0.2.1

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.
@@ -6,6 +6,7 @@ import type { BrowserManager } from '../browser-manager';
6
6
  import type { SessionManager, Session } from '../session-manager';
7
7
  import { handleSnapshot } from '../snapshot';
8
8
  import { DEFAULTS } from '../constants';
9
+ import { sanitizeName } from '../sanitize';
9
10
  import * as Diff from 'diff';
10
11
  import * as fs from 'fs';
11
12
 
@@ -100,12 +101,59 @@ export async function handleMetaCommand(
100
101
  return `Session "${id}" closed`;
101
102
  }
102
103
 
104
+ // ─── State Persistence ───────────────────────────────
105
+ case 'state': {
106
+ const subcommand = args[0];
107
+ if (!subcommand || !['save', 'load'].includes(subcommand)) {
108
+ throw new Error('Usage: browse state save [name] | browse state load [name]');
109
+ }
110
+ const name = sanitizeName(args[1] || 'default');
111
+ const statesDir = `${LOCAL_DIR}/states`;
112
+ const statePath = `${statesDir}/${name}.json`;
113
+
114
+ if (subcommand === 'save') {
115
+ const context = bm.getContext();
116
+ if (!context) throw new Error('No browser context');
117
+ const state = await context.storageState();
118
+ fs.mkdirSync(statesDir, { recursive: true });
119
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
120
+ return `State saved: ${statePath}`;
121
+ }
122
+
123
+ if (subcommand === 'load') {
124
+ if (!fs.existsSync(statePath)) {
125
+ throw new Error(`State file not found: ${statePath}. Run "browse state save ${name}" first.`);
126
+ }
127
+ const stateData = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
128
+ // Add cookies from saved state to current context
129
+ const context = bm.getContext();
130
+ if (!context) throw new Error('No browser context');
131
+ if (stateData.cookies?.length) {
132
+ await context.addCookies(stateData.cookies);
133
+ }
134
+ // Restore localStorage/sessionStorage for each origin
135
+ if (stateData.origins?.length) {
136
+ for (const origin of stateData.origins) {
137
+ 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(() => {});
142
+ }
143
+ }
144
+ }
145
+ }
146
+ return `State loaded: ${statePath}`;
147
+ }
148
+ throw new Error('Usage: browse state save [name] | browse state load [name]');
149
+ }
150
+
103
151
  // ─── Visual ────────────────────────────────────────
104
152
  case 'screenshot': {
105
153
  const page = bm.getPage();
106
154
  const annotate = args.includes('--annotate');
107
155
  const filteredArgs = args.filter(a => a !== '--annotate');
108
- const screenshotPath = filteredArgs[0] || `${LOCAL_DIR}/browse-screenshot.png`;
156
+ const screenshotPath = filteredArgs[0] || (currentSession ? `${currentSession.outputDir}/screenshot.png` : `${LOCAL_DIR}/browse-screenshot.png`);
109
157
 
110
158
  if (annotate) {
111
159
  const viewport = page.viewportSize() || { width: 1920, height: 1080 };
@@ -180,14 +228,14 @@ export async function handleMetaCommand(
180
228
 
181
229
  case 'pdf': {
182
230
  const page = bm.getPage();
183
- const pdfPath = args[0] || `${LOCAL_DIR}/browse-page.pdf`;
231
+ const pdfPath = args[0] || (currentSession ? `${currentSession.outputDir}/page.pdf` : `${LOCAL_DIR}/browse-page.pdf`);
184
232
  await page.pdf({ path: pdfPath, format: 'A4' });
185
233
  return `PDF saved: ${pdfPath}`;
186
234
  }
187
235
 
188
236
  case 'responsive': {
189
237
  const page = bm.getPage();
190
- const prefix = args[0] || `${LOCAL_DIR}/browse-responsive`;
238
+ const prefix = args[0] || (currentSession ? `${currentSession.outputDir}/responsive` : `${LOCAL_DIR}/browse-responsive`);
191
239
  const viewports = [
192
240
  { name: 'mobile', width: 375, height: 812 },
193
241
  { name: 'tablet', width: 768, height: 1024 },
@@ -229,15 +277,28 @@ export async function handleMetaCommand(
229
277
  const results: string[] = [];
230
278
  const { handleReadCommand } = await import('./read');
231
279
  const { handleWriteCommand } = await import('./write');
280
+ const { PolicyChecker } = await import('../policy');
232
281
 
233
- const WRITE_SET = new Set(['goto','back','forward','reload','click','fill','select','hover','type','press','scroll','wait','viewport','cookie','header','useragent','upload','dialog-accept','dialog-dismiss','emulate']);
234
- const READ_SET = new Set(['text','html','links','forms','accessibility','js','eval','css','attrs','state','dialog','console','network','cookies','storage','perf','devices']);
282
+ const WRITE_SET = new Set(['goto','back','forward','reload','click','dblclick','fill','select','hover','focus','check','uncheck','type','press','scroll','wait','viewport','cookie','header','useragent','upload','dialog-accept','dialog-dismiss','emulate','drag','keydown','keyup','highlight','download','route','offline']);
283
+ const READ_SET = new Set(['text','html','links','forms','accessibility','js','eval','css','attrs','element-state','dialog','console','network','cookies','storage','perf','devices','value','count']);
235
284
 
236
285
  const sessionBuffers = currentSession?.buffers;
286
+ const policy = new PolicyChecker();
237
287
 
238
288
  for (const cmd of commands) {
239
289
  const [name, ...cmdArgs] = cmd;
240
290
  try {
291
+ // Policy check for each sub-command — chain must not bypass policy
292
+ const policyResult = policy.check(name);
293
+ if (policyResult === 'deny') {
294
+ results.push(`[${name}] ERROR: Command '${name}' denied by policy`);
295
+ continue;
296
+ }
297
+ if (policyResult === 'confirm') {
298
+ results.push(`[${name}] ERROR: Command '${name}' requires confirmation (policy)`);
299
+ continue;
300
+ }
301
+
241
302
  let result: string;
242
303
  if (WRITE_SET.has(name)) result = await handleWriteCommand(name, cmdArgs, bm);
243
304
  else if (READ_SET.has(name)) result = await handleReadCommand(name, cmdArgs, bm, sessionBuffers);
@@ -352,6 +413,116 @@ export async function handleMetaCommand(
352
413
  return output.join('\n');
353
414
  }
354
415
 
416
+ // ─── Auth Vault ─────────────────────────────────────
417
+ case 'auth': {
418
+ const subcommand = args[0];
419
+ const { AuthVault } = await import('../auth-vault');
420
+ const vault = new AuthVault(LOCAL_DIR);
421
+
422
+ switch (subcommand) {
423
+ case 'save': {
424
+ 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;
428
+ if (!password && process.env.BROWSE_AUTH_PASSWORD) {
429
+ password = process.env.BROWSE_AUTH_PASSWORD;
430
+ }
431
+ if (!password && args.includes('--password-stdin')) {
432
+ password = (await Bun.stdin.text()).trim();
433
+ }
434
+ if (!name || !url || !username || !password) {
435
+ throw new Error(
436
+ 'Usage: browse auth save <name> <url> <username> <password>\n' +
437
+ ' browse auth save <name> <url> <username> --password-stdin\n' +
438
+ ' BROWSE_AUTH_PASSWORD=secret browse auth save <name> <url> <username>'
439
+ );
440
+ }
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
+ const selectors = (userSel || passSel || submitSel) ? { username: userSel, password: passSel, submit: submitSel } : undefined;
451
+ vault.save(name, url, username, password, selectors);
452
+ return `Credentials saved: ${name}`;
453
+ }
454
+ case 'login': {
455
+ const name = args[1];
456
+ if (!name) throw new Error('Usage: browse auth login <name>');
457
+ return await vault.login(name, bm);
458
+ }
459
+ case 'list': {
460
+ const creds = vault.list();
461
+ if (creds.length === 0) return '(no saved credentials)';
462
+ return creds.map(c => ` ${c.name} — ${c.url} (${c.username})`).join('\n');
463
+ }
464
+ case 'delete': {
465
+ const name = args[1];
466
+ if (!name) throw new Error('Usage: browse auth delete <name>');
467
+ vault.delete(name);
468
+ return `Credentials deleted: ${name}`;
469
+ }
470
+ default:
471
+ throw new Error('Usage: browse auth save|login|list|delete [args...]');
472
+ }
473
+ }
474
+
475
+ // ─── HAR Recording ────────────────────────────────
476
+ case 'har': {
477
+ const subcommand = args[0];
478
+ if (!subcommand) throw new Error('Usage: browse har start | browse har stop [path]');
479
+
480
+ if (subcommand === 'start') {
481
+ bm.startHarRecording();
482
+ return 'HAR recording started';
483
+ }
484
+
485
+ if (subcommand === 'stop') {
486
+ const recording = bm.stopHarRecording();
487
+ if (!recording) throw new Error('No active HAR recording. Run "browse har start" first.');
488
+
489
+ const sessionBuffers = currentSession?.buffers || bm.getBuffers();
490
+ const { formatAsHar } = await import('../har');
491
+ const har = formatAsHar(sessionBuffers.networkBuffer, recording.startTime);
492
+
493
+ const harPath = args[1] || (currentSession
494
+ ? `${currentSession.outputDir}/recording.har`
495
+ : `${LOCAL_DIR}/browse-recording.har`);
496
+
497
+ fs.writeFileSync(harPath, JSON.stringify(har, null, 2));
498
+ const entryCount = (har as any).log.entries.length;
499
+ return `HAR saved: ${harPath} (${entryCount} entries)`;
500
+ }
501
+
502
+ throw new Error('Usage: browse har start | browse har stop [path]');
503
+ }
504
+
505
+ // ─── iframe Targeting ─────────────────────────────
506
+ case 'frame': {
507
+ if (args[0] === 'main' || args[0] === 'top') {
508
+ bm.resetFrame();
509
+ return 'Switched to main frame';
510
+ }
511
+ const selector = args[0];
512
+ if (!selector) throw new Error('Usage: browse frame <selector> | browse frame main');
513
+ // Verify the iframe exists and is accessible
514
+ const page = bm.getPage();
515
+ const frameEl = page.locator(selector);
516
+ const count = await frameEl.count();
517
+ if (count === 0) throw new Error(`iframe not found: ${selector}`);
518
+ const handle = await frameEl.elementHandle({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
519
+ if (!handle) throw new Error(`iframe not found: ${selector}`);
520
+ const frame = await handle.contentFrame();
521
+ if (!frame) throw new Error(`Element ${selector} is not an iframe`);
522
+ bm.setFrame(selector);
523
+ return `Switched to frame: ${selector}`;
524
+ }
525
+
355
526
  default:
356
527
  throw new Error(`Unknown meta command: ${command}`);
357
528
  }
@@ -17,12 +17,15 @@ export async function handleReadCommand(
17
17
  buffers?: SessionBuffers
18
18
  ): Promise<string> {
19
19
  const page = bm.getPage();
20
+ // When a frame is active, evaluate() calls run inside the frame context.
21
+ // For locator-based commands, resolveRef already scopes through the frame.
22
+ const evalCtx = await bm.getFrameContext() || page;
20
23
 
21
24
  switch (command) {
22
25
  case 'text': {
23
26
  // TreeWalker-based extraction — never appends to the live DOM,
24
27
  // so MutationObservers are not triggered.
25
- return await page.evaluate(() => {
28
+ return await evalCtx.evaluate(() => {
26
29
  const body = document.body;
27
30
  if (!body) return '';
28
31
  const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'SVG']);
@@ -57,11 +60,15 @@ export async function handleReadCommand(
57
60
  }
58
61
  return await page.innerHTML(resolved.selector);
59
62
  }
63
+ // When a frame is active, return the frame's full HTML
64
+ if (bm.getActiveFrameSelector()) {
65
+ return await evalCtx.evaluate(() => document.documentElement.outerHTML);
66
+ }
60
67
  return await page.content();
61
68
  }
62
69
 
63
70
  case 'links': {
64
- const links = await page.evaluate(() =>
71
+ const links = await evalCtx.evaluate(() =>
65
72
  [...document.querySelectorAll('a[href]')].map(a => ({
66
73
  text: a.textContent?.trim().slice(0, 120) || '',
67
74
  href: (a as HTMLAnchorElement).href,
@@ -71,7 +78,7 @@ export async function handleReadCommand(
71
78
  }
72
79
 
73
80
  case 'forms': {
74
- const forms = await page.evaluate(() => {
81
+ const forms = await evalCtx.evaluate(() => {
75
82
  return [...document.querySelectorAll('form')].map((form, i) => {
76
83
  const fields = [...form.querySelectorAll('input, select, textarea')].map(el => {
77
84
  const input = el as HTMLInputElement;
@@ -101,14 +108,15 @@ export async function handleReadCommand(
101
108
  }
102
109
 
103
110
  case 'accessibility': {
104
- const snapshot = await page.locator("body").ariaSnapshot();
111
+ const root = bm.getLocatorRoot();
112
+ const snapshot = await root.locator('body').ariaSnapshot();
105
113
  return snapshot;
106
114
  }
107
115
 
108
116
  case 'js': {
109
117
  const expr = args[0];
110
118
  if (!expr) throw new Error('Usage: browse js <expression>');
111
- const result = await page.evaluate(expr);
119
+ const result = await evalCtx.evaluate(expr);
112
120
  return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
113
121
  }
114
122
 
@@ -117,7 +125,7 @@ export async function handleReadCommand(
117
125
  if (!filePath) throw new Error('Usage: browse eval <js-file>');
118
126
  if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
119
127
  const code = fs.readFileSync(filePath, 'utf-8');
120
- const result = await page.evaluate(code);
128
+ const result = await evalCtx.evaluate(code);
121
129
  return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
122
130
  }
123
131
 
@@ -132,7 +140,7 @@ export async function handleReadCommand(
132
140
  );
133
141
  return value;
134
142
  }
135
- const value = await page.evaluate(
143
+ const value = await evalCtx.evaluate(
136
144
  ([sel, prop]) => {
137
145
  const el = document.querySelector(sel);
138
146
  if (!el) return { __notFound: true, selector: sel };
@@ -146,9 +154,9 @@ export async function handleReadCommand(
146
154
  return value as string;
147
155
  }
148
156
 
149
- case 'state': {
157
+ case 'element-state': {
150
158
  const selector = args[0];
151
- if (!selector) throw new Error('Usage: browse state <selector>');
159
+ if (!selector) throw new Error('Usage: browse element-state <selector>');
152
160
  const resolved = bm.resolveRef(selector);
153
161
  const locator = 'locator' in resolved
154
162
  ? resolved.locator
@@ -202,7 +210,7 @@ export async function handleReadCommand(
202
210
  });
203
211
  return JSON.stringify(attrs, null, 2);
204
212
  }
205
- const attrs = await page.evaluate((sel) => {
213
+ const attrs = await evalCtx.evaluate((sel) => {
206
214
  const el = document.querySelector(sel);
207
215
  if (!el) return { __notFound: true, selector: sel };
208
216
  const result: Record<string, string> = {};
@@ -256,10 +264,10 @@ export async function handleReadCommand(
256
264
  if (args[0] === 'set' && args[1]) {
257
265
  const key = args[1];
258
266
  const value = args[2] || '';
259
- await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]);
267
+ await evalCtx.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]);
260
268
  return `Set localStorage["${key}"] = "${value}"`;
261
269
  }
262
- const storage = await page.evaluate(() => ({
270
+ const storage = await evalCtx.evaluate(() => ({
263
271
  localStorage: { ...localStorage },
264
272
  sessionStorage: { ...sessionStorage },
265
273
  }));
@@ -267,7 +275,7 @@ export async function handleReadCommand(
267
275
  }
268
276
 
269
277
  case 'perf': {
270
- const timings = await page.evaluate(() => {
278
+ const timings = await evalCtx.evaluate(() => {
271
279
  const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
272
280
  if (!nav) return 'No navigation timing data available.';
273
281
  return {
@@ -288,6 +296,24 @@ export async function handleReadCommand(
288
296
  .join('\n');
289
297
  }
290
298
 
299
+ case 'value': {
300
+ const selector = args[0];
301
+ if (!selector) throw new Error('Usage: browse value <selector>');
302
+ const resolved = bm.resolveRef(selector);
303
+ const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
304
+ const value = await locator.inputValue({ timeout: 5000 });
305
+ return value;
306
+ }
307
+
308
+ case 'count': {
309
+ const selector = args[0];
310
+ if (!selector) throw new Error('Usage: browse count <selector>');
311
+ const resolved = bm.resolveRef(selector);
312
+ const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
313
+ const count = await locator.count();
314
+ return String(count);
315
+ }
316
+
291
317
  case 'devices': {
292
318
  const filter = args.join(' ').toLowerCase();
293
319
  const all = listDevices();
@@ -1,19 +1,22 @@
1
1
  /**
2
2
  * Write commands — navigate and interact with pages (side effects)
3
3
  *
4
- * goto, back, forward, reload, click, fill, select, hover, type,
5
- * press, scroll, wait, viewport, cookie, header, useragent
4
+ * goto, back, forward, reload, click, dblclick, fill, select, hover,
5
+ * focus, check, uncheck, type, press, scroll, wait, viewport, cookie,
6
+ * header, useragent, drag, keydown, keyup
6
7
  */
7
8
 
8
9
  import type { BrowserManager } from '../browser-manager';
9
10
  import { resolveDevice, listDevices } from '../browser-manager';
11
+ import type { DomainFilter } from '../domain-filter';
10
12
  import { DEFAULTS } from '../constants';
11
13
  import * as fs from 'fs';
12
14
 
13
15
  export async function handleWriteCommand(
14
16
  command: string,
15
17
  args: string[],
16
- bm: BrowserManager
18
+ bm: BrowserManager,
19
+ domainFilter?: DomainFilter | null
17
20
  ): Promise<string> {
18
21
  const page = bm.getPage();
19
22
 
@@ -21,6 +24,9 @@ export async function handleWriteCommand(
21
24
  case 'goto': {
22
25
  const url = args[0];
23
26
  if (!url) throw new Error('Usage: browse goto <url>');
27
+ if (domainFilter && !domainFilter.isAllowed(url)) {
28
+ throw new Error(domainFilter.blockedMessage(url));
29
+ }
24
30
  const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: DEFAULTS.COMMAND_TIMEOUT_MS });
25
31
  const status = response?.status() || 'unknown';
26
32
  return `Navigated to ${url} (${status})`;
@@ -109,7 +115,17 @@ export async function handleWriteCommand(
109
115
 
110
116
  case 'scroll': {
111
117
  const selector = args[0];
112
- if (selector) {
118
+ if (selector === 'up') {
119
+ const scrollCtx = await bm.getFrameContext() || page;
120
+ await scrollCtx.evaluate(() => window.scrollBy(0, -window.innerHeight));
121
+ return 'Scrolled up one viewport';
122
+ }
123
+ if (selector === 'down') {
124
+ const scrollCtx = await bm.getFrameContext() || page;
125
+ await scrollCtx.evaluate(() => window.scrollBy(0, window.innerHeight));
126
+ return 'Scrolled down one viewport';
127
+ }
128
+ if (selector && selector !== 'bottom') {
113
129
  const resolved = bm.resolveRef(selector);
114
130
  if ('locator' in resolved) {
115
131
  await resolved.locator.scrollIntoViewIfNeeded({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
@@ -118,13 +134,32 @@ export async function handleWriteCommand(
118
134
  }
119
135
  return `Scrolled ${selector} into view`;
120
136
  }
121
- await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
137
+ // Scroll to bottom (default or explicit "bottom")
138
+ const scrollCtx = await bm.getFrameContext() || page;
139
+ await scrollCtx.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
122
140
  return 'Scrolled to bottom';
123
141
  }
124
142
 
125
143
  case 'wait': {
126
144
  const selector = args[0];
127
- if (!selector) throw new Error('Usage: browse wait <selector>');
145
+ if (!selector) throw new Error('Usage: browse wait <selector|--url|--network-idle> [timeout]');
146
+
147
+ // wait --network-idle [timeout] — wait for network to settle
148
+ if (selector === '--network-idle') {
149
+ const timeout = args[1] ? parseInt(args[1], 10) : DEFAULTS.COMMAND_TIMEOUT_MS;
150
+ await page.waitForLoadState('networkidle', { timeout });
151
+ return 'Network idle';
152
+ }
153
+
154
+ // wait --url <pattern> [timeout] — wait for URL to match
155
+ if (selector === '--url') {
156
+ const pattern = args[1];
157
+ if (!pattern) throw new Error('Usage: browse wait --url <pattern> [timeout]');
158
+ const timeout = args[2] ? parseInt(args[2], 10) : DEFAULTS.COMMAND_TIMEOUT_MS;
159
+ await page.waitForURL(pattern, { timeout });
160
+ return `URL matched: ${page.url()}`;
161
+ }
162
+
128
163
  const timeout = args[1] ? parseInt(args[1], 10) : DEFAULTS.COMMAND_TIMEOUT_MS;
129
164
  const resolved = bm.resolveRef(selector);
130
165
  if ('locator' in resolved) {
@@ -253,6 +288,165 @@ export async function handleWriteCommand(
253
288
  ].join('\n');
254
289
  }
255
290
 
291
+ case 'dblclick': {
292
+ const selector = args[0];
293
+ if (!selector) throw new Error('Usage: browse dblclick <selector>');
294
+ const resolved = bm.resolveRef(selector);
295
+ if ('locator' in resolved) {
296
+ await resolved.locator.dblclick({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
297
+ } else {
298
+ await page.dblclick(resolved.selector, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
299
+ }
300
+ return `Double-clicked ${selector}`;
301
+ }
302
+
303
+ case 'focus': {
304
+ const selector = args[0];
305
+ if (!selector) throw new Error('Usage: browse focus <selector>');
306
+ const resolved = bm.resolveRef(selector);
307
+ if ('locator' in resolved) {
308
+ await resolved.locator.focus({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
309
+ } else {
310
+ await page.locator(resolved.selector).focus({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
311
+ }
312
+ return `Focused ${selector}`;
313
+ }
314
+
315
+ case 'check': {
316
+ const selector = args[0];
317
+ if (!selector) throw new Error('Usage: browse check <selector>');
318
+ const resolved = bm.resolveRef(selector);
319
+ if ('locator' in resolved) {
320
+ await resolved.locator.check({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
321
+ } else {
322
+ await page.locator(resolved.selector).check({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
323
+ }
324
+ return `Checked ${selector}`;
325
+ }
326
+
327
+ case 'uncheck': {
328
+ const selector = args[0];
329
+ if (!selector) throw new Error('Usage: browse uncheck <selector>');
330
+ const resolved = bm.resolveRef(selector);
331
+ if ('locator' in resolved) {
332
+ await resolved.locator.uncheck({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
333
+ } else {
334
+ await page.locator(resolved.selector).uncheck({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
335
+ }
336
+ return `Unchecked ${selector}`;
337
+ }
338
+
339
+ case 'drag': {
340
+ const [srcSel, tgtSel] = args;
341
+ if (!srcSel || !tgtSel) throw new Error('Usage: browse drag <source> <target>');
342
+ const srcResolved = bm.resolveRef(srcSel);
343
+ const tgtResolved = bm.resolveRef(tgtSel);
344
+ const srcLocator = 'locator' in srcResolved ? srcResolved.locator : page.locator(srcResolved.selector);
345
+ const tgtLocator = 'locator' in tgtResolved ? tgtResolved.locator : page.locator(tgtResolved.selector);
346
+ await srcLocator.dragTo(tgtLocator, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
347
+ return `Dragged ${srcSel} to ${tgtSel}`;
348
+ }
349
+
350
+ case 'keydown': {
351
+ const key = args[0];
352
+ if (!key) throw new Error('Usage: browse keydown <key>');
353
+ await page.keyboard.down(key);
354
+ return `Key down: ${key}`;
355
+ }
356
+
357
+ case 'keyup': {
358
+ const key = args[0];
359
+ if (!key) throw new Error('Usage: browse keyup <key>');
360
+ await page.keyboard.up(key);
361
+ return `Key up: ${key}`;
362
+ }
363
+
364
+ case 'highlight': {
365
+ const selector = args[0];
366
+ if (!selector) throw new Error('Usage: browse highlight <selector>');
367
+ const resolved = bm.resolveRef(selector);
368
+ const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
369
+ await locator.evaluate((el) => {
370
+ el.style.outline = '3px solid #e11d48';
371
+ el.style.outlineOffset = '2px';
372
+ });
373
+ return `Highlighted ${selector}`;
374
+ }
375
+
376
+ case 'download': {
377
+ const [selector, savePath] = args;
378
+ if (!selector) throw new Error('Usage: browse download <selector> [path]');
379
+ const resolved = bm.resolveRef(selector);
380
+ const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
381
+ const [download] = await Promise.all([
382
+ page.waitForEvent('download', { timeout: DEFAULTS.COMMAND_TIMEOUT_MS }),
383
+ locator.click({ timeout: DEFAULTS.ACTION_TIMEOUT_MS }),
384
+ ]);
385
+ const finalPath = savePath || download.suggestedFilename();
386
+ await download.saveAs(finalPath);
387
+ return `Downloaded: ${finalPath}`;
388
+ }
389
+
390
+ case 'offline': {
391
+ const mode = args[0];
392
+ if (mode === 'on') {
393
+ await bm.setOffline(true);
394
+ return 'Offline mode: ON';
395
+ }
396
+ if (mode === 'off') {
397
+ await bm.setOffline(false);
398
+ return 'Offline mode: OFF';
399
+ }
400
+ // Toggle
401
+ const newState = !bm.isOffline();
402
+ await bm.setOffline(newState);
403
+ return `Offline mode: ${newState ? 'ON' : 'OFF'}`;
404
+ }
405
+
406
+ case 'route': {
407
+ // route <pattern> block — abort matching requests
408
+ // route <pattern> fulfill <status> [body] — respond with custom data
409
+ // route clear — remove all routes
410
+ const pattern = args[0];
411
+ if (!pattern) throw new Error('Usage: browse route <url-pattern> block | browse route <url-pattern> fulfill <status> [body] | browse route clear');
412
+
413
+ const context = bm.getContext();
414
+ if (!context) throw new Error('No browser context');
415
+
416
+ if (pattern === 'clear') {
417
+ await context.unrouteAll();
418
+ bm.clearUserRoutes();
419
+ // Re-apply domain filter route if active
420
+ if (domainFilter) {
421
+ await context.route('**/*', (route) => {
422
+ const url = route.request().url();
423
+ if (domainFilter!.isAllowed(url)) { route.continue(); } else { route.abort('blockedbyclient'); }
424
+ });
425
+ }
426
+ return domainFilter ? 'All routes cleared (domain filter preserved)' : 'All routes cleared';
427
+ }
428
+
429
+ const action = args[1] || 'block';
430
+
431
+ if (action === 'block') {
432
+ await context.route(pattern, (route) => route.abort('blockedbyclient'));
433
+ bm.addUserRoute(pattern, 'block');
434
+ return `Blocking requests matching: ${pattern}`;
435
+ }
436
+
437
+ if (action === 'fulfill') {
438
+ const status = parseInt(args[2] || '200', 10);
439
+ const body = args[3] || '';
440
+ await context.route(pattern, (route) =>
441
+ route.fulfill({ status, body, contentType: 'text/plain' })
442
+ );
443
+ bm.addUserRoute(pattern, 'fulfill', status, body);
444
+ return `Mocking requests matching: ${pattern} → ${status}${body ? ` "${body}"` : ''}`;
445
+ }
446
+
447
+ throw new Error('Usage: browse route <pattern> block | browse route <pattern> fulfill <status> [body]');
448
+ }
449
+
256
450
  default:
257
451
  throw new Error(`Unknown write command: ${command}`);
258
452
  }
package/src/config.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Config file loader — reads browse.json from project root.
3
+ * Config values serve as defaults — CLI flags and env vars override.
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+
9
+ export interface BrowseConfig {
10
+ session?: string;
11
+ json?: boolean;
12
+ contentBoundaries?: boolean;
13
+ allowedDomains?: string[];
14
+ idleTimeout?: number;
15
+ viewport?: string;
16
+ device?: string;
17
+ }
18
+
19
+ /**
20
+ * Load browse.json from the project root (directory containing .git or .claude).
21
+ * Returns empty config if file doesn't exist or is malformed.
22
+ */
23
+ export function loadConfig(): BrowseConfig {
24
+ let dir = process.cwd();
25
+ for (let i = 0; i < 20; i++) {
26
+ if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, '.claude'))) {
27
+ const configPath = path.join(dir, 'browse.json');
28
+ if (fs.existsSync(configPath)) {
29
+ try {
30
+ const raw = fs.readFileSync(configPath, 'utf-8');
31
+ return JSON.parse(raw) as BrowseConfig;
32
+ } catch {
33
+ // Malformed JSON — silently ignore
34
+ return {};
35
+ }
36
+ }
37
+ return {};
38
+ }
39
+ const parent = path.dirname(dir);
40
+ if (parent === dir) break;
41
+ dir = parent;
42
+ }
43
+ return {};
44
+ }