@ulpi/browse 0.7.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -73,16 +73,7 @@ export async function handleWriteCommand(
73
73
 
74
74
  case 'click': {
75
75
  const selector = args[0];
76
- if (!selector) throw new Error('Usage: browse click <selector|x,y>');
77
- // Coordinate click: "590,461" or "590, 461"
78
- const coordMatch = selector.match(/^(\d+)\s*,\s*(\d+)$/);
79
- if (coordMatch) {
80
- const x = parseInt(coordMatch[1], 10);
81
- const y = parseInt(coordMatch[2], 10);
82
- await page.mouse.click(x, y);
83
- await page.waitForLoadState('domcontentloaded').catch(() => {});
84
- return `Clicked at (${x}, ${y}) → now at ${page.url()}`;
85
- }
76
+ if (!selector) throw new Error('Usage: browse click <selector>');
86
77
  const resolved = bm.resolveRef(selector);
87
78
  if ('locator' in resolved) {
88
79
  await resolved.locator.click({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
@@ -175,7 +166,7 @@ export async function handleWriteCommand(
175
166
 
176
167
  case 'wait': {
177
168
  const selector = args[0];
178
- if (!selector) throw new Error('Usage: browse wait <selector|--url|--network-idle> [timeout]');
169
+ if (!selector) throw new Error('Usage: browse wait <selector|ms|--url|--text|--fn|--load|--network-idle> [args]');
179
170
 
180
171
  // wait --network-idle [timeout] — wait for network to settle
181
172
  if (selector === '--network-idle') {
@@ -193,14 +184,55 @@ export async function handleWriteCommand(
193
184
  return `URL matched: ${page.url()}`;
194
185
  }
195
186
 
196
- const timeout = args[1] ? parseInt(args[1], 10) : DEFAULTS.COMMAND_TIMEOUT_MS;
187
+ // wait --text "text" [timeout] wait for text to appear in page body
188
+ if (selector === '--text') {
189
+ const text = args[1];
190
+ if (!text) throw new Error('Usage: browse wait --text <text> [timeout]');
191
+ const timeout = args[2] ? parseInt(args[2], 10) : DEFAULTS.COMMAND_TIMEOUT_MS;
192
+ await page.waitForFunction((t) => document.body.innerText.includes(t), text, { timeout });
193
+ return `Text found: "${text}"`;
194
+ }
195
+
196
+ // wait --fn "expression" [timeout] — wait for JS condition to be truthy
197
+ if (selector === '--fn') {
198
+ const expr = args[1];
199
+ if (!expr) throw new Error('Usage: browse wait --fn <js-expression> [timeout]');
200
+ const timeout = args[2] ? parseInt(args[2], 10) : DEFAULTS.COMMAND_TIMEOUT_MS;
201
+ await page.waitForFunction(expr, undefined, { timeout });
202
+ return `Condition met: ${expr}`;
203
+ }
204
+
205
+ // wait --load <state> [timeout] — wait for load state
206
+ if (selector === '--load') {
207
+ const state = args[1] as 'load' | 'domcontentloaded' | 'networkidle';
208
+ if (!state || !['load', 'domcontentloaded', 'networkidle'].includes(state)) {
209
+ throw new Error('Usage: browse wait --load <load|domcontentloaded|networkidle> [timeout]');
210
+ }
211
+ const timeout = args[2] ? parseInt(args[2], 10) : DEFAULTS.COMMAND_TIMEOUT_MS;
212
+ await page.waitForLoadState(state, { timeout });
213
+ return `Load state reached: ${state}`;
214
+ }
215
+
216
+ // wait <ms> — wait for milliseconds (numeric first arg)
217
+ if (/^\d+$/.test(selector)) {
218
+ const ms = parseInt(selector, 10);
219
+ await page.waitForTimeout(ms);
220
+ return `Waited ${ms}ms`;
221
+ }
222
+
223
+ // wait <sel> [--state hidden] [timeout] — wait for element
224
+ const stateIdx = args.indexOf('--state');
225
+ const state = stateIdx >= 0 ? args[stateIdx + 1] as 'visible' | 'hidden' | 'attached' | 'detached' : 'visible';
226
+ const timeoutArgs = args.filter((a, i) => i !== 0 && a !== '--state' && (stateIdx < 0 || i !== stateIdx + 1));
227
+ const timeout = timeoutArgs[0] ? parseInt(timeoutArgs[0], 10) : DEFAULTS.COMMAND_TIMEOUT_MS;
228
+
197
229
  const resolved = bm.resolveRef(selector);
198
230
  if ('locator' in resolved) {
199
- await resolved.locator.waitFor({ state: 'visible', timeout });
231
+ await resolved.locator.waitFor({ state, timeout });
200
232
  } else {
201
- await page.waitForSelector(resolved.selector, { timeout });
233
+ await page.waitForSelector(resolved.selector, { state, timeout });
202
234
  }
203
- return `Element ${selector} appeared`;
235
+ return state === 'hidden' ? `Element ${selector} hidden` : `Element ${selector} appeared`;
204
236
  }
205
237
 
206
238
  case 'viewport': {
@@ -213,7 +245,54 @@ export async function handleWriteCommand(
213
245
 
214
246
  case 'cookie': {
215
247
  const cookieStr = args[0];
216
- if (!cookieStr || !cookieStr.includes('=')) throw new Error('Usage: browse cookie <name>=<value>');
248
+ if (!cookieStr) throw new Error('Usage: browse cookie <name>=<value> | cookie clear | cookie set <n> <v> [opts] | cookie export <file> | cookie import <file>');
249
+
250
+ // cookie clear — remove all cookies
251
+ if (cookieStr === 'clear') {
252
+ await page.context().clearCookies();
253
+ return 'All cookies cleared';
254
+ }
255
+
256
+ // cookie export <file> — save cookies to JSON file
257
+ if (cookieStr === 'export') {
258
+ const file = args[1];
259
+ if (!file) throw new Error('Usage: browse cookie export <file>');
260
+ const cookies = await page.context().cookies();
261
+ fs.writeFileSync(file, JSON.stringify(cookies, null, 2));
262
+ return `Exported ${cookies.length} cookie(s) to ${file}`;
263
+ }
264
+
265
+ // cookie import <file> — load cookies from JSON file
266
+ if (cookieStr === 'import') {
267
+ const file = args[1];
268
+ if (!file) throw new Error('Usage: browse cookie import <file>');
269
+ if (!fs.existsSync(file)) throw new Error(`File not found: ${file}`);
270
+ const cookies = JSON.parse(fs.readFileSync(file, 'utf-8'));
271
+ if (!Array.isArray(cookies)) throw new Error('Cookie file must contain a JSON array of cookie objects');
272
+ await page.context().addCookies(cookies);
273
+ return `Imported ${cookies.length} cookie(s) from ${file}`;
274
+ }
275
+
276
+ // cookie set <name> <value> [--domain <d>] [--secure] [--expires <ts>] [--sameSite <s>]
277
+ if (cookieStr === 'set') {
278
+ const name = args[1];
279
+ const value = args[2];
280
+ if (!name || !value) throw new Error('Usage: browse cookie set <name> <value> [--domain <d>] [--secure] [--expires <ts>] [--sameSite <s>]');
281
+ const url = new URL(page.url());
282
+ const cookie: any = { name, value, domain: url.hostname, path: '/' };
283
+ for (let i = 3; i < args.length; i++) {
284
+ if (args[i] === '--domain' && args[i + 1]) { cookie.domain = args[++i]; }
285
+ else if (args[i] === '--secure') { cookie.secure = true; }
286
+ else if (args[i] === '--expires' && args[i + 1]) { cookie.expires = parseInt(args[++i], 10); }
287
+ else if (args[i] === '--sameSite' && args[i + 1]) { cookie.sameSite = args[++i]; }
288
+ else if (args[i] === '--path' && args[i + 1]) { cookie.path = args[++i]; }
289
+ }
290
+ await page.context().addCookies([cookie]);
291
+ return `Cookie set: ${name}=${value}${cookie.domain !== url.hostname ? ` (domain: ${cookie.domain})` : ''}`;
292
+ }
293
+
294
+ // Legacy: cookie <name>=<value>
295
+ if (!cookieStr.includes('=')) throw new Error('Usage: browse cookie <name>=<value> | cookie clear | cookie set <n> <v> [opts] | cookie export <file> | cookie import <file>');
217
296
  const eq = cookieStr.indexOf('=');
218
297
  const name = cookieStr.slice(0, eq);
219
298
  const value = cookieStr.slice(eq + 1);
@@ -436,6 +515,147 @@ export async function handleWriteCommand(
436
515
  return `Offline mode: ${newState ? 'ON' : 'OFF'}`;
437
516
  }
438
517
 
518
+ case 'rightclick': {
519
+ const selector = args[0];
520
+ if (!selector) throw new Error('Usage: browse rightclick <selector>');
521
+ const resolved = bm.resolveRef(selector);
522
+ if ('locator' in resolved) {
523
+ await resolved.locator.click({ button: 'right', timeout: DEFAULTS.ACTION_TIMEOUT_MS });
524
+ } else {
525
+ await page.click(resolved.selector, { button: 'right', timeout: DEFAULTS.ACTION_TIMEOUT_MS });
526
+ }
527
+ return `Right-clicked ${selector}`;
528
+ }
529
+
530
+ case 'tap': {
531
+ const selector = args[0];
532
+ if (!selector) throw new Error('Usage: browse tap <selector>');
533
+ const resolved = bm.resolveRef(selector);
534
+ try {
535
+ if ('locator' in resolved) {
536
+ await resolved.locator.tap({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
537
+ } else {
538
+ await page.locator(resolved.selector).tap({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
539
+ }
540
+ } catch (err: any) {
541
+ if (err.message?.includes('hasTouch') || err.message?.includes('touch')) {
542
+ throw new Error(
543
+ `Tap requires a touch-enabled context. Run 'browse emulate "iPhone 14"' (or any mobile device) first to enable touch.`
544
+ );
545
+ }
546
+ throw err;
547
+ }
548
+ return `Tapped ${selector}`;
549
+ }
550
+
551
+ case 'swipe': {
552
+ const dir = args[0];
553
+ if (!dir || !['up', 'down', 'left', 'right'].includes(dir)) {
554
+ throw new Error('Usage: browse swipe <up|down|left|right> [pixels]');
555
+ }
556
+ const distance = args[1] ? parseInt(args[1], 10) : undefined;
557
+ const vp = page.viewportSize() || { width: 1920, height: 1080 };
558
+ const cx = Math.floor(vp.width / 2);
559
+ const cy = Math.floor(vp.height / 2);
560
+ const dx = dir === 'left' ? -(distance || vp.width * 0.7) : dir === 'right' ? (distance || vp.width * 0.7) : 0;
561
+ const dy = dir === 'up' ? -(distance || vp.height * 0.7) : dir === 'down' ? (distance || vp.height * 0.7) : 0;
562
+ // Use evaluate to dispatch synthetic touch events for maximum compatibility
563
+ await page.evaluate(({ cx, cy, dx, dy }) => {
564
+ const start = new Touch({ identifier: 1, target: document.elementFromPoint(cx, cy) || document.body, clientX: cx, clientY: cy });
565
+ const end = new Touch({ identifier: 1, target: document.elementFromPoint(cx, cy) || document.body, clientX: cx + dx, clientY: cy + dy });
566
+ const el = document.elementFromPoint(cx, cy) || document.body;
567
+ el.dispatchEvent(new TouchEvent('touchstart', { touches: [start], changedTouches: [start], bubbles: true }));
568
+ el.dispatchEvent(new TouchEvent('touchmove', { touches: [end], changedTouches: [end], bubbles: true }));
569
+ el.dispatchEvent(new TouchEvent('touchend', { touches: [], changedTouches: [end], bubbles: true }));
570
+ }, { cx, cy, dx, dy });
571
+ return `Swiped ${dir}${distance ? ` ${distance}px` : ''}`;
572
+ }
573
+
574
+ case 'mouse': {
575
+ const sub = args[0];
576
+ if (!sub) throw new Error('Usage: browse mouse <move|down|up|wheel> [args]\n move <x> <y>\n down [left|right|middle]\n up [left|right|middle]\n wheel <dy> [dx]');
577
+ switch (sub) {
578
+ case 'move': {
579
+ const x = parseInt(args[1], 10);
580
+ const y = parseInt(args[2], 10);
581
+ if (isNaN(x) || isNaN(y)) throw new Error('Usage: browse mouse move <x> <y>');
582
+ await page.mouse.move(x, y);
583
+ return `Mouse moved to ${x},${y}`;
584
+ }
585
+ case 'down': {
586
+ const button = (args[1] || 'left') as 'left' | 'right' | 'middle';
587
+ await page.mouse.down({ button });
588
+ return `Mouse down (${button})`;
589
+ }
590
+ case 'up': {
591
+ const button = (args[1] || 'left') as 'left' | 'right' | 'middle';
592
+ await page.mouse.up({ button });
593
+ return `Mouse up (${button})`;
594
+ }
595
+ case 'wheel': {
596
+ const dy = parseInt(args[1], 10);
597
+ const dx = args[2] ? parseInt(args[2], 10) : 0;
598
+ if (isNaN(dy)) throw new Error('Usage: browse mouse wheel <dy> [dx]');
599
+ await page.mouse.wheel(dx, dy);
600
+ return `Mouse wheel dx=${dx} dy=${dy}`;
601
+ }
602
+ default:
603
+ throw new Error(`Unknown mouse subcommand: ${sub}. Use move|down|up|wheel`);
604
+ }
605
+ }
606
+
607
+ case 'keyboard': {
608
+ const sub = args[0];
609
+ if (!sub) throw new Error('Usage: browse keyboard <inserttext> <text>');
610
+ if (sub === 'inserttext' || sub === 'insertText') {
611
+ const text = args.slice(1).join(' ');
612
+ if (!text) throw new Error('Usage: browse keyboard inserttext <text>');
613
+ await page.keyboard.insertText(text);
614
+ return `Inserted text: "${text}"`;
615
+ }
616
+ throw new Error(`Unknown keyboard subcommand: ${sub}. Use inserttext`);
617
+ }
618
+
619
+ case 'scrollinto':
620
+ case 'scrollintoview': {
621
+ const selector = args[0];
622
+ if (!selector) throw new Error('Usage: browse scrollinto <selector>');
623
+ const resolved = bm.resolveRef(selector);
624
+ if ('locator' in resolved) {
625
+ await resolved.locator.scrollIntoViewIfNeeded({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
626
+ } else {
627
+ await page.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
628
+ }
629
+ return `Scrolled ${selector} into view`;
630
+ }
631
+
632
+ case 'set': {
633
+ const sub = args[0];
634
+ if (!sub) throw new Error('Usage: browse set <geo|media> [args]\n geo <lat> <lng>\n media <dark|light|no-preference>');
635
+ switch (sub) {
636
+ case 'geo': {
637
+ const lat = parseFloat(args[1]);
638
+ const lng = parseFloat(args[2]);
639
+ if (isNaN(lat) || isNaN(lng)) throw new Error('Usage: browse set geo <latitude> <longitude>');
640
+ const context = bm.getContext();
641
+ if (!context) throw new Error('No browser context');
642
+ await context.grantPermissions(['geolocation']);
643
+ await context.setGeolocation({ latitude: lat, longitude: lng });
644
+ return `Geolocation set to ${lat}, ${lng}`;
645
+ }
646
+ case 'media': {
647
+ const scheme = args[1];
648
+ if (!scheme || !['dark', 'light', 'no-preference'].includes(scheme)) {
649
+ throw new Error('Usage: browse set media <dark|light|no-preference>');
650
+ }
651
+ await page.emulateMedia({ colorScheme: scheme as 'dark' | 'light' | 'no-preference' });
652
+ return `Color scheme set to ${scheme}`;
653
+ }
654
+ default:
655
+ throw new Error(`Unknown set subcommand: ${sub}. Use geo|media`);
656
+ }
657
+ }
658
+
439
659
  case 'route': {
440
660
  // route <pattern> block — abort matching requests
441
661
  // route <pattern> fulfill <status> [body] — respond with custom data
package/src/config.ts CHANGED
@@ -14,7 +14,6 @@ export interface BrowseConfig {
14
14
  idleTimeout?: number;
15
15
  viewport?: string;
16
16
  device?: string;
17
- runtime?: string;
18
17
  }
19
18
 
20
19
  /**