@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.
- package/README.md +6 -6
- package/dist/browse.mjs +6756 -0
- package/package.json +17 -13
- package/skill/SKILL.md +2 -3
- package/bin/browse.ts +0 -11
- package/src/auth-vault.ts +0 -196
- package/src/browser-manager.ts +0 -976
- package/src/buffers.ts +0 -81
- package/src/bun.d.ts +0 -65
- package/src/chrome-discover.ts +0 -73
- package/src/cli.ts +0 -783
- package/src/commands/meta.ts +0 -986
- package/src/commands/read.ts +0 -375
- package/src/commands/write.ts +0 -704
- package/src/config.ts +0 -44
- package/src/constants.ts +0 -14
- package/src/cookie-import.ts +0 -410
- package/src/diff.d.ts +0 -12
- package/src/domain-filter.ts +0 -140
- package/src/encryption.ts +0 -48
- package/src/har.ts +0 -66
- package/src/install-skill.ts +0 -98
- package/src/png-compare.ts +0 -247
- package/src/policy.ts +0 -94
- package/src/rebrowser.d.ts +0 -7
- package/src/record-export.ts +0 -98
- package/src/runtime.ts +0 -161
- package/src/sanitize.ts +0 -11
- package/src/server.ts +0 -526
- package/src/session-manager.ts +0 -240
- package/src/session-persist.ts +0 -192
- package/src/snapshot.ts +0 -606
- package/src/types.ts +0 -12
package/src/commands/meta.ts
DELETED
|
@@ -1,986 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Meta commands — tabs, server control, screenshots, chain, diff, snapshot, sessions
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { BrowserManager } from '../browser-manager';
|
|
6
|
-
import type { SessionManager, Session } from '../session-manager';
|
|
7
|
-
import { handleSnapshot } from '../snapshot';
|
|
8
|
-
import { DEFAULTS } from '../constants';
|
|
9
|
-
import { sanitizeName } from '../sanitize';
|
|
10
|
-
import * as Diff from 'diff';
|
|
11
|
-
import * as fs from 'fs';
|
|
12
|
-
|
|
13
|
-
const LOCAL_DIR = process.env.BROWSE_LOCAL_DIR || '/tmp';
|
|
14
|
-
|
|
15
|
-
export async function handleMetaCommand(
|
|
16
|
-
command: string,
|
|
17
|
-
args: string[],
|
|
18
|
-
bm: BrowserManager,
|
|
19
|
-
shutdown: () => Promise<void> | void,
|
|
20
|
-
sessionManager?: SessionManager,
|
|
21
|
-
currentSession?: Session
|
|
22
|
-
): Promise<string> {
|
|
23
|
-
switch (command) {
|
|
24
|
-
// ─── Tabs ──────────────────────────────────────────
|
|
25
|
-
case 'tabs': {
|
|
26
|
-
const tabs = await bm.getTabListWithTitles();
|
|
27
|
-
return tabs.map(t =>
|
|
28
|
-
`${t.active ? '→ ' : ' '}[${t.id}] ${t.title || '(untitled)'} — ${t.url}`
|
|
29
|
-
).join('\n');
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
case 'tab': {
|
|
33
|
-
const id = parseInt(args[0], 10);
|
|
34
|
-
if (isNaN(id)) throw new Error('Usage: browse tab <id>');
|
|
35
|
-
bm.switchTab(id);
|
|
36
|
-
return `Switched to tab ${id}`;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
case 'newtab': {
|
|
40
|
-
const url = args[0];
|
|
41
|
-
const id = await bm.newTab(url);
|
|
42
|
-
return `Opened tab ${id}${url ? ` → ${url}` : ''}`;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
case 'closetab': {
|
|
46
|
-
const id = args[0] ? parseInt(args[0], 10) : undefined;
|
|
47
|
-
await bm.closeTab(id);
|
|
48
|
-
return `Closed tab${id ? ` ${id}` : ''}`;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ─── Server Control ────────────────────────────────
|
|
52
|
-
case 'status': {
|
|
53
|
-
const page = bm.getPage();
|
|
54
|
-
const tabs = bm.getTabCount();
|
|
55
|
-
const lines = [
|
|
56
|
-
`Status: healthy`,
|
|
57
|
-
`URL: ${page.url()}`,
|
|
58
|
-
`Tabs: ${tabs}`,
|
|
59
|
-
`PID: ${process.pid}`,
|
|
60
|
-
`Uptime: ${Math.floor(process.uptime())}s`,
|
|
61
|
-
];
|
|
62
|
-
if (sessionManager) {
|
|
63
|
-
lines.push(`Sessions: ${sessionManager.getSessionCount()}`);
|
|
64
|
-
}
|
|
65
|
-
if (currentSession) {
|
|
66
|
-
lines.push(`Session: ${currentSession.id}`);
|
|
67
|
-
}
|
|
68
|
-
return lines.join('\n');
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
case 'url': {
|
|
72
|
-
return bm.getCurrentUrl();
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
case 'stop': {
|
|
76
|
-
setTimeout(() => shutdown(), 100);
|
|
77
|
-
return 'Server stopped';
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
case 'restart': {
|
|
81
|
-
console.log('[browse] Restart requested. Exiting for CLI to restart.');
|
|
82
|
-
setTimeout(() => shutdown(), 100);
|
|
83
|
-
return 'Restarting...';
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// ─── Sessions ───────────────────────────────────────
|
|
87
|
-
case 'sessions': {
|
|
88
|
-
if (!sessionManager) return '(session management not available)';
|
|
89
|
-
const list = sessionManager.listSessions();
|
|
90
|
-
if (list.length === 0) return '(no active sessions)';
|
|
91
|
-
return list.map(s =>
|
|
92
|
-
` [${s.id}] ${s.tabs} tab(s) — ${s.url} — idle ${s.idleSeconds}s`
|
|
93
|
-
).join('\n');
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
case 'session-close': {
|
|
97
|
-
if (!sessionManager) throw new Error('Session management not available');
|
|
98
|
-
const id = args[0];
|
|
99
|
-
if (!id) throw new Error('Usage: browse session-close <id>');
|
|
100
|
-
// Flush buffers before closing so logs aren't lost
|
|
101
|
-
const closingSession = sessionManager.getAllSessions().find(s => s.id === id);
|
|
102
|
-
if (closingSession) {
|
|
103
|
-
const buffers = closingSession.buffers;
|
|
104
|
-
const consolePath = `${closingSession.outputDir}/console.log`;
|
|
105
|
-
const networkPath = `${closingSession.outputDir}/network.log`;
|
|
106
|
-
const newConsoleCount = buffers.consoleTotalAdded - buffers.lastConsoleFlushed;
|
|
107
|
-
if (newConsoleCount > 0) {
|
|
108
|
-
const count = Math.min(newConsoleCount, buffers.consoleBuffer.length);
|
|
109
|
-
const entries = buffers.consoleBuffer.slice(-count);
|
|
110
|
-
const lines = entries.map(e =>
|
|
111
|
-
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
|
112
|
-
).join('\n') + '\n';
|
|
113
|
-
fs.appendFileSync(consolePath, lines);
|
|
114
|
-
buffers.lastConsoleFlushed = buffers.consoleTotalAdded;
|
|
115
|
-
}
|
|
116
|
-
const newNetworkCount = buffers.networkTotalAdded - buffers.lastNetworkFlushed;
|
|
117
|
-
if (newNetworkCount > 0) {
|
|
118
|
-
const count = Math.min(newNetworkCount, buffers.networkBuffer.length);
|
|
119
|
-
const entries = buffers.networkBuffer.slice(-count);
|
|
120
|
-
const lines = entries.map(e =>
|
|
121
|
-
`[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
|
|
122
|
-
).join('\n') + '\n';
|
|
123
|
-
fs.appendFileSync(networkPath, lines);
|
|
124
|
-
buffers.lastNetworkFlushed = buffers.networkTotalAdded;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
await sessionManager.closeSession(id);
|
|
128
|
-
return `Session "${id}" closed`;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ─── State Persistence ───────────────────────────────
|
|
132
|
-
case 'state': {
|
|
133
|
-
const subcommand = args[0];
|
|
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
|
-
}
|
|
137
|
-
const name = sanitizeName(args[1] || 'default');
|
|
138
|
-
const statesDir = `${LOCAL_DIR}/states`;
|
|
139
|
-
const statePath = `${statesDir}/${name}.json`;
|
|
140
|
-
|
|
141
|
-
if (subcommand === 'list') {
|
|
142
|
-
if (!fs.existsSync(statesDir)) return '(no saved states)';
|
|
143
|
-
const files = fs.readdirSync(statesDir).filter(f => f.endsWith('.json'));
|
|
144
|
-
if (files.length === 0) return '(no saved states)';
|
|
145
|
-
const lines: string[] = [];
|
|
146
|
-
for (const file of files) {
|
|
147
|
-
const fp = `${statesDir}/${file}`;
|
|
148
|
-
const stat = fs.statSync(fp);
|
|
149
|
-
lines.push(` ${file.replace('.json', '')} ${stat.size}B ${new Date(stat.mtimeMs).toISOString()}`);
|
|
150
|
-
}
|
|
151
|
-
return lines.join('\n');
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (subcommand === 'show') {
|
|
155
|
-
if (!fs.existsSync(statePath)) {
|
|
156
|
-
throw new Error(`State file not found: ${statePath}`);
|
|
157
|
-
}
|
|
158
|
-
const data = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
159
|
-
const cookieCount = data.cookies?.length || 0;
|
|
160
|
-
const originCount = data.origins?.length || 0;
|
|
161
|
-
const storageItems = (data.origins || []).reduce((sum: number, o: any) => sum + (o.localStorage?.length || 0), 0);
|
|
162
|
-
return [
|
|
163
|
-
`State: ${name}`,
|
|
164
|
-
`Cookies: ${cookieCount}`,
|
|
165
|
-
`Origins: ${originCount}`,
|
|
166
|
-
`Storage items: ${storageItems}`,
|
|
167
|
-
].join('\n');
|
|
168
|
-
}
|
|
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
|
-
|
|
185
|
-
if (subcommand === 'save') {
|
|
186
|
-
const context = bm.getContext();
|
|
187
|
-
if (!context) throw new Error('No browser context');
|
|
188
|
-
const state = await context.storageState();
|
|
189
|
-
fs.mkdirSync(statesDir, { recursive: true });
|
|
190
|
-
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
191
|
-
return `State saved: ${statePath}`;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (subcommand === 'load') {
|
|
195
|
-
if (!fs.existsSync(statePath)) {
|
|
196
|
-
throw new Error(`State file not found: ${statePath}. Run "browse state save ${name}" first.`);
|
|
197
|
-
}
|
|
198
|
-
const stateData = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
199
|
-
const context = bm.getContext();
|
|
200
|
-
if (!context) throw new Error('No browser context');
|
|
201
|
-
const warnings: string[] = [];
|
|
202
|
-
if (stateData.cookies?.length) {
|
|
203
|
-
try {
|
|
204
|
-
await context.addCookies(stateData.cookies);
|
|
205
|
-
} catch (err: any) {
|
|
206
|
-
warnings.push(`Cookies: ${err.message}`);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
if (stateData.origins?.length) {
|
|
210
|
-
for (const origin of stateData.origins) {
|
|
211
|
-
if (origin.localStorage?.length) {
|
|
212
|
-
try {
|
|
213
|
-
const page = bm.getPage();
|
|
214
|
-
await page.goto(origin.origin, { waitUntil: 'domcontentloaded', timeout: 5000 });
|
|
215
|
-
for (const item of origin.localStorage) {
|
|
216
|
-
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [item.name, item.value]);
|
|
217
|
-
}
|
|
218
|
-
} catch (err: any) {
|
|
219
|
-
warnings.push(`Storage for ${origin.origin}: ${err.message}`);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
if (warnings.length > 0) {
|
|
225
|
-
return `State loaded: ${statePath} (${warnings.length} warning(s))\n${warnings.join('\n')}`;
|
|
226
|
-
}
|
|
227
|
-
return `State loaded: ${statePath}`;
|
|
228
|
-
}
|
|
229
|
-
throw new Error('Usage: browse state save|load|list|show [name]');
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// ─── Visual ────────────────────────────────────────
|
|
233
|
-
case 'screenshot': {
|
|
234
|
-
const page = bm.getPage();
|
|
235
|
-
const annotate = args.includes('--annotate');
|
|
236
|
-
const isFullPage = args.includes('--full');
|
|
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
|
-
}
|
|
262
|
-
|
|
263
|
-
if (annotate) {
|
|
264
|
-
const viewport = page.viewportSize() || { width: 1920, height: 1080 };
|
|
265
|
-
const annotations = await page.evaluate((vp) => {
|
|
266
|
-
const INTERACTIVE = ['a', 'button', 'input', 'select', 'textarea', 'details', 'summary'];
|
|
267
|
-
const INTERACTIVE_ROLES = ['button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
|
|
268
|
-
'listbox', 'menuitem', 'option', 'searchbox', 'slider', 'switch', 'tab'];
|
|
269
|
-
const results: Array<{ x: number; y: number; desc: string }> = [];
|
|
270
|
-
const scrollX = window.scrollX;
|
|
271
|
-
const scrollY = window.scrollY;
|
|
272
|
-
|
|
273
|
-
const candidates = document.querySelectorAll(
|
|
274
|
-
INTERACTIVE.join(',') + ',[role],[onclick],[tabindex],[data-action]'
|
|
275
|
-
);
|
|
276
|
-
|
|
277
|
-
for (let i = 0; i < candidates.length && results.length < 200; i++) {
|
|
278
|
-
const el = candidates[i] as HTMLElement;
|
|
279
|
-
if (el.offsetWidth === 0 && el.offsetHeight === 0) continue;
|
|
280
|
-
|
|
281
|
-
const tag = el.tagName.toLowerCase();
|
|
282
|
-
const role = el.getAttribute('role') || '';
|
|
283
|
-
const isInteractive = INTERACTIVE.includes(tag) || INTERACTIVE_ROLES.includes(role);
|
|
284
|
-
if (!isInteractive && !el.hasAttribute('onclick') &&
|
|
285
|
-
!el.hasAttribute('tabindex') && !el.hasAttribute('data-action') &&
|
|
286
|
-
getComputedStyle(el).cursor !== 'pointer') continue;
|
|
287
|
-
|
|
288
|
-
const rect = el.getBoundingClientRect();
|
|
289
|
-
if (rect.right < 0 || rect.left > vp.width) continue;
|
|
290
|
-
if (rect.width < 5 || rect.height < 5) continue;
|
|
291
|
-
|
|
292
|
-
const text = (el.textContent || '').trim().slice(0, 40).replace(/\s+/g, ' ');
|
|
293
|
-
const desc = `${tag}${role ? '[' + role + ']' : ''} "${text}"`;
|
|
294
|
-
results.push({ x: rect.left + scrollX, y: rect.top + scrollY, desc });
|
|
295
|
-
}
|
|
296
|
-
return results;
|
|
297
|
-
}, viewport);
|
|
298
|
-
|
|
299
|
-
const legend: string[] = [];
|
|
300
|
-
const badges = annotations.map((a, i) => {
|
|
301
|
-
const num = i + 1;
|
|
302
|
-
legend.push(`${num}. ${a.desc}`);
|
|
303
|
-
return { num, x: a.x, y: a.y };
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
try {
|
|
307
|
-
await page.evaluate((items: Array<{ num: number; x: number; y: number }>) => {
|
|
308
|
-
const container = document.createElement('div');
|
|
309
|
-
container.id = '__browse_annotate__';
|
|
310
|
-
container.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;z-index:2147483647;pointer-events:none;';
|
|
311
|
-
for (const b of items) {
|
|
312
|
-
const el = document.createElement('div');
|
|
313
|
-
el.style.cssText = `position:absolute;top:${b.y}px;left:${b.x}px;width:20px;height:20px;border-radius:50%;background:#e11d48;color:#fff;font:bold 11px/20px sans-serif;text-align:center;border:1px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.4);`;
|
|
314
|
-
el.textContent = String(b.num);
|
|
315
|
-
container.appendChild(el);
|
|
316
|
-
}
|
|
317
|
-
document.body.appendChild(container);
|
|
318
|
-
}, badges);
|
|
319
|
-
|
|
320
|
-
await page.screenshot({ path: screenshotPath, fullPage: isFullPage });
|
|
321
|
-
} finally {
|
|
322
|
-
await page.evaluate(() => {
|
|
323
|
-
document.getElementById('__browse_annotate__')?.remove();
|
|
324
|
-
}).catch(() => {});
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
return `Screenshot saved: ${screenshotPath}\n\nLegend:\n${legend.join('\n')}`;
|
|
328
|
-
}
|
|
329
|
-
|
|
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 });
|
|
338
|
-
return `Screenshot saved: ${screenshotPath}`;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
case 'pdf': {
|
|
342
|
-
const page = bm.getPage();
|
|
343
|
-
const pdfPath = args[0] || (currentSession ? `${currentSession.outputDir}/page.pdf` : `${LOCAL_DIR}/browse-page.pdf`);
|
|
344
|
-
await page.pdf({ path: pdfPath, format: 'A4' });
|
|
345
|
-
return `PDF saved: ${pdfPath}`;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
case 'responsive': {
|
|
349
|
-
const page = bm.getPage();
|
|
350
|
-
const prefix = args[0] || (currentSession ? `${currentSession.outputDir}/responsive` : `${LOCAL_DIR}/browse-responsive`);
|
|
351
|
-
const viewports = [
|
|
352
|
-
{ name: 'mobile', width: 375, height: 812 },
|
|
353
|
-
{ name: 'tablet', width: 768, height: 1024 },
|
|
354
|
-
{ name: 'desktop', width: 1920, height: 1080 },
|
|
355
|
-
];
|
|
356
|
-
const originalViewport = page.viewportSize();
|
|
357
|
-
const results: string[] = [];
|
|
358
|
-
|
|
359
|
-
try {
|
|
360
|
-
for (const vp of viewports) {
|
|
361
|
-
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
362
|
-
const path = `${prefix}-${vp.name}.png`;
|
|
363
|
-
await page.screenshot({ path, fullPage: true });
|
|
364
|
-
results.push(`${vp.name} (${vp.width}x${vp.height}): ${path}`);
|
|
365
|
-
}
|
|
366
|
-
} finally {
|
|
367
|
-
if (originalViewport) {
|
|
368
|
-
await page.setViewportSize(originalViewport).catch(() => {});
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
return results.join('\n');
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// ─── Chain ─────────────────────────────────────────
|
|
376
|
-
case 'chain': {
|
|
377
|
-
const jsonStr = args[0];
|
|
378
|
-
if (!jsonStr) throw new Error('Usage: echo \'[["goto","url"],["text"]]\' | browse chain');
|
|
379
|
-
|
|
380
|
-
let commands: string[][];
|
|
381
|
-
try {
|
|
382
|
-
commands = JSON.parse(jsonStr);
|
|
383
|
-
} catch {
|
|
384
|
-
throw new Error('Invalid JSON. Expected: [["command", "arg1", "arg2"], ...]');
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
if (!Array.isArray(commands)) throw new Error('Expected JSON array of commands');
|
|
388
|
-
|
|
389
|
-
const results: string[] = [];
|
|
390
|
-
const { handleReadCommand } = await import('./read');
|
|
391
|
-
const { handleWriteCommand } = await import('./write');
|
|
392
|
-
const { PolicyChecker } = await import('../policy');
|
|
393
|
-
|
|
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']);
|
|
396
|
-
|
|
397
|
-
const sessionBuffers = currentSession?.buffers;
|
|
398
|
-
const policy = new PolicyChecker();
|
|
399
|
-
|
|
400
|
-
for (const cmd of commands) {
|
|
401
|
-
const [name, ...cmdArgs] = cmd;
|
|
402
|
-
try {
|
|
403
|
-
// Policy check for each sub-command — chain must not bypass policy
|
|
404
|
-
const policyResult = policy.check(name);
|
|
405
|
-
if (policyResult === 'deny') {
|
|
406
|
-
results.push(`[${name}] ERROR: Command '${name}' denied by policy`);
|
|
407
|
-
continue;
|
|
408
|
-
}
|
|
409
|
-
if (policyResult === 'confirm') {
|
|
410
|
-
results.push(`[${name}] ERROR: Command '${name}' requires confirmation (policy)`);
|
|
411
|
-
continue;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
let result: string;
|
|
415
|
-
if (WRITE_SET.has(name)) result = await handleWriteCommand(name, cmdArgs, bm, currentSession?.domainFilter);
|
|
416
|
-
else if (READ_SET.has(name)) result = await handleReadCommand(name, cmdArgs, bm, sessionBuffers);
|
|
417
|
-
else result = await handleMetaCommand(name, cmdArgs, bm, shutdown, sessionManager, currentSession);
|
|
418
|
-
results.push(`[${name}] ${result}`);
|
|
419
|
-
} catch (err: any) {
|
|
420
|
-
results.push(`[${name}] ERROR: ${err.message}`);
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
return results.join('\n\n');
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// ─── Diff ──────────────────────────────────────────
|
|
428
|
-
case 'diff': {
|
|
429
|
-
const [url1, url2] = args;
|
|
430
|
-
if (!url1 || !url2) throw new Error('Usage: browse diff <url1> <url2>');
|
|
431
|
-
|
|
432
|
-
const extractText = () => {
|
|
433
|
-
const body = document.body;
|
|
434
|
-
if (!body) return '';
|
|
435
|
-
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'SVG']);
|
|
436
|
-
const lines: string[] = [];
|
|
437
|
-
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
|
|
438
|
-
acceptNode(node) {
|
|
439
|
-
let el = node.parentElement;
|
|
440
|
-
while (el && el !== body) {
|
|
441
|
-
if (SKIP.has(el.tagName)) return NodeFilter.FILTER_REJECT;
|
|
442
|
-
const style = getComputedStyle(el);
|
|
443
|
-
if (style.display === 'none' || style.visibility === 'hidden') return NodeFilter.FILTER_REJECT;
|
|
444
|
-
el = el.parentElement;
|
|
445
|
-
}
|
|
446
|
-
return NodeFilter.FILTER_ACCEPT;
|
|
447
|
-
},
|
|
448
|
-
});
|
|
449
|
-
let node: Node | null;
|
|
450
|
-
while ((node = walker.nextNode())) {
|
|
451
|
-
const text = (node.textContent || '').trim();
|
|
452
|
-
if (text) lines.push(text);
|
|
453
|
-
}
|
|
454
|
-
return lines.join('\n');
|
|
455
|
-
};
|
|
456
|
-
|
|
457
|
-
const previousTabId = bm.getActiveTabId();
|
|
458
|
-
const tempTabId = await bm.newTab(url1);
|
|
459
|
-
const tempPage = bm.getPage();
|
|
460
|
-
|
|
461
|
-
let text1: string;
|
|
462
|
-
let text2: string;
|
|
463
|
-
try {
|
|
464
|
-
text1 = await tempPage.evaluate(extractText);
|
|
465
|
-
await tempPage.goto(url2, { waitUntil: 'domcontentloaded', timeout: DEFAULTS.COMMAND_TIMEOUT_MS });
|
|
466
|
-
text2 = await tempPage.evaluate(extractText);
|
|
467
|
-
} finally {
|
|
468
|
-
await bm.closeTab(tempTabId);
|
|
469
|
-
if (bm.hasTab(previousTabId)) {
|
|
470
|
-
bm.switchTab(previousTabId);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
const changes = Diff.diffLines(text1, text2);
|
|
475
|
-
const output: string[] = [`--- ${url1}`, `+++ ${url2}`, ''];
|
|
476
|
-
|
|
477
|
-
for (const part of changes) {
|
|
478
|
-
const prefix = part.added ? '+' : part.removed ? '-' : ' ';
|
|
479
|
-
const lines = part.value.split('\n').filter(l => l.length > 0);
|
|
480
|
-
for (const line of lines) {
|
|
481
|
-
output.push(`${prefix} ${line}`);
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
return output.join('\n');
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// ─── Snapshot ─────────────────────────────────────
|
|
489
|
-
case 'snapshot': {
|
|
490
|
-
return await handleSnapshot(args, bm);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// ─── Snapshot Diff ──────────────────────────────
|
|
494
|
-
case 'snapshot-diff': {
|
|
495
|
-
const previous = bm.getLastSnapshot();
|
|
496
|
-
if (!previous) {
|
|
497
|
-
return 'No previous snapshot to compare against. Run "snapshot" first.';
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
const snapshotArgs = bm.getLastSnapshotOpts();
|
|
501
|
-
const current = await handleSnapshot(snapshotArgs, bm);
|
|
502
|
-
|
|
503
|
-
if (!current || current === '(no accessible elements found)' || current === '(no interactive elements found)') {
|
|
504
|
-
return 'Current page has no accessible elements to compare.';
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
const stripRefs = (text: string) => text.replace(/@e\d+ /g, '');
|
|
508
|
-
const changes = Diff.diffLines(stripRefs(previous), stripRefs(current));
|
|
509
|
-
const output: string[] = ['--- previous snapshot', '+++ current snapshot', ''];
|
|
510
|
-
let hasChanges = false;
|
|
511
|
-
|
|
512
|
-
for (const part of changes) {
|
|
513
|
-
if (part.added || part.removed) hasChanges = true;
|
|
514
|
-
const prefix = part.added ? '+' : part.removed ? '-' : ' ';
|
|
515
|
-
const lines = part.value.split('\n').filter(l => l.length > 0);
|
|
516
|
-
for (const line of lines) {
|
|
517
|
-
output.push(`${prefix} ${line}`);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
if (!hasChanges) {
|
|
522
|
-
return 'No changes detected between snapshots.';
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
return output.join('\n');
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// ─── Screenshot Diff ──────────────────────────────
|
|
529
|
-
case 'screenshot-diff': {
|
|
530
|
-
const isFullPageDiff = args.includes('--full');
|
|
531
|
-
const diffArgs = args.filter(a => a !== '--full');
|
|
532
|
-
const baseline = diffArgs[0];
|
|
533
|
-
if (!baseline) throw new Error('Usage: browse screenshot-diff <baseline> [current] [--threshold 0.1] [--full]');
|
|
534
|
-
if (!fs.existsSync(baseline)) throw new Error(`Baseline file not found: ${baseline}`);
|
|
535
|
-
|
|
536
|
-
let thresholdPct = 0.1;
|
|
537
|
-
const threshIdx = diffArgs.indexOf('--threshold');
|
|
538
|
-
if (threshIdx !== -1 && diffArgs[threshIdx + 1]) {
|
|
539
|
-
thresholdPct = parseFloat(diffArgs[threshIdx + 1]);
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
const baselineBuffer = fs.readFileSync(baseline);
|
|
543
|
-
|
|
544
|
-
// Find optional current image path: any non-flag arg after baseline
|
|
545
|
-
let currentBuffer: Buffer;
|
|
546
|
-
let currentPath: string | undefined;
|
|
547
|
-
for (let i = 1; i < diffArgs.length; i++) {
|
|
548
|
-
if (diffArgs[i] === '--threshold') { i++; continue; }
|
|
549
|
-
if (!diffArgs[i].startsWith('--')) { currentPath = diffArgs[i]; break; }
|
|
550
|
-
}
|
|
551
|
-
if (currentPath) {
|
|
552
|
-
if (!fs.existsSync(currentPath)) throw new Error(`Current screenshot not found: ${currentPath}`);
|
|
553
|
-
currentBuffer = fs.readFileSync(currentPath);
|
|
554
|
-
} else {
|
|
555
|
-
const page = bm.getPage();
|
|
556
|
-
currentBuffer = await page.screenshot({ fullPage: isFullPageDiff }) as Buffer;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
const { compareScreenshots } = await import('../png-compare');
|
|
560
|
-
const result = compareScreenshots(baselineBuffer, currentBuffer, thresholdPct);
|
|
561
|
-
|
|
562
|
-
// Diff path: append -diff before extension, or add -diff.png if no extension
|
|
563
|
-
const extIdx = baseline.lastIndexOf('.');
|
|
564
|
-
const diffPath = extIdx > 0
|
|
565
|
-
? baseline.slice(0, extIdx) + '-diff' + baseline.slice(extIdx)
|
|
566
|
-
: baseline + '-diff.png';
|
|
567
|
-
if (!result.passed && result.diffImage) {
|
|
568
|
-
fs.writeFileSync(diffPath, result.diffImage);
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
return [
|
|
572
|
-
`Pixels: ${result.totalPixels}`,
|
|
573
|
-
`Different: ${result.diffPixels}`,
|
|
574
|
-
`Mismatch: ${result.mismatchPct.toFixed(3)}%`,
|
|
575
|
-
`Threshold: ${thresholdPct}%`,
|
|
576
|
-
`Result: ${result.passed ? 'PASS' : 'FAIL'}`,
|
|
577
|
-
...(!result.passed ? [`Diff saved: ${diffPath}`] : []),
|
|
578
|
-
].join('\n');
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
// ─── Auth Vault ─────────────────────────────────────
|
|
582
|
-
case 'auth': {
|
|
583
|
-
const subcommand = args[0];
|
|
584
|
-
const { AuthVault } = await import('../auth-vault');
|
|
585
|
-
const vault = new AuthVault(LOCAL_DIR);
|
|
586
|
-
|
|
587
|
-
switch (subcommand) {
|
|
588
|
-
case 'save': {
|
|
589
|
-
const [, name, url, username] = args;
|
|
590
|
-
// Parse optional selector flags first (Task 9: scan flags before positional args)
|
|
591
|
-
let userSel: string | undefined;
|
|
592
|
-
let passSel: string | undefined;
|
|
593
|
-
let submitSel: string | undefined;
|
|
594
|
-
const positionalAfterUsername: string[] = [];
|
|
595
|
-
const knownFlags = new Set(['--user-sel', '--pass-sel', '--submit-sel']);
|
|
596
|
-
for (let i = 4; i < args.length; i++) {
|
|
597
|
-
if (args[i] === '--user-sel' && args[i+1]) { userSel = args[++i]; }
|
|
598
|
-
else if (args[i] === '--pass-sel' && args[i+1]) { passSel = args[++i]; }
|
|
599
|
-
else if (args[i] === '--submit-sel' && args[i+1]) { submitSel = args[++i]; }
|
|
600
|
-
else if (!knownFlags.has(args[i])) { positionalAfterUsername.push(args[i]); }
|
|
601
|
-
}
|
|
602
|
-
// Password: from positional arg (after username), or env var
|
|
603
|
-
// (--password-stdin is handled in CLI before reaching server)
|
|
604
|
-
let password: string | undefined = positionalAfterUsername[0];
|
|
605
|
-
if (!password && process.env.BROWSE_AUTH_PASSWORD) {
|
|
606
|
-
password = process.env.BROWSE_AUTH_PASSWORD;
|
|
607
|
-
}
|
|
608
|
-
if (!name || !url || !username || !password) {
|
|
609
|
-
throw new Error(
|
|
610
|
-
'Usage: browse auth save <name> <url> <username> <password>\n' +
|
|
611
|
-
' browse auth save <name> <url> <username> --password-stdin\n' +
|
|
612
|
-
' BROWSE_AUTH_PASSWORD=secret browse auth save <name> <url> <username>'
|
|
613
|
-
);
|
|
614
|
-
}
|
|
615
|
-
const selectors = (userSel || passSel || submitSel) ? { username: userSel, password: passSel, submit: submitSel } : undefined;
|
|
616
|
-
vault.save(name, url, username, password, selectors);
|
|
617
|
-
return `Credentials saved: ${name}`;
|
|
618
|
-
}
|
|
619
|
-
case 'login': {
|
|
620
|
-
const name = args[1];
|
|
621
|
-
if (!name) throw new Error('Usage: browse auth login <name>');
|
|
622
|
-
return await vault.login(name, bm);
|
|
623
|
-
}
|
|
624
|
-
case 'list': {
|
|
625
|
-
const creds = vault.list();
|
|
626
|
-
if (creds.length === 0) return '(no saved credentials)';
|
|
627
|
-
return creds.map(c => ` ${c.name} — ${c.url} (${c.username})`).join('\n');
|
|
628
|
-
}
|
|
629
|
-
case 'delete': {
|
|
630
|
-
const name = args[1];
|
|
631
|
-
if (!name) throw new Error('Usage: browse auth delete <name>');
|
|
632
|
-
vault.delete(name);
|
|
633
|
-
return `Credentials deleted: ${name}`;
|
|
634
|
-
}
|
|
635
|
-
default:
|
|
636
|
-
throw new Error('Usage: browse auth save|login|list|delete [args...]');
|
|
637
|
-
}
|
|
638
|
-
}
|
|
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
|
-
|
|
704
|
-
// ─── HAR Recording ────────────────────────────────
|
|
705
|
-
case 'har': {
|
|
706
|
-
const subcommand = args[0];
|
|
707
|
-
if (!subcommand) throw new Error('Usage: browse har start | browse har stop [path]');
|
|
708
|
-
|
|
709
|
-
if (subcommand === 'start') {
|
|
710
|
-
bm.startHarRecording();
|
|
711
|
-
return 'HAR recording started';
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
if (subcommand === 'stop') {
|
|
715
|
-
const recording = bm.stopHarRecording();
|
|
716
|
-
if (!recording) throw new Error('No active HAR recording. Run "browse har start" first.');
|
|
717
|
-
|
|
718
|
-
const sessionBuffers = currentSession?.buffers || bm.getBuffers();
|
|
719
|
-
const { formatAsHar } = await import('../har');
|
|
720
|
-
const har = formatAsHar(sessionBuffers.networkBuffer, recording.startTime);
|
|
721
|
-
|
|
722
|
-
const harPath = args[1] || (currentSession
|
|
723
|
-
? `${currentSession.outputDir}/recording.har`
|
|
724
|
-
: `${LOCAL_DIR}/browse-recording.har`);
|
|
725
|
-
|
|
726
|
-
fs.writeFileSync(harPath, JSON.stringify(har, null, 2));
|
|
727
|
-
const entryCount = (har as any).log.entries.length;
|
|
728
|
-
return `HAR saved: ${harPath} (${entryCount} entries)`;
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
throw new Error('Usage: browse har start | browse har stop [path]');
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
// ─── Video Recording ─────────────────────────────────
|
|
735
|
-
case 'video': {
|
|
736
|
-
const subcommand = args[0];
|
|
737
|
-
if (!subcommand) throw new Error('Usage: browse video start [dir] | browse video stop | browse video status');
|
|
738
|
-
|
|
739
|
-
if (subcommand === 'start') {
|
|
740
|
-
const dir = args[1] || (currentSession
|
|
741
|
-
? `${currentSession.outputDir}`
|
|
742
|
-
: `${LOCAL_DIR}`);
|
|
743
|
-
await bm.startVideoRecording(dir);
|
|
744
|
-
return `Video recording started — output dir: ${dir}`;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
if (subcommand === 'stop') {
|
|
748
|
-
const result = await bm.stopVideoRecording();
|
|
749
|
-
if (!result) throw new Error('No active video recording. Run "browse video start" first.');
|
|
750
|
-
const duration = ((Date.now() - result.startedAt) / 1000).toFixed(1);
|
|
751
|
-
return `Video saved: ${result.paths.join(', ')} (${duration}s)`;
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
if (subcommand === 'status') {
|
|
755
|
-
const recording = bm.getVideoRecording();
|
|
756
|
-
if (!recording) return 'No active video recording';
|
|
757
|
-
const duration = ((Date.now() - recording.startedAt) / 1000).toFixed(1);
|
|
758
|
-
return `Video recording active — dir: ${recording.dir}, duration: ${duration}s`;
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
throw new Error('Usage: browse video start [dir] | browse video stop | browse video status');
|
|
762
|
-
}
|
|
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
|
-
|
|
798
|
-
// ─── Semantic Locator ──────────────────────────────
|
|
799
|
-
case 'find': {
|
|
800
|
-
const root = bm.getLocatorRoot();
|
|
801
|
-
const page = bm.getPage();
|
|
802
|
-
const sub = args[0];
|
|
803
|
-
if (!sub) throw new Error('Usage: browse find role|text|label|placeholder|testid|alt|title|first|last|nth <query>');
|
|
804
|
-
const query = args[1];
|
|
805
|
-
if (!query) throw new Error(`Usage: browse find ${sub} <query>`);
|
|
806
|
-
|
|
807
|
-
let locator;
|
|
808
|
-
switch (sub) {
|
|
809
|
-
case 'role': {
|
|
810
|
-
const nameOpt = args[2];
|
|
811
|
-
locator = nameOpt ? root.getByRole(query as any, { name: nameOpt }) : root.getByRole(query as any);
|
|
812
|
-
break;
|
|
813
|
-
}
|
|
814
|
-
case 'text':
|
|
815
|
-
locator = root.getByText(query);
|
|
816
|
-
break;
|
|
817
|
-
case 'label':
|
|
818
|
-
locator = root.getByLabel(query);
|
|
819
|
-
break;
|
|
820
|
-
case 'placeholder':
|
|
821
|
-
locator = root.getByPlaceholder(query);
|
|
822
|
-
break;
|
|
823
|
-
case 'testid':
|
|
824
|
-
locator = root.getByTestId(query);
|
|
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
|
-
}
|
|
853
|
-
default:
|
|
854
|
-
throw new Error(`Unknown find type: ${sub}. Use role|text|label|placeholder|testid|alt|title|first|last|nth`);
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
const count = await locator.count();
|
|
858
|
-
let firstText = '';
|
|
859
|
-
if (count > 0) {
|
|
860
|
-
try {
|
|
861
|
-
firstText = (await locator.first().textContent({ timeout: 2000 })) || '';
|
|
862
|
-
firstText = firstText.trim().slice(0, 100);
|
|
863
|
-
} catch {}
|
|
864
|
-
}
|
|
865
|
-
return `Found ${count} match(es)${firstText ? `: "${firstText}"` : ''}`;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
// ─── iframe Targeting ─────────────────────────────
|
|
869
|
-
case 'frame': {
|
|
870
|
-
if (args[0] === 'main' || args[0] === 'top') {
|
|
871
|
-
bm.resetFrame();
|
|
872
|
-
return 'Switched to main frame';
|
|
873
|
-
}
|
|
874
|
-
const selector = args[0];
|
|
875
|
-
if (!selector) throw new Error('Usage: browse frame <selector> | browse frame main');
|
|
876
|
-
// Verify the iframe exists and is accessible
|
|
877
|
-
const page = bm.getPage();
|
|
878
|
-
const frameEl = page.locator(selector);
|
|
879
|
-
const count = await frameEl.count();
|
|
880
|
-
if (count === 0) throw new Error(`iframe not found: ${selector}`);
|
|
881
|
-
const handle = await frameEl.elementHandle({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
882
|
-
if (!handle) throw new Error(`iframe not found: ${selector}`);
|
|
883
|
-
const frame = await handle.contentFrame();
|
|
884
|
-
if (!frame) throw new Error(`Element ${selector} is not an iframe`);
|
|
885
|
-
bm.setFrame(selector);
|
|
886
|
-
return `Switched to frame: ${selector}`;
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
// ─── DevTools Inspect ──────────────────────────────
|
|
890
|
-
case 'inspect': {
|
|
891
|
-
const debugPort = parseInt(process.env.BROWSE_DEBUG_PORT || '0', 10);
|
|
892
|
-
if (!debugPort) {
|
|
893
|
-
throw new Error(
|
|
894
|
-
'DevTools inspect requires BROWSE_DEBUG_PORT to be set.\n' +
|
|
895
|
-
'Restart with: BROWSE_DEBUG_PORT=9222 browse restart\n' +
|
|
896
|
-
'Then run: browse inspect'
|
|
897
|
-
);
|
|
898
|
-
}
|
|
899
|
-
try {
|
|
900
|
-
const resp = await fetch(`http://127.0.0.1:${debugPort}/json`, { signal: AbortSignal.timeout(2000) });
|
|
901
|
-
const pages = await resp.json() as any[];
|
|
902
|
-
const currentUrl = bm.getCurrentUrl();
|
|
903
|
-
const target = pages.find((p: any) => p.url === currentUrl) || pages[0];
|
|
904
|
-
if (!target) throw new Error('No debuggable pages found');
|
|
905
|
-
return [
|
|
906
|
-
`DevTools URL: ${target.devtoolsFrontendUrl}`,
|
|
907
|
-
`Page: ${target.title} (${target.url})`,
|
|
908
|
-
`WebSocket: ${target.webSocketDebuggerUrl}`,
|
|
909
|
-
].join('\n');
|
|
910
|
-
} catch (err: any) {
|
|
911
|
-
if (err.message.includes('BROWSE_DEBUG_PORT')) throw err;
|
|
912
|
-
throw new Error(`Cannot reach Chrome debug port at ${debugPort}: ${err.message}`);
|
|
913
|
-
}
|
|
914
|
-
}
|
|
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
|
-
|
|
983
|
-
default:
|
|
984
|
-
throw new Error(`Unknown meta command: ${command}`);
|
|
985
|
-
}
|
|
986
|
-
}
|