@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.
- package/LICENSE +1 -1
- package/README.md +449 -283
- package/package.json +1 -1
- package/skill/SKILL.md +113 -5
- package/src/auth-vault.ts +4 -52
- package/src/browser-manager.ts +20 -5
- package/src/bun.d.ts +15 -20
- package/src/chrome-discover.ts +73 -0
- package/src/cli.ts +110 -10
- package/src/commands/meta.ts +247 -9
- package/src/commands/read.ts +28 -0
- package/src/commands/write.ts +236 -16
- package/src/config.ts +0 -1
- package/src/cookie-import.ts +410 -0
- package/src/encryption.ts +48 -0
- package/src/record-export.ts +98 -0
- package/src/server.ts +43 -2
- package/src/session-manager.ts +48 -0
- package/src/session-persist.ts +192 -0
package/src/commands/write.ts
CHANGED
|
@@ -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
|
|
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> [
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|