@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.
- package/README.md +9 -3
- package/bin/browse.ts +10 -1
- package/package.json +9 -8
- package/skill/SKILL.md +163 -26
- package/src/auth-vault.ts +244 -0
- package/src/browser-manager.ts +177 -2
- package/src/cli.ts +159 -25
- package/src/commands/meta.ts +176 -5
- package/src/commands/read.ts +39 -13
- package/src/commands/write.ts +200 -6
- package/src/config.ts +44 -0
- package/src/constants.ts +4 -2
- package/src/domain-filter.ts +134 -0
- package/src/har.ts +66 -0
- package/src/policy.ts +94 -0
- package/src/sanitize.ts +11 -0
- package/src/server.ts +196 -56
- package/src/session-manager.ts +65 -2
- package/src/snapshot.ts +18 -13
package/src/commands/meta.ts
CHANGED
|
@@ -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
|
}
|
package/src/commands/read.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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();
|
package/src/commands/write.ts
CHANGED
|
@@ -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,
|
|
5
|
-
* press, scroll, wait, viewport, cookie,
|
|
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
|
-
|
|
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
|
+
}
|