@ulpi/browse 0.7.5 → 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 +444 -300
- 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/cli.ts
CHANGED
|
@@ -20,8 +20,13 @@ const cliFlags = {
|
|
|
20
20
|
contentBoundaries: false,
|
|
21
21
|
allowedDomains: '' as string,
|
|
22
22
|
headed: false,
|
|
23
|
+
stateFile: '' as string,
|
|
24
|
+
maxOutput: 0,
|
|
23
25
|
};
|
|
24
26
|
|
|
27
|
+
// Track whether --state has been applied (only sent on first command)
|
|
28
|
+
let stateFileApplied = false;
|
|
29
|
+
|
|
25
30
|
const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
|
|
26
31
|
// One server per project directory by default. Sessions handle agent isolation.
|
|
27
32
|
// For multiple servers on the same project: set BROWSE_INSTANCE or BROWSE_PORT.
|
|
@@ -376,7 +381,8 @@ export const SAFE_TO_RETRY = new Set([
|
|
|
376
381
|
'css', 'attrs', 'element-state', 'dialog',
|
|
377
382
|
'console', 'network', 'cookies', 'perf', 'value', 'count',
|
|
378
383
|
// Meta commands that are read-only or idempotent
|
|
379
|
-
'tabs', 'status', 'url', 'snapshot', 'snapshot-diff', 'devices', 'sessions', 'frame', 'find',
|
|
384
|
+
'tabs', 'status', 'url', 'snapshot', 'snapshot-diff', 'devices', 'sessions', 'frame', 'find', 'record', 'cookie-import',
|
|
385
|
+
'box', 'errors', 'doctor', 'upgrade',
|
|
380
386
|
]);
|
|
381
387
|
|
|
382
388
|
// Commands that return static data independent of page state.
|
|
@@ -402,6 +408,13 @@ async function sendCommand(state: ServerState, command: string, args: string[],
|
|
|
402
408
|
if (cliFlags.allowedDomains) {
|
|
403
409
|
headers['X-Browse-Allowed-Domains'] = cliFlags.allowedDomains;
|
|
404
410
|
}
|
|
411
|
+
if (cliFlags.stateFile && !stateFileApplied) {
|
|
412
|
+
headers['X-Browse-State'] = cliFlags.stateFile;
|
|
413
|
+
stateFileApplied = true;
|
|
414
|
+
}
|
|
415
|
+
if (cliFlags.maxOutput > 0) {
|
|
416
|
+
headers['X-Browse-Max-Output'] = String(cliFlags.maxOutput);
|
|
417
|
+
}
|
|
405
418
|
|
|
406
419
|
try {
|
|
407
420
|
const resp = await fetch(`http://127.0.0.1:${state.port}/command`, {
|
|
@@ -511,7 +524,7 @@ export async function main() {
|
|
|
511
524
|
for (let i = 0; i < a.length; i++) {
|
|
512
525
|
if (!a[i].startsWith('-')) return i;
|
|
513
526
|
// Skip flag values for known value-flags
|
|
514
|
-
if (a[i] === '--session' || a[i] === '--allowed-domains') i++;
|
|
527
|
+
if (a[i] === '--session' || a[i] === '--allowed-domains' || a[i] === '--cdp' || a[i] === '--state') i++;
|
|
515
528
|
}
|
|
516
529
|
return a.length;
|
|
517
530
|
}
|
|
@@ -569,11 +582,79 @@ export async function main() {
|
|
|
569
582
|
}
|
|
570
583
|
headed = headed || process.env.BROWSE_HEADED === '1';
|
|
571
584
|
|
|
585
|
+
// Extract --connect flag (only before command)
|
|
586
|
+
let connectFlag = false;
|
|
587
|
+
const connectIdx = args.indexOf('--connect');
|
|
588
|
+
if (connectIdx !== -1 && connectIdx < findCommandIndex(args)) {
|
|
589
|
+
connectFlag = true;
|
|
590
|
+
args.splice(connectIdx, 1);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Extract --cdp <port> flag (only before command)
|
|
594
|
+
let cdpPort: number | undefined;
|
|
595
|
+
const cdpIdx = args.indexOf('--cdp');
|
|
596
|
+
if (cdpIdx !== -1 && cdpIdx < findCommandIndex(args)) {
|
|
597
|
+
const portStr = args[cdpIdx + 1];
|
|
598
|
+
if (!portStr || portStr.startsWith('-')) {
|
|
599
|
+
console.error('Usage: browse --cdp <port> <command> [args...]');
|
|
600
|
+
process.exit(1);
|
|
601
|
+
}
|
|
602
|
+
cdpPort = parseInt(portStr, 10);
|
|
603
|
+
if (!Number.isFinite(cdpPort) || cdpPort <= 0) {
|
|
604
|
+
console.error(`Invalid CDP port: ${portStr}`);
|
|
605
|
+
process.exit(1);
|
|
606
|
+
}
|
|
607
|
+
args.splice(cdpIdx, 2);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Extract --state <path> flag (only before command)
|
|
611
|
+
let stateFile = '';
|
|
612
|
+
const stateIdx = args.indexOf('--state');
|
|
613
|
+
if (stateIdx !== -1 && stateIdx < findCommandIndex(args)) {
|
|
614
|
+
stateFile = args[stateIdx + 1] || '';
|
|
615
|
+
if (!stateFile || stateFile.startsWith('-')) {
|
|
616
|
+
console.error('Usage: browse --state <path> <command> [args...]');
|
|
617
|
+
process.exit(1);
|
|
618
|
+
}
|
|
619
|
+
args.splice(stateIdx, 2);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Handle --connect / --cdp: blocked by Bun WebSocket bug (oven-sh/bun#9911)
|
|
623
|
+
// Bun's compiled binary sends "Connection: keep-alive" instead of "Connection: Upgrade",
|
|
624
|
+
// breaking the WebSocket handshake required by Playwright's connectOverCDP().
|
|
625
|
+
// This affects all CDP-based connections (--connect, --cdp, lightpanda).
|
|
626
|
+
// The discovery and flag logic is ready — enable when Bun fixes the bug.
|
|
627
|
+
if (connectFlag || cdpPort) {
|
|
628
|
+
console.error(
|
|
629
|
+
'--connect/--cdp are not yet supported in the compiled binary.\n' +
|
|
630
|
+
'Bun\'s WebSocket client breaks the CDP handshake (oven-sh/bun#9911).\n' +
|
|
631
|
+
'Workaround: use cookie-import to borrow auth from your browser instead:\n' +
|
|
632
|
+
' browse cookie-import chrome --domain <your-domain>'
|
|
633
|
+
);
|
|
634
|
+
process.exit(1);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Extract --max-output <n> flag (only before command)
|
|
638
|
+
let maxOutput = 0;
|
|
639
|
+
const maxOutputIdx = args.indexOf('--max-output');
|
|
640
|
+
if (maxOutputIdx !== -1 && maxOutputIdx < findCommandIndex(args)) {
|
|
641
|
+
const val = args[maxOutputIdx + 1];
|
|
642
|
+
if (!val || val.startsWith('-')) {
|
|
643
|
+
console.error('Usage: browse --max-output <chars> <command> [args...]');
|
|
644
|
+
process.exit(1);
|
|
645
|
+
}
|
|
646
|
+
maxOutput = parseInt(val, 10);
|
|
647
|
+
args.splice(maxOutputIdx, 2);
|
|
648
|
+
}
|
|
649
|
+
maxOutput = maxOutput || parseInt(process.env.BROWSE_MAX_OUTPUT || '0', 10) || 0;
|
|
650
|
+
|
|
572
651
|
// Set global flags for sendCommand()
|
|
573
652
|
cliFlags.json = jsonMode;
|
|
574
653
|
cliFlags.contentBoundaries = contentBoundaries;
|
|
575
654
|
cliFlags.allowedDomains = allowedDomains || '';
|
|
576
655
|
cliFlags.headed = headed;
|
|
656
|
+
cliFlags.stateFile = stateFile;
|
|
657
|
+
cliFlags.maxOutput = maxOutput;
|
|
577
658
|
|
|
578
659
|
// ─── Local commands (no server needed) ─────────────────────
|
|
579
660
|
if (args[0] === 'version' || args[0] === '--version' || args[0] === '-V') {
|
|
@@ -600,34 +681,49 @@ Usage: browse [options] <command> [args...]
|
|
|
600
681
|
|
|
601
682
|
Navigation: goto <url> | back | forward | reload | url
|
|
602
683
|
Content: text | html [sel] | links | forms | accessibility
|
|
603
|
-
Interaction: click <sel> |
|
|
604
|
-
|
|
684
|
+
Interaction: click <sel> | rightclick <sel> | dblclick <sel>
|
|
685
|
+
fill <sel> <val> | select <sel> <val>
|
|
686
|
+
hover <sel> | focus <sel> | tap <sel>
|
|
605
687
|
check <sel> | uncheck <sel> | drag <src> <tgt>
|
|
606
688
|
type <text> | press <key> | keydown <key> | keyup <key>
|
|
607
|
-
|
|
689
|
+
keyboard inserttext <text>
|
|
690
|
+
scroll [sel|up|down] | scrollinto <sel>
|
|
691
|
+
swipe <up|down|left|right> [px]
|
|
692
|
+
wait <sel|ms|--url|--text|--fn|--load|--network-idle>
|
|
608
693
|
viewport <WxH> | highlight <sel> | download <sel> [path]
|
|
694
|
+
Mouse: mouse move <x> <y> | mouse down [btn] | mouse up [btn]
|
|
695
|
+
mouse wheel <dy> [dx]
|
|
696
|
+
Settings: set geo <lat> <lng> | set media <dark|light|no-preference>
|
|
609
697
|
Device: emulate <device> | emulate reset | devices [filter]
|
|
610
698
|
Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
|
|
611
|
-
element-state <sel> |
|
|
699
|
+
element-state <sel> | box <sel>
|
|
700
|
+
console [--clear] | errors [--clear] | network [--clear]
|
|
612
701
|
cookies | storage [set <k> <v>] | perf
|
|
613
702
|
value <sel> | count <sel> | clipboard [write <text>]
|
|
614
|
-
Visual: screenshot [
|
|
703
|
+
Visual: screenshot [sel|@ref] [path] [--full] [--clip x,y,w,h]
|
|
704
|
+
screenshot --annotate | pdf [path] | responsive [prefix]
|
|
615
705
|
Snapshot: snapshot [-i] [-f] [-V] [-c] [-C] [-d N] [-s sel]
|
|
616
|
-
Find: find role|text|label|placeholder|testid <query>
|
|
706
|
+
Find: find role|text|label|placeholder|testid|alt|title <query>
|
|
707
|
+
find first|last <sel> | find nth <n> <sel>
|
|
617
708
|
Compare: diff <url1> <url2> | screenshot-diff <baseline> [current]
|
|
618
709
|
Multi-step: chain (reads JSON from stdin)
|
|
710
|
+
Cookies: cookie <n>=<v> | cookie set <n> <v> [--domain --secure]
|
|
711
|
+
cookie clear | cookie export <file> | cookie import <file>
|
|
619
712
|
Network: offline [on|off] | route <pattern> block|fulfill
|
|
713
|
+
header <n>:<v> | useragent <str>
|
|
620
714
|
Recording: har start | har stop [path]
|
|
621
715
|
video start [dir] | video stop | video status
|
|
716
|
+
record start | record stop | record status
|
|
717
|
+
record export browse|replay [path]
|
|
622
718
|
Tabs: tabs | tab <id> | newtab [url] | closetab [id]
|
|
623
719
|
Frames: frame <sel> | frame main
|
|
624
720
|
Sessions: sessions | session-close <id>
|
|
625
721
|
Auth: auth save <name> <url> <user> <pass|--password-stdin>
|
|
626
722
|
auth login <name> | auth list | auth delete <name>
|
|
723
|
+
cookie-import --list | cookie-import <browser> [--domain <d>] [--profile <p>]
|
|
627
724
|
State: state save|load|list|show [name]
|
|
628
725
|
Debug: inspect (requires BROWSE_DEBUG_PORT)
|
|
629
|
-
Server: status | instances |
|
|
630
|
-
useragent <str> | stop | restart
|
|
726
|
+
Server: status | instances | stop | restart | doctor | upgrade
|
|
631
727
|
Setup: install-skill [path]
|
|
632
728
|
|
|
633
729
|
Options:
|
|
@@ -636,6 +732,10 @@ Options:
|
|
|
636
732
|
--content-boundaries Wrap page content in nonce-delimited markers
|
|
637
733
|
--allowed-domains <d,d> Block navigation/resources outside allowlist
|
|
638
734
|
--headed Run browser in headed (visible) mode
|
|
735
|
+
--max-output <n> Truncate output to N characters
|
|
736
|
+
--state <path> Load state file (cookies/storage) before first command
|
|
737
|
+
--connect Auto-discover and connect to running Chrome
|
|
738
|
+
--cdp <port> Connect to Chrome on specific debugging port
|
|
639
739
|
|
|
640
740
|
Snapshot flags:
|
|
641
741
|
-i Interactive elements only (terse flat list by default)
|
package/src/commands/meta.ts
CHANGED
|
@@ -131,8 +131,8 @@ export async function handleMetaCommand(
|
|
|
131
131
|
// ─── State Persistence ───────────────────────────────
|
|
132
132
|
case 'state': {
|
|
133
133
|
const subcommand = args[0];
|
|
134
|
-
if (!subcommand || !['save', 'load', 'list', 'show'].includes(subcommand)) {
|
|
135
|
-
throw new Error('Usage: browse state save|load|list|show [name]');
|
|
134
|
+
if (!subcommand || !['save', 'load', 'list', 'show', 'clean'].includes(subcommand)) {
|
|
135
|
+
throw new Error('Usage: browse state save|load|list|show|clean [name] [--older-than N]');
|
|
136
136
|
}
|
|
137
137
|
const name = sanitizeName(args[1] || 'default');
|
|
138
138
|
const statesDir = `${LOCAL_DIR}/states`;
|
|
@@ -167,6 +167,21 @@ export async function handleMetaCommand(
|
|
|
167
167
|
].join('\n');
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
if (subcommand === 'clean') {
|
|
171
|
+
const olderThanIdx = args.indexOf('--older-than');
|
|
172
|
+
const maxDays = olderThanIdx !== -1 && args[olderThanIdx + 1]
|
|
173
|
+
? parseInt(args[olderThanIdx + 1], 10)
|
|
174
|
+
: 7;
|
|
175
|
+
if (isNaN(maxDays) || maxDays < 1) {
|
|
176
|
+
throw new Error('--older-than must be a positive number of days');
|
|
177
|
+
}
|
|
178
|
+
const { cleanOldStates } = await import('../session-persist');
|
|
179
|
+
const { deleted } = cleanOldStates(LOCAL_DIR, maxDays);
|
|
180
|
+
return deleted > 0
|
|
181
|
+
? `Deleted ${deleted} state file(s) older than ${maxDays} days`
|
|
182
|
+
: `No state files older than ${maxDays} days`;
|
|
183
|
+
}
|
|
184
|
+
|
|
170
185
|
if (subcommand === 'save') {
|
|
171
186
|
const context = bm.getContext();
|
|
172
187
|
if (!context) throw new Error('No browser context');
|
|
@@ -219,8 +234,31 @@ export async function handleMetaCommand(
|
|
|
219
234
|
const page = bm.getPage();
|
|
220
235
|
const annotate = args.includes('--annotate');
|
|
221
236
|
const isFullPage = args.includes('--full');
|
|
222
|
-
const
|
|
223
|
-
const
|
|
237
|
+
const clipIdx = args.indexOf('--clip');
|
|
238
|
+
const clipArg = clipIdx >= 0 ? args[clipIdx + 1] : null;
|
|
239
|
+
const filteredArgs = args.filter((a, i) => a !== '--annotate' && a !== '--full' && a !== '--clip' && (clipIdx < 0 || i !== clipIdx + 1));
|
|
240
|
+
|
|
241
|
+
// Parse --clip x,y,w,h
|
|
242
|
+
let clip: { x: number; y: number; width: number; height: number } | undefined;
|
|
243
|
+
if (clipArg) {
|
|
244
|
+
const parts = clipArg.split(',').map(Number);
|
|
245
|
+
if (parts.length !== 4 || parts.some(isNaN)) throw new Error('Usage: browse screenshot --clip x,y,width,height [path]');
|
|
246
|
+
clip = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
|
|
247
|
+
if (isFullPage) throw new Error('Cannot use --clip with --full');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Detect element/ref selector vs output path
|
|
251
|
+
// Selector: starts with @e, ., #, [ — Path: contains / or ends with image extension
|
|
252
|
+
let elementSelector: string | null = null;
|
|
253
|
+
let screenshotPath: string;
|
|
254
|
+
const firstArg = filteredArgs[0];
|
|
255
|
+
if (firstArg && (firstArg.startsWith('@e') || firstArg.startsWith('@c') || /^[.#\[]/.test(firstArg))) {
|
|
256
|
+
if (clip) throw new Error('Cannot use --clip with element selector');
|
|
257
|
+
elementSelector = firstArg;
|
|
258
|
+
screenshotPath = filteredArgs[1] || (currentSession ? `${currentSession.outputDir}/screenshot.png` : `${LOCAL_DIR}/browse-screenshot.png`);
|
|
259
|
+
} else {
|
|
260
|
+
screenshotPath = firstArg || (currentSession ? `${currentSession.outputDir}/screenshot.png` : `${LOCAL_DIR}/browse-screenshot.png`);
|
|
261
|
+
}
|
|
224
262
|
|
|
225
263
|
if (annotate) {
|
|
226
264
|
const viewport = page.viewportSize() || { width: 1920, height: 1080 };
|
|
@@ -289,7 +327,14 @@ export async function handleMetaCommand(
|
|
|
289
327
|
return `Screenshot saved: ${screenshotPath}\n\nLegend:\n${legend.join('\n')}`;
|
|
290
328
|
}
|
|
291
329
|
|
|
292
|
-
|
|
330
|
+
if (elementSelector) {
|
|
331
|
+
const resolved = bm.resolveRef(elementSelector);
|
|
332
|
+
const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
|
|
333
|
+
await locator.screenshot({ path: screenshotPath });
|
|
334
|
+
return `Screenshot saved: ${screenshotPath} (element: ${elementSelector})`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
await page.screenshot({ path: screenshotPath, fullPage: isFullPage, clip });
|
|
293
338
|
return `Screenshot saved: ${screenshotPath}`;
|
|
294
339
|
}
|
|
295
340
|
|
|
@@ -346,8 +391,8 @@ export async function handleMetaCommand(
|
|
|
346
391
|
const { handleWriteCommand } = await import('./write');
|
|
347
392
|
const { PolicyChecker } = await import('../policy');
|
|
348
393
|
|
|
349
|
-
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']);
|
|
350
|
-
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','clipboard']);
|
|
394
|
+
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','rightclick','tap','swipe','mouse','keyboard','scrollinto','scrollintoview','set']);
|
|
395
|
+
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','clipboard','box','errors']);
|
|
351
396
|
|
|
352
397
|
const sessionBuffers = currentSession?.buffers;
|
|
353
398
|
const policy = new PolicyChecker();
|
|
@@ -592,6 +637,70 @@ export async function handleMetaCommand(
|
|
|
592
637
|
}
|
|
593
638
|
}
|
|
594
639
|
|
|
640
|
+
// ─── Cookie Import ──────────────────────────────────
|
|
641
|
+
case 'cookie-import': {
|
|
642
|
+
const { findInstalledBrowsers, importCookies, CookieImportError } = await import('../cookie-import');
|
|
643
|
+
|
|
644
|
+
// --list: show installed browsers
|
|
645
|
+
if (args.includes('--list')) {
|
|
646
|
+
const browsers = findInstalledBrowsers();
|
|
647
|
+
if (browsers.length === 0) return 'No supported Chromium browsers found';
|
|
648
|
+
return 'Installed browsers:\n' + browsers.map(b => ` ${b.name}`).join('\n');
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const browserName = args[0];
|
|
652
|
+
if (!browserName) {
|
|
653
|
+
throw new Error(
|
|
654
|
+
'Usage: browse cookie-import --list\n' +
|
|
655
|
+
' browse cookie-import <browser> [--domain <d>] [--profile <p>]\n' +
|
|
656
|
+
'Supported browsers: chrome, arc, brave, edge'
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Parse --domain and --profile flags
|
|
661
|
+
const domains: string[] = [];
|
|
662
|
+
let profile: string | undefined;
|
|
663
|
+
for (let i = 1; i < args.length; i++) {
|
|
664
|
+
if (args[i] === '--domain' && args[i + 1]) { domains.push(args[++i]); }
|
|
665
|
+
else if (args[i] === '--profile' && args[i + 1]) { profile = args[++i]; }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
try {
|
|
669
|
+
// If no domains specified, import all by listing domains first then importing all
|
|
670
|
+
if (domains.length === 0) {
|
|
671
|
+
const { listDomains } = await import('../cookie-import');
|
|
672
|
+
const { domains: allDomains, browser } = listDomains(browserName, profile);
|
|
673
|
+
if (allDomains.length === 0) return `No cookies found in ${browser}`;
|
|
674
|
+
const allDomainNames = allDomains.map(d => d.domain);
|
|
675
|
+
const result = await importCookies(browserName, allDomainNames, profile);
|
|
676
|
+
const context = bm.getContext();
|
|
677
|
+
if (!context) throw new Error('No browser context');
|
|
678
|
+
if (result.cookies.length > 0) await context.addCookies(result.cookies);
|
|
679
|
+
const domainCount = Object.keys(result.domainCounts).length;
|
|
680
|
+
const failedNote = result.failed > 0 ? ` (${result.failed} failed to decrypt)` : '';
|
|
681
|
+
return `Imported ${result.count} cookies from ${browser} across ${domainCount} domains${failedNote}`;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const result = await importCookies(browserName, domains, profile);
|
|
685
|
+
const context = bm.getContext();
|
|
686
|
+
if (!context) throw new Error('No browser context');
|
|
687
|
+
if (result.cookies.length > 0) await context.addCookies(result.cookies);
|
|
688
|
+
const domainLabel = domains.length === 1 ? `for ${domains[0]} ` : '';
|
|
689
|
+
const failedNote = result.failed > 0 ? ` (${result.failed} failed to decrypt)` : '';
|
|
690
|
+
// Resolve display name from the result's domain counts keys or use arg
|
|
691
|
+
const browserDisplay = Object.keys(result.domainCounts).length > 0
|
|
692
|
+
? browserName.charAt(0).toUpperCase() + browserName.slice(1)
|
|
693
|
+
: browserName;
|
|
694
|
+
return `Imported ${result.count} cookies ${domainLabel}from ${browserDisplay}${failedNote}`;
|
|
695
|
+
} catch (err) {
|
|
696
|
+
if (err instanceof CookieImportError) {
|
|
697
|
+
const hint = err.action === 'retry' ? ' (retry may help)' : '';
|
|
698
|
+
throw new Error(err.message + hint);
|
|
699
|
+
}
|
|
700
|
+
throw err;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
595
704
|
// ─── HAR Recording ────────────────────────────────
|
|
596
705
|
case 'har': {
|
|
597
706
|
const subcommand = args[0];
|
|
@@ -652,11 +761,46 @@ export async function handleMetaCommand(
|
|
|
652
761
|
throw new Error('Usage: browse video start [dir] | browse video stop | browse video status');
|
|
653
762
|
}
|
|
654
763
|
|
|
764
|
+
// ─── Doctor ────────────────────────────────────────
|
|
765
|
+
case 'doctor': {
|
|
766
|
+
const lines: string[] = [];
|
|
767
|
+
lines.push(`Bun: ${typeof Bun !== 'undefined' ? Bun.version : 'not available'}`);
|
|
768
|
+
try {
|
|
769
|
+
const pw = await import('playwright');
|
|
770
|
+
lines.push(`Playwright: installed`);
|
|
771
|
+
try {
|
|
772
|
+
const chromium = pw.chromium;
|
|
773
|
+
lines.push(`Chromium: ${chromium.executablePath()}`);
|
|
774
|
+
} catch {
|
|
775
|
+
lines.push(`Chromium: NOT FOUND — run "bunx playwright install chromium"`);
|
|
776
|
+
}
|
|
777
|
+
} catch {
|
|
778
|
+
lines.push(`Playwright: NOT INSTALLED — run "bun install playwright"`);
|
|
779
|
+
}
|
|
780
|
+
lines.push(`Server: running (you're connected)`);
|
|
781
|
+
return lines.join('\n');
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// ─── Upgrade ────────────────────────────────────────
|
|
785
|
+
case 'upgrade': {
|
|
786
|
+
const { execSync } = await import('child_process');
|
|
787
|
+
try {
|
|
788
|
+
const output = execSync('npm update -g @ulpi/browse 2>&1', { encoding: 'utf-8', timeout: 30000 });
|
|
789
|
+
return `Upgrade complete.\n${output.trim()}`;
|
|
790
|
+
} catch (err: any) {
|
|
791
|
+
if (err.message?.includes('EACCES') || err.message?.includes('permission')) {
|
|
792
|
+
return `Permission denied. Try: sudo npm update -g @ulpi/browse`;
|
|
793
|
+
}
|
|
794
|
+
return `Upgrade failed: ${err.message}\nManual: npm install -g @ulpi/browse`;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
655
798
|
// ─── Semantic Locator ──────────────────────────────
|
|
656
799
|
case 'find': {
|
|
657
800
|
const root = bm.getLocatorRoot();
|
|
801
|
+
const page = bm.getPage();
|
|
658
802
|
const sub = args[0];
|
|
659
|
-
if (!sub) throw new Error('Usage: browse find role|text|label|placeholder|testid <query>
|
|
803
|
+
if (!sub) throw new Error('Usage: browse find role|text|label|placeholder|testid|alt|title|first|last|nth <query>');
|
|
660
804
|
const query = args[1];
|
|
661
805
|
if (!query) throw new Error(`Usage: browse find ${sub} <query>`);
|
|
662
806
|
|
|
@@ -679,8 +823,35 @@ export async function handleMetaCommand(
|
|
|
679
823
|
case 'testid':
|
|
680
824
|
locator = root.getByTestId(query);
|
|
681
825
|
break;
|
|
826
|
+
case 'alt':
|
|
827
|
+
locator = root.getByAltText(query);
|
|
828
|
+
break;
|
|
829
|
+
case 'title':
|
|
830
|
+
locator = root.getByTitle(query);
|
|
831
|
+
break;
|
|
832
|
+
case 'first': {
|
|
833
|
+
locator = page.locator(query).first();
|
|
834
|
+
const text = await locator.textContent({ timeout: 2000 }).catch(() => '') || '';
|
|
835
|
+
const total = await page.locator(query).count();
|
|
836
|
+
return `Found ${total} match(es), first: "${text.trim().slice(0, 100)}"`;
|
|
837
|
+
}
|
|
838
|
+
case 'last': {
|
|
839
|
+
locator = page.locator(query).last();
|
|
840
|
+
const text = await locator.textContent({ timeout: 2000 }).catch(() => '') || '';
|
|
841
|
+
const total = await page.locator(query).count();
|
|
842
|
+
return `Found ${total} match(es), last: "${text.trim().slice(0, 100)}"`;
|
|
843
|
+
}
|
|
844
|
+
case 'nth': {
|
|
845
|
+
const n = parseInt(query, 10);
|
|
846
|
+
const sel = args[2];
|
|
847
|
+
if (isNaN(n) || !sel) throw new Error('Usage: browse find nth <index> <selector>');
|
|
848
|
+
locator = page.locator(sel).nth(n);
|
|
849
|
+
const text = await locator.textContent({ timeout: 2000 }).catch(() => '') || '';
|
|
850
|
+
const total = await page.locator(sel).count();
|
|
851
|
+
return `Found ${total} match(es), nth(${n}): "${text.trim().slice(0, 100)}"`;
|
|
852
|
+
}
|
|
682
853
|
default:
|
|
683
|
-
throw new Error(`Unknown find type: ${sub}. Use role|text|label|placeholder|testid`);
|
|
854
|
+
throw new Error(`Unknown find type: ${sub}. Use role|text|label|placeholder|testid|alt|title|first|last|nth`);
|
|
684
855
|
}
|
|
685
856
|
|
|
686
857
|
const count = await locator.count();
|
|
@@ -742,6 +913,73 @@ export async function handleMetaCommand(
|
|
|
742
913
|
}
|
|
743
914
|
}
|
|
744
915
|
|
|
916
|
+
// ─── Record & Export ─────────────────────────────────
|
|
917
|
+
case 'record': {
|
|
918
|
+
const subcommand = args[0];
|
|
919
|
+
if (!subcommand) throw new Error('Usage: browse record start | stop | status | export browse|replay [path]');
|
|
920
|
+
|
|
921
|
+
if (subcommand === 'start') {
|
|
922
|
+
if (!currentSession) throw new Error('Recording requires a session context');
|
|
923
|
+
if (currentSession.recording) throw new Error('Recording already active. Run "browse record stop" first.');
|
|
924
|
+
currentSession.recording = [];
|
|
925
|
+
return 'Recording started';
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (subcommand === 'stop') {
|
|
929
|
+
if (!currentSession) throw new Error('Recording requires a session context');
|
|
930
|
+
if (!currentSession.recording) throw new Error('No active recording. Run "browse record start" first.');
|
|
931
|
+
const count = currentSession.recording.length;
|
|
932
|
+
// Store last recording for export after stop
|
|
933
|
+
(currentSession as any)._lastRecording = currentSession.recording;
|
|
934
|
+
currentSession.recording = null;
|
|
935
|
+
return `Recording stopped (${count} steps captured)`;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (subcommand === 'status') {
|
|
939
|
+
if (!currentSession) return 'No session context';
|
|
940
|
+
if (currentSession.recording) {
|
|
941
|
+
return `Recording active — ${currentSession.recording.length} steps captured`;
|
|
942
|
+
}
|
|
943
|
+
const last = (currentSession as any)._lastRecording;
|
|
944
|
+
if (last) return `Recording stopped — ${last.length} steps available for export`;
|
|
945
|
+
return 'No active recording';
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
if (subcommand === 'export') {
|
|
949
|
+
if (!currentSession) throw new Error('Recording requires a session context');
|
|
950
|
+
const format = args[1];
|
|
951
|
+
if (!format) throw new Error('Usage: browse record export browse|replay [path]');
|
|
952
|
+
|
|
953
|
+
// Use active recording or last stopped recording
|
|
954
|
+
const steps = currentSession.recording || (currentSession as any)._lastRecording;
|
|
955
|
+
if (!steps || steps.length === 0) {
|
|
956
|
+
throw new Error('No recording to export. Run "browse record start" first, execute commands, then export.');
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const { exportBrowse, exportReplay } = await import('../record-export');
|
|
960
|
+
|
|
961
|
+
let output: string;
|
|
962
|
+
if (format === 'browse') {
|
|
963
|
+
output = exportBrowse(steps);
|
|
964
|
+
} else if (format === 'replay') {
|
|
965
|
+
output = exportReplay(steps);
|
|
966
|
+
} else {
|
|
967
|
+
throw new Error(`Unknown format: ${format}. Use "browse" (chain JSON) or "replay" (Playwright/Puppeteer).`);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const filePath = args[2];
|
|
971
|
+
if (filePath) {
|
|
972
|
+
fs.writeFileSync(filePath, output);
|
|
973
|
+
return `Exported ${steps.length} steps as ${format}: ${filePath}`;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// No path — return the script to stdout
|
|
977
|
+
return output;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
throw new Error('Usage: browse record start | stop | status | export browse|replay [path]');
|
|
981
|
+
}
|
|
982
|
+
|
|
745
983
|
default:
|
|
746
984
|
throw new Error(`Unknown meta command: ${command}`);
|
|
747
985
|
}
|
package/src/commands/read.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { BrowserManager } from '../browser-manager';
|
|
9
9
|
import { listDevices } from '../browser-manager';
|
|
10
10
|
import type { SessionBuffers } from '../buffers';
|
|
11
|
+
import { DEFAULTS } from '../constants';
|
|
11
12
|
import * as fs from 'fs';
|
|
12
13
|
|
|
13
14
|
export async function handleReadCommand(
|
|
@@ -331,6 +332,33 @@ export async function handleReadCommand(
|
|
|
331
332
|
}
|
|
332
333
|
}
|
|
333
334
|
|
|
335
|
+
case 'box': {
|
|
336
|
+
const selector = args[0];
|
|
337
|
+
if (!selector) throw new Error('Usage: browse box <selector>');
|
|
338
|
+
const resolved = bm.resolveRef(selector);
|
|
339
|
+
const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
|
|
340
|
+
const box = await locator.boundingBox({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
341
|
+
if (!box) throw new Error(`Element ${selector} is not visible or has no bounding box`);
|
|
342
|
+
return JSON.stringify({ x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
case 'errors': {
|
|
346
|
+
const cb = (buffers || bm.getBuffers()).consoleBuffer;
|
|
347
|
+
if (args[0] === '--clear') {
|
|
348
|
+
const before = cb.length;
|
|
349
|
+
// Remove error entries in-place
|
|
350
|
+
for (let i = cb.length - 1; i >= 0; i--) {
|
|
351
|
+
if (cb[i].level === 'error') cb.splice(i, 1);
|
|
352
|
+
}
|
|
353
|
+
return `Cleared ${before - cb.length} error(s).`;
|
|
354
|
+
}
|
|
355
|
+
const errors = cb.filter(e => e.level === 'error');
|
|
356
|
+
if (errors.length === 0) return '(no errors)';
|
|
357
|
+
return errors.map(e =>
|
|
358
|
+
`[${new Date(e.timestamp).toISOString()}] ${e.text}`
|
|
359
|
+
).join('\n');
|
|
360
|
+
}
|
|
361
|
+
|
|
334
362
|
case 'devices': {
|
|
335
363
|
const filter = args.join(' ').toLowerCase();
|
|
336
364
|
const all = listDevices();
|