@ulpi/browse 0.10.0 → 1.0.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.
@@ -1,704 +0,0 @@
1
- /**
2
- * Write commands — navigate and interact with pages (side effects)
3
- *
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
7
- */
8
-
9
- import type { BrowserContext } from 'playwright';
10
- import type { BrowserManager } from '../browser-manager';
11
- import { resolveDevice, listDevices } from '../browser-manager';
12
- import type { DomainFilter } from '../domain-filter';
13
- import { DEFAULTS } from '../constants';
14
- import * as fs from 'fs';
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
-
39
- export async function handleWriteCommand(
40
- command: string,
41
- args: string[],
42
- bm: BrowserManager,
43
- domainFilter?: DomainFilter | null
44
- ): Promise<string> {
45
- const page = bm.getPage();
46
-
47
- switch (command) {
48
- case 'goto': {
49
- const url = args[0];
50
- if (!url) throw new Error('Usage: browse goto <url>');
51
- if (domainFilter && !domainFilter.isAllowed(url)) {
52
- throw new Error(domainFilter.blockedMessage(url));
53
- }
54
- const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: DEFAULTS.COMMAND_TIMEOUT_MS });
55
- const status = response?.status() || 'unknown';
56
- return `Navigated to ${url} (${status})`;
57
- }
58
-
59
- case 'back': {
60
- await page.goBack({ waitUntil: 'domcontentloaded', timeout: DEFAULTS.COMMAND_TIMEOUT_MS });
61
- return `Back → ${page.url()}`;
62
- }
63
-
64
- case 'forward': {
65
- await page.goForward({ waitUntil: 'domcontentloaded', timeout: DEFAULTS.COMMAND_TIMEOUT_MS });
66
- return `Forward → ${page.url()}`;
67
- }
68
-
69
- case 'reload': {
70
- await page.reload({ waitUntil: 'domcontentloaded', timeout: DEFAULTS.COMMAND_TIMEOUT_MS });
71
- return `Reloaded ${page.url()}`;
72
- }
73
-
74
- case 'click': {
75
- const selector = args[0];
76
- if (!selector) throw new Error('Usage: browse click <selector>');
77
- const resolved = bm.resolveRef(selector);
78
- if ('locator' in resolved) {
79
- await resolved.locator.click({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
80
- } else {
81
- await page.click(resolved.selector, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
82
- }
83
- // Wait briefly for any navigation/DOM update
84
- await page.waitForLoadState('domcontentloaded').catch(() => {});
85
- return `Clicked ${selector} → now at ${page.url()}`;
86
- }
87
-
88
- case 'fill': {
89
- const [selector, ...valueParts] = args;
90
- const value = valueParts.join(' ');
91
- if (!selector) throw new Error('Usage: browse fill <selector> <value>');
92
- const resolved = bm.resolveRef(selector);
93
- if ('locator' in resolved) {
94
- await resolved.locator.fill(value, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
95
- } else {
96
- await page.fill(resolved.selector, value, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
97
- }
98
- return `Filled ${selector}`;
99
- }
100
-
101
- case 'select': {
102
- const [selector, ...valueParts] = args;
103
- const value = valueParts.join(' ');
104
- if (!selector) throw new Error('Usage: browse select <selector> <value>');
105
- const resolved = bm.resolveRef(selector);
106
- if ('locator' in resolved) {
107
- await resolved.locator.selectOption(value, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
108
- } else {
109
- await page.selectOption(resolved.selector, value, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
110
- }
111
- return `Selected "${value}" in ${selector}`;
112
- }
113
-
114
- case 'hover': {
115
- const selector = args[0];
116
- if (!selector) throw new Error('Usage: browse hover <selector>');
117
- const resolved = bm.resolveRef(selector);
118
- if ('locator' in resolved) {
119
- await resolved.locator.hover({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
120
- } else {
121
- await page.hover(resolved.selector, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
122
- }
123
- return `Hovered ${selector}`;
124
- }
125
-
126
- case 'type': {
127
- const text = args.join(' ');
128
- if (!text) throw new Error('Usage: browse type <text>');
129
- await page.keyboard.type(text);
130
- return `Typed "${text}"`;
131
- }
132
-
133
- case 'press': {
134
- const key = args[0];
135
- if (!key) throw new Error('Usage: browse press <key> (e.g., Enter, Tab, Escape)');
136
- await page.keyboard.press(key);
137
- return `Pressed ${key}`;
138
- }
139
-
140
- case 'scroll': {
141
- const selector = args[0];
142
- if (selector === 'up') {
143
- const scrollCtx = await bm.getFrameContext() || page;
144
- await scrollCtx.evaluate(() => window.scrollBy(0, -window.innerHeight));
145
- return 'Scrolled up one viewport';
146
- }
147
- if (selector === 'down') {
148
- const scrollCtx = await bm.getFrameContext() || page;
149
- await scrollCtx.evaluate(() => window.scrollBy(0, window.innerHeight));
150
- return 'Scrolled down one viewport';
151
- }
152
- if (selector && selector !== 'bottom') {
153
- const resolved = bm.resolveRef(selector);
154
- if ('locator' in resolved) {
155
- await resolved.locator.scrollIntoViewIfNeeded({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
156
- } else {
157
- await page.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
158
- }
159
- return `Scrolled ${selector} into view`;
160
- }
161
- // Scroll to bottom (default or explicit "bottom")
162
- const scrollCtx = await bm.getFrameContext() || page;
163
- await scrollCtx.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
164
- return 'Scrolled to bottom';
165
- }
166
-
167
- case 'wait': {
168
- const selector = args[0];
169
- if (!selector) throw new Error('Usage: browse wait <selector|ms|--url|--text|--fn|--load|--network-idle> [args]');
170
-
171
- // wait --network-idle [timeout] — wait for network to settle
172
- if (selector === '--network-idle') {
173
- const timeout = args[1] ? parseInt(args[1], 10) : DEFAULTS.COMMAND_TIMEOUT_MS;
174
- await page.waitForLoadState('networkidle', { timeout });
175
- return 'Network idle';
176
- }
177
-
178
- // wait --url <pattern> [timeout] — wait for URL to match
179
- if (selector === '--url') {
180
- const pattern = args[1];
181
- if (!pattern) throw new Error('Usage: browse wait --url <pattern> [timeout]');
182
- const timeout = args[2] ? parseInt(args[2], 10) : DEFAULTS.COMMAND_TIMEOUT_MS;
183
- await page.waitForURL(pattern, { timeout });
184
- return `URL matched: ${page.url()}`;
185
- }
186
-
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
-
229
- const resolved = bm.resolveRef(selector);
230
- if ('locator' in resolved) {
231
- await resolved.locator.waitFor({ state, timeout });
232
- } else {
233
- await page.waitForSelector(resolved.selector, { state, timeout });
234
- }
235
- return state === 'hidden' ? `Element ${selector} hidden` : `Element ${selector} appeared`;
236
- }
237
-
238
- case 'viewport': {
239
- const size = args[0];
240
- if (!size || !size.includes('x')) throw new Error('Usage: browse viewport <WxH> (e.g., 375x812)');
241
- const [w, h] = size.split('x').map(Number);
242
- await bm.setViewport(w, h);
243
- return `Viewport set to ${w}x${h}`;
244
- }
245
-
246
- case 'cookie': {
247
- const cookieStr = args[0];
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>');
296
- const eq = cookieStr.indexOf('=');
297
- const name = cookieStr.slice(0, eq);
298
- const value = cookieStr.slice(eq + 1);
299
- const url = new URL(page.url());
300
- await page.context().addCookies([{
301
- name,
302
- value,
303
- domain: url.hostname,
304
- path: '/',
305
- }]);
306
- return `Cookie set: ${name}=${value}`;
307
- }
308
-
309
- case 'header': {
310
- const headerStr = args[0];
311
- if (!headerStr || !headerStr.includes(':')) throw new Error('Usage: browse header <name>:<value>');
312
- const sep = headerStr.indexOf(':');
313
- const name = headerStr.slice(0, sep).trim();
314
- const value = headerStr.slice(sep + 1).trim();
315
- await bm.setExtraHeader(name, value);
316
- return `Header set: ${name}: ${value}`;
317
- }
318
-
319
- case 'useragent': {
320
- const ua = args.join(' ');
321
- if (!ua) throw new Error('Usage: browse useragent <string>');
322
- const prevUA = bm.getUserAgent();
323
- bm.setUserAgent(ua);
324
- try {
325
- await bm.applyUserAgent();
326
- } catch (err) {
327
- // Rollback: restore previous UA so stored state matches the live context
328
- bm.setUserAgent(prevUA || '');
329
- throw err;
330
- }
331
- return `User agent set: ${ua}\nNote: Cookies and tab URLs preserved. localStorage/sessionStorage were reset (Playwright limitation).`;
332
- }
333
-
334
- case 'upload': {
335
- const [selector, ...filePaths] = args;
336
- if (!selector || filePaths.length === 0) throw new Error('Usage: browse upload <selector> <file1> [file2] ...');
337
- for (const fp of filePaths) {
338
- if (!fs.existsSync(fp)) throw new Error(`File not found: ${fp}`);
339
- }
340
- const resolved = bm.resolveRef(selector);
341
- if ('locator' in resolved) {
342
- await resolved.locator.setInputFiles(filePaths, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
343
- } else {
344
- await page.locator(resolved.selector).setInputFiles(filePaths, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
345
- }
346
- return `Uploaded ${filePaths.length} file(s) to ${selector}`;
347
- }
348
-
349
- case 'dialog-accept': {
350
- const value = args.join(' ') || undefined;
351
- bm.setAutoDialogAction('accept', value);
352
- return `Dialog auto-action set to: accept${value ? ` (with value: "${value}")` : ''}`;
353
- }
354
-
355
- case 'dialog-dismiss': {
356
- bm.setAutoDialogAction('dismiss');
357
- return 'Dialog auto-action set to: dismiss';
358
- }
359
-
360
- case 'emulate': {
361
- const deviceName = args.join(' ');
362
- if (!deviceName) {
363
- throw new Error(
364
- 'Usage: browse emulate <device>\n' +
365
- 'Examples: browse emulate iPhone 15, browse emulate Pixel 7, browse emulate reset\n' +
366
- 'Run "browse devices" to see all available devices.'
367
- );
368
- }
369
-
370
- // Reset to desktop
371
- if (deviceName.toLowerCase() === 'reset' || deviceName.toLowerCase() === 'desktop') {
372
- await bm.emulateDevice(null);
373
- return 'Device emulation reset to desktop (1920x1080)';
374
- }
375
-
376
- const device = resolveDevice(deviceName);
377
- if (!device) {
378
- // Find close matches
379
- const all = listDevices();
380
- const lower = deviceName.toLowerCase();
381
- const suggestions = all.filter(d => d.toLowerCase().includes(lower)).slice(0, 5);
382
- throw new Error(
383
- `Unknown device: "${deviceName}"\n` +
384
- (suggestions.length > 0
385
- ? `Did you mean: ${suggestions.join(', ')}?\n`
386
- : '') +
387
- 'Run "browse devices" to see all available devices.'
388
- );
389
- }
390
-
391
- await bm.emulateDevice(device);
392
- return [
393
- `Emulating: ${deviceName}`,
394
- ` Viewport: ${device.viewport.width}x${device.viewport.height}`,
395
- ` Scale: ${device.deviceScaleFactor}x`,
396
- ` Mobile: ${device.isMobile}`,
397
- ` Touch: ${device.hasTouch}`,
398
- ` UA: ${device.userAgent.slice(0, 80)}...`,
399
- 'Note: Cookies and tab URLs preserved. localStorage/sessionStorage were reset (Playwright limitation).',
400
- ].join('\n');
401
- }
402
-
403
- case 'dblclick': {
404
- const selector = args[0];
405
- if (!selector) throw new Error('Usage: browse dblclick <selector>');
406
- const resolved = bm.resolveRef(selector);
407
- if ('locator' in resolved) {
408
- await resolved.locator.dblclick({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
409
- } else {
410
- await page.dblclick(resolved.selector, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
411
- }
412
- return `Double-clicked ${selector}`;
413
- }
414
-
415
- case 'focus': {
416
- const selector = args[0];
417
- if (!selector) throw new Error('Usage: browse focus <selector>');
418
- const resolved = bm.resolveRef(selector);
419
- if ('locator' in resolved) {
420
- await resolved.locator.focus({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
421
- } else {
422
- await page.locator(resolved.selector).focus({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
423
- }
424
- return `Focused ${selector}`;
425
- }
426
-
427
- case 'check': {
428
- const selector = args[0];
429
- if (!selector) throw new Error('Usage: browse check <selector>');
430
- const resolved = bm.resolveRef(selector);
431
- if ('locator' in resolved) {
432
- await resolved.locator.check({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
433
- } else {
434
- await page.locator(resolved.selector).check({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
435
- }
436
- return `Checked ${selector}`;
437
- }
438
-
439
- case 'uncheck': {
440
- const selector = args[0];
441
- if (!selector) throw new Error('Usage: browse uncheck <selector>');
442
- const resolved = bm.resolveRef(selector);
443
- if ('locator' in resolved) {
444
- await resolved.locator.uncheck({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
445
- } else {
446
- await page.locator(resolved.selector).uncheck({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
447
- }
448
- return `Unchecked ${selector}`;
449
- }
450
-
451
- case 'drag': {
452
- const [srcSel, tgtSel] = args;
453
- if (!srcSel || !tgtSel) throw new Error('Usage: browse drag <source> <target>');
454
- const srcResolved = bm.resolveRef(srcSel);
455
- const tgtResolved = bm.resolveRef(tgtSel);
456
- const srcLocator = 'locator' in srcResolved ? srcResolved.locator : page.locator(srcResolved.selector);
457
- const tgtLocator = 'locator' in tgtResolved ? tgtResolved.locator : page.locator(tgtResolved.selector);
458
- await srcLocator.dragTo(tgtLocator, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
459
- return `Dragged ${srcSel} to ${tgtSel}`;
460
- }
461
-
462
- case 'keydown': {
463
- const key = args[0];
464
- if (!key) throw new Error('Usage: browse keydown <key>');
465
- await page.keyboard.down(key);
466
- return `Key down: ${key}`;
467
- }
468
-
469
- case 'keyup': {
470
- const key = args[0];
471
- if (!key) throw new Error('Usage: browse keyup <key>');
472
- await page.keyboard.up(key);
473
- return `Key up: ${key}`;
474
- }
475
-
476
- case 'highlight': {
477
- const selector = args[0];
478
- if (!selector) throw new Error('Usage: browse highlight <selector>');
479
- const resolved = bm.resolveRef(selector);
480
- const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
481
- await locator.evaluate((el) => {
482
- el.style.outline = '3px solid #e11d48';
483
- el.style.outlineOffset = '2px';
484
- });
485
- return `Highlighted ${selector}`;
486
- }
487
-
488
- case 'download': {
489
- const [selector, savePath] = args;
490
- if (!selector) throw new Error('Usage: browse download <selector> [path]');
491
- const resolved = bm.resolveRef(selector);
492
- const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
493
- const [download] = await Promise.all([
494
- page.waitForEvent('download', { timeout: DEFAULTS.COMMAND_TIMEOUT_MS }),
495
- locator.click({ timeout: DEFAULTS.ACTION_TIMEOUT_MS }),
496
- ]);
497
- const finalPath = savePath || download.suggestedFilename();
498
- await download.saveAs(finalPath);
499
- return `Downloaded: ${finalPath}`;
500
- }
501
-
502
- case 'offline': {
503
- const mode = args[0];
504
- if (mode === 'on') {
505
- await bm.setOffline(true);
506
- return 'Offline mode: ON';
507
- }
508
- if (mode === 'off') {
509
- await bm.setOffline(false);
510
- return 'Offline mode: OFF';
511
- }
512
- // Toggle
513
- const newState = !bm.isOffline();
514
- await bm.setOffline(newState);
515
- return `Offline mode: ${newState ? 'ON' : 'OFF'}`;
516
- }
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
-
659
- case 'route': {
660
- // route <pattern> block — abort matching requests
661
- // route <pattern> fulfill <status> [body] — respond with custom data
662
- // route clear — remove all routes
663
- const pattern = args[0];
664
- if (!pattern) throw new Error('Usage: browse route <url-pattern> block | browse route <url-pattern> fulfill <status> [body] | browse route clear');
665
-
666
- const context = bm.getContext();
667
- if (!context) throw new Error('No browser context');
668
-
669
- if (pattern === 'clear') {
670
- await context.unrouteAll();
671
- bm.clearUserRoutes();
672
- // Re-apply domain filter route if active
673
- if (domainFilter) {
674
- await context.route('**/*', (route) => {
675
- const url = route.request().url();
676
- if (domainFilter!.isAllowed(url)) { route.fallback(); } else { route.abort('blockedbyclient'); }
677
- });
678
- }
679
- return domainFilter ? 'All routes cleared (domain filter preserved)' : 'All routes cleared';
680
- }
681
-
682
- const action = args[1] || 'block';
683
-
684
- if (action === 'block') {
685
- bm.addUserRoute(pattern, 'block');
686
- await rebuildRoutes(context, bm, domainFilter);
687
- return `Blocking requests matching: ${pattern}`;
688
- }
689
-
690
- if (action === 'fulfill') {
691
- const status = parseInt(args[2] || '200', 10);
692
- const body = args[3] || '';
693
- bm.addUserRoute(pattern, 'fulfill', status, body);
694
- await rebuildRoutes(context, bm, domainFilter);
695
- return `Mocking requests matching: ${pattern} → ${status}${body ? ` "${body}"` : ''}`;
696
- }
697
-
698
- throw new Error('Usage: browse route <pattern> block | browse route <pattern> fulfill <status> [body]');
699
- }
700
-
701
- default:
702
- throw new Error(`Unknown write command: ${command}`);
703
- }
704
- }