@ulpi/browse 0.1.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.
@@ -0,0 +1,358 @@
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 * as Diff from 'diff';
10
+ import * as fs from 'fs';
11
+
12
+ const LOCAL_DIR = process.env.BROWSE_LOCAL_DIR || '/tmp';
13
+
14
+ export async function handleMetaCommand(
15
+ command: string,
16
+ args: string[],
17
+ bm: BrowserManager,
18
+ shutdown: () => Promise<void> | void,
19
+ sessionManager?: SessionManager,
20
+ currentSession?: Session
21
+ ): Promise<string> {
22
+ switch (command) {
23
+ // ─── Tabs ──────────────────────────────────────────
24
+ case 'tabs': {
25
+ const tabs = await bm.getTabListWithTitles();
26
+ return tabs.map(t =>
27
+ `${t.active ? '→ ' : ' '}[${t.id}] ${t.title || '(untitled)'} — ${t.url}`
28
+ ).join('\n');
29
+ }
30
+
31
+ case 'tab': {
32
+ const id = parseInt(args[0], 10);
33
+ if (isNaN(id)) throw new Error('Usage: browse tab <id>');
34
+ bm.switchTab(id);
35
+ return `Switched to tab ${id}`;
36
+ }
37
+
38
+ case 'newtab': {
39
+ const url = args[0];
40
+ const id = await bm.newTab(url);
41
+ return `Opened tab ${id}${url ? ` → ${url}` : ''}`;
42
+ }
43
+
44
+ case 'closetab': {
45
+ const id = args[0] ? parseInt(args[0], 10) : undefined;
46
+ await bm.closeTab(id);
47
+ return `Closed tab${id ? ` ${id}` : ''}`;
48
+ }
49
+
50
+ // ─── Server Control ────────────────────────────────
51
+ case 'status': {
52
+ const page = bm.getPage();
53
+ const tabs = bm.getTabCount();
54
+ const lines = [
55
+ `Status: healthy`,
56
+ `URL: ${page.url()}`,
57
+ `Tabs: ${tabs}`,
58
+ `PID: ${process.pid}`,
59
+ `Uptime: ${Math.floor(process.uptime())}s`,
60
+ ];
61
+ if (sessionManager) {
62
+ lines.push(`Sessions: ${sessionManager.getSessionCount()}`);
63
+ }
64
+ if (currentSession) {
65
+ lines.push(`Session: ${currentSession.id}`);
66
+ }
67
+ return lines.join('\n');
68
+ }
69
+
70
+ case 'url': {
71
+ return bm.getCurrentUrl();
72
+ }
73
+
74
+ case 'stop': {
75
+ setTimeout(() => shutdown(), 100);
76
+ return 'Server stopped';
77
+ }
78
+
79
+ case 'restart': {
80
+ console.log('[browse] Restart requested. Exiting for CLI to restart.');
81
+ setTimeout(() => shutdown(), 100);
82
+ return 'Restarting...';
83
+ }
84
+
85
+ // ─── Sessions ───────────────────────────────────────
86
+ case 'sessions': {
87
+ if (!sessionManager) return '(session management not available)';
88
+ const list = sessionManager.listSessions();
89
+ if (list.length === 0) return '(no active sessions)';
90
+ return list.map(s =>
91
+ ` [${s.id}] ${s.tabs} tab(s) — ${s.url} — idle ${s.idleSeconds}s`
92
+ ).join('\n');
93
+ }
94
+
95
+ case 'session-close': {
96
+ if (!sessionManager) throw new Error('Session management not available');
97
+ const id = args[0];
98
+ if (!id) throw new Error('Usage: browse session-close <id>');
99
+ await sessionManager.closeSession(id);
100
+ return `Session "${id}" closed`;
101
+ }
102
+
103
+ // ─── Visual ────────────────────────────────────────
104
+ case 'screenshot': {
105
+ const page = bm.getPage();
106
+ const annotate = args.includes('--annotate');
107
+ const filteredArgs = args.filter(a => a !== '--annotate');
108
+ const screenshotPath = filteredArgs[0] || `${LOCAL_DIR}/browse-screenshot.png`;
109
+
110
+ if (annotate) {
111
+ const viewport = page.viewportSize() || { width: 1920, height: 1080 };
112
+ const annotations = await page.evaluate((vp) => {
113
+ const INTERACTIVE = ['a', 'button', 'input', 'select', 'textarea', 'details', 'summary'];
114
+ const INTERACTIVE_ROLES = ['button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
115
+ 'listbox', 'menuitem', 'option', 'searchbox', 'slider', 'switch', 'tab'];
116
+ const results: Array<{ x: number; y: number; desc: string }> = [];
117
+ const scrollX = window.scrollX;
118
+ const scrollY = window.scrollY;
119
+
120
+ const candidates = document.querySelectorAll(
121
+ INTERACTIVE.join(',') + ',[role],[onclick],[tabindex],[data-action]'
122
+ );
123
+
124
+ for (let i = 0; i < candidates.length && results.length < 200; i++) {
125
+ const el = candidates[i] as HTMLElement;
126
+ if (el.offsetWidth === 0 && el.offsetHeight === 0) continue;
127
+
128
+ const tag = el.tagName.toLowerCase();
129
+ const role = el.getAttribute('role') || '';
130
+ const isInteractive = INTERACTIVE.includes(tag) || INTERACTIVE_ROLES.includes(role);
131
+ if (!isInteractive && !el.hasAttribute('onclick') &&
132
+ !el.hasAttribute('tabindex') && !el.hasAttribute('data-action') &&
133
+ getComputedStyle(el).cursor !== 'pointer') continue;
134
+
135
+ const rect = el.getBoundingClientRect();
136
+ if (rect.right < 0 || rect.left > vp.width) continue;
137
+ if (rect.width < 5 || rect.height < 5) continue;
138
+
139
+ const text = (el.textContent || '').trim().slice(0, 40).replace(/\s+/g, ' ');
140
+ const desc = `${tag}${role ? '[' + role + ']' : ''} "${text}"`;
141
+ results.push({ x: rect.left + scrollX, y: rect.top + scrollY, desc });
142
+ }
143
+ return results;
144
+ }, viewport);
145
+
146
+ const legend: string[] = [];
147
+ const badges = annotations.map((a, i) => {
148
+ const num = i + 1;
149
+ legend.push(`${num}. ${a.desc}`);
150
+ return { num, x: a.x, y: a.y };
151
+ });
152
+
153
+ try {
154
+ await page.evaluate((items: Array<{ num: number; x: number; y: number }>) => {
155
+ const container = document.createElement('div');
156
+ container.id = '__browse_annotate__';
157
+ container.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;z-index:2147483647;pointer-events:none;';
158
+ for (const b of items) {
159
+ const el = document.createElement('div');
160
+ 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);`;
161
+ el.textContent = String(b.num);
162
+ container.appendChild(el);
163
+ }
164
+ document.body.appendChild(container);
165
+ }, badges);
166
+
167
+ await page.screenshot({ path: screenshotPath, fullPage: true });
168
+ } finally {
169
+ await page.evaluate(() => {
170
+ document.getElementById('__browse_annotate__')?.remove();
171
+ }).catch(() => {});
172
+ }
173
+
174
+ return `Screenshot saved: ${screenshotPath}\n\nLegend:\n${legend.join('\n')}`;
175
+ }
176
+
177
+ await page.screenshot({ path: screenshotPath, fullPage: true });
178
+ return `Screenshot saved: ${screenshotPath}`;
179
+ }
180
+
181
+ case 'pdf': {
182
+ const page = bm.getPage();
183
+ const pdfPath = args[0] || `${LOCAL_DIR}/browse-page.pdf`;
184
+ await page.pdf({ path: pdfPath, format: 'A4' });
185
+ return `PDF saved: ${pdfPath}`;
186
+ }
187
+
188
+ case 'responsive': {
189
+ const page = bm.getPage();
190
+ const prefix = args[0] || `${LOCAL_DIR}/browse-responsive`;
191
+ const viewports = [
192
+ { name: 'mobile', width: 375, height: 812 },
193
+ { name: 'tablet', width: 768, height: 1024 },
194
+ { name: 'desktop', width: 1920, height: 1080 },
195
+ ];
196
+ const originalViewport = page.viewportSize();
197
+ const results: string[] = [];
198
+
199
+ try {
200
+ for (const vp of viewports) {
201
+ await page.setViewportSize({ width: vp.width, height: vp.height });
202
+ const path = `${prefix}-${vp.name}.png`;
203
+ await page.screenshot({ path, fullPage: true });
204
+ results.push(`${vp.name} (${vp.width}x${vp.height}): ${path}`);
205
+ }
206
+ } finally {
207
+ if (originalViewport) {
208
+ await page.setViewportSize(originalViewport).catch(() => {});
209
+ }
210
+ }
211
+
212
+ return results.join('\n');
213
+ }
214
+
215
+ // ─── Chain ─────────────────────────────────────────
216
+ case 'chain': {
217
+ const jsonStr = args[0];
218
+ if (!jsonStr) throw new Error('Usage: echo \'[["goto","url"],["text"]]\' | browse chain');
219
+
220
+ let commands: string[][];
221
+ try {
222
+ commands = JSON.parse(jsonStr);
223
+ } catch {
224
+ throw new Error('Invalid JSON. Expected: [["command", "arg1", "arg2"], ...]');
225
+ }
226
+
227
+ if (!Array.isArray(commands)) throw new Error('Expected JSON array of commands');
228
+
229
+ const results: string[] = [];
230
+ const { handleReadCommand } = await import('./read');
231
+ const { handleWriteCommand } = await import('./write');
232
+
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']);
235
+
236
+ const sessionBuffers = currentSession?.buffers;
237
+
238
+ for (const cmd of commands) {
239
+ const [name, ...cmdArgs] = cmd;
240
+ try {
241
+ let result: string;
242
+ if (WRITE_SET.has(name)) result = await handleWriteCommand(name, cmdArgs, bm);
243
+ else if (READ_SET.has(name)) result = await handleReadCommand(name, cmdArgs, bm, sessionBuffers);
244
+ else result = await handleMetaCommand(name, cmdArgs, bm, shutdown, sessionManager, currentSession);
245
+ results.push(`[${name}] ${result}`);
246
+ } catch (err: any) {
247
+ results.push(`[${name}] ERROR: ${err.message}`);
248
+ }
249
+ }
250
+
251
+ return results.join('\n\n');
252
+ }
253
+
254
+ // ─── Diff ──────────────────────────────────────────
255
+ case 'diff': {
256
+ const [url1, url2] = args;
257
+ if (!url1 || !url2) throw new Error('Usage: browse diff <url1> <url2>');
258
+
259
+ const extractText = () => {
260
+ const body = document.body;
261
+ if (!body) return '';
262
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'SVG']);
263
+ const lines: string[] = [];
264
+ const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
265
+ acceptNode(node) {
266
+ let el = node.parentElement;
267
+ while (el && el !== body) {
268
+ if (SKIP.has(el.tagName)) return NodeFilter.FILTER_REJECT;
269
+ const style = getComputedStyle(el);
270
+ if (style.display === 'none' || style.visibility === 'hidden') return NodeFilter.FILTER_REJECT;
271
+ el = el.parentElement;
272
+ }
273
+ return NodeFilter.FILTER_ACCEPT;
274
+ },
275
+ });
276
+ let node: Node | null;
277
+ while ((node = walker.nextNode())) {
278
+ const text = (node.textContent || '').trim();
279
+ if (text) lines.push(text);
280
+ }
281
+ return lines.join('\n');
282
+ };
283
+
284
+ const previousTabId = bm.getActiveTabId();
285
+ const tempTabId = await bm.newTab(url1);
286
+ const tempPage = bm.getPage();
287
+
288
+ let text1: string;
289
+ let text2: string;
290
+ try {
291
+ text1 = await tempPage.evaluate(extractText);
292
+ await tempPage.goto(url2, { waitUntil: 'domcontentloaded', timeout: DEFAULTS.COMMAND_TIMEOUT_MS });
293
+ text2 = await tempPage.evaluate(extractText);
294
+ } finally {
295
+ await bm.closeTab(tempTabId);
296
+ if (bm.hasTab(previousTabId)) {
297
+ bm.switchTab(previousTabId);
298
+ }
299
+ }
300
+
301
+ const changes = Diff.diffLines(text1, text2);
302
+ const output: string[] = [`--- ${url1}`, `+++ ${url2}`, ''];
303
+
304
+ for (const part of changes) {
305
+ const prefix = part.added ? '+' : part.removed ? '-' : ' ';
306
+ const lines = part.value.split('\n').filter(l => l.length > 0);
307
+ for (const line of lines) {
308
+ output.push(`${prefix} ${line}`);
309
+ }
310
+ }
311
+
312
+ return output.join('\n');
313
+ }
314
+
315
+ // ─── Snapshot ─────────────────────────────────────
316
+ case 'snapshot': {
317
+ return await handleSnapshot(args, bm);
318
+ }
319
+
320
+ // ─── Snapshot Diff ──────────────────────────────
321
+ case 'snapshot-diff': {
322
+ const previous = bm.getLastSnapshot();
323
+ if (!previous) {
324
+ return 'No previous snapshot to compare against. Run "snapshot" first.';
325
+ }
326
+
327
+ const snapshotArgs = bm.getLastSnapshotOpts();
328
+ const current = await handleSnapshot(snapshotArgs, bm);
329
+
330
+ if (!current || current === '(no accessible elements found)' || current === '(no interactive elements found)') {
331
+ return 'Current page has no accessible elements to compare.';
332
+ }
333
+
334
+ const stripRefs = (text: string) => text.replace(/@e\d+ /g, '');
335
+ const changes = Diff.diffLines(stripRefs(previous), stripRefs(current));
336
+ const output: string[] = ['--- previous snapshot', '+++ current snapshot', ''];
337
+ let hasChanges = false;
338
+
339
+ for (const part of changes) {
340
+ if (part.added || part.removed) hasChanges = true;
341
+ const prefix = part.added ? '+' : part.removed ? '-' : ' ';
342
+ const lines = part.value.split('\n').filter(l => l.length > 0);
343
+ for (const line of lines) {
344
+ output.push(`${prefix} ${line}`);
345
+ }
346
+ }
347
+
348
+ if (!hasChanges) {
349
+ return 'No changes detected between snapshots.';
350
+ }
351
+
352
+ return output.join('\n');
353
+ }
354
+
355
+ default:
356
+ throw new Error(`Unknown meta command: ${command}`);
357
+ }
358
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Read commands — extract data from pages without side effects
3
+ *
4
+ * text, html, links, forms, accessibility, js, eval, css, attrs, state,
5
+ * console, network, cookies, storage, perf
6
+ */
7
+
8
+ import type { BrowserManager } from '../browser-manager';
9
+ import { listDevices } from '../browser-manager';
10
+ import type { SessionBuffers } from '../buffers';
11
+ import * as fs from 'fs';
12
+
13
+ export async function handleReadCommand(
14
+ command: string,
15
+ args: string[],
16
+ bm: BrowserManager,
17
+ buffers?: SessionBuffers
18
+ ): Promise<string> {
19
+ const page = bm.getPage();
20
+
21
+ switch (command) {
22
+ case 'text': {
23
+ // TreeWalker-based extraction — never appends to the live DOM,
24
+ // so MutationObservers are not triggered.
25
+ return await page.evaluate(() => {
26
+ const body = document.body;
27
+ if (!body) return '';
28
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'SVG']);
29
+ const lines: string[] = [];
30
+ const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
31
+ acceptNode(node) {
32
+ let el = node.parentElement;
33
+ while (el && el !== body) {
34
+ if (SKIP.has(el.tagName)) return NodeFilter.FILTER_REJECT;
35
+ const style = getComputedStyle(el);
36
+ if (style.display === 'none' || style.visibility === 'hidden') return NodeFilter.FILTER_REJECT;
37
+ el = el.parentElement;
38
+ }
39
+ return NodeFilter.FILTER_ACCEPT;
40
+ },
41
+ });
42
+ let node: Node | null;
43
+ while ((node = walker.nextNode())) {
44
+ const text = (node.textContent || '').trim();
45
+ if (text) lines.push(text);
46
+ }
47
+ return lines.join('\n');
48
+ });
49
+ }
50
+
51
+ case 'html': {
52
+ const selector = args[0];
53
+ if (selector) {
54
+ const resolved = bm.resolveRef(selector);
55
+ if ('locator' in resolved) {
56
+ return await resolved.locator.innerHTML({ timeout: 5000 });
57
+ }
58
+ return await page.innerHTML(resolved.selector);
59
+ }
60
+ return await page.content();
61
+ }
62
+
63
+ case 'links': {
64
+ const links = await page.evaluate(() =>
65
+ [...document.querySelectorAll('a[href]')].map(a => ({
66
+ text: a.textContent?.trim().slice(0, 120) || '',
67
+ href: (a as HTMLAnchorElement).href,
68
+ })).filter(l => l.text && l.href)
69
+ );
70
+ return links.map(l => `${l.text} → ${l.href}`).join('\n');
71
+ }
72
+
73
+ case 'forms': {
74
+ const forms = await page.evaluate(() => {
75
+ return [...document.querySelectorAll('form')].map((form, i) => {
76
+ const fields = [...form.querySelectorAll('input, select, textarea')].map(el => {
77
+ const input = el as HTMLInputElement;
78
+ return {
79
+ tag: el.tagName.toLowerCase(),
80
+ type: input.type || undefined,
81
+ name: input.name || undefined,
82
+ id: input.id || undefined,
83
+ placeholder: input.placeholder || undefined,
84
+ required: input.required || undefined,
85
+ value: input.value || undefined,
86
+ options: el.tagName === 'SELECT'
87
+ ? [...(el as HTMLSelectElement).options].map(o => ({ value: o.value, text: o.text }))
88
+ : undefined,
89
+ };
90
+ });
91
+ return {
92
+ index: i,
93
+ action: form.action || undefined,
94
+ method: form.method || 'get',
95
+ id: form.id || undefined,
96
+ fields,
97
+ };
98
+ });
99
+ });
100
+ return JSON.stringify(forms, null, 2);
101
+ }
102
+
103
+ case 'accessibility': {
104
+ const snapshot = await page.locator("body").ariaSnapshot();
105
+ return snapshot;
106
+ }
107
+
108
+ case 'js': {
109
+ const expr = args[0];
110
+ if (!expr) throw new Error('Usage: browse js <expression>');
111
+ const result = await page.evaluate(expr);
112
+ return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
113
+ }
114
+
115
+ case 'eval': {
116
+ const filePath = args[0];
117
+ if (!filePath) throw new Error('Usage: browse eval <js-file>');
118
+ if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
119
+ const code = fs.readFileSync(filePath, 'utf-8');
120
+ const result = await page.evaluate(code);
121
+ return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
122
+ }
123
+
124
+ case 'css': {
125
+ const [selector, property] = args;
126
+ if (!selector || !property) throw new Error('Usage: browse css <selector> <property>');
127
+ const resolved = bm.resolveRef(selector);
128
+ if ('locator' in resolved) {
129
+ const value = await resolved.locator.evaluate(
130
+ (el, prop) => getComputedStyle(el).getPropertyValue(prop),
131
+ property
132
+ );
133
+ return value;
134
+ }
135
+ const value = await page.evaluate(
136
+ ([sel, prop]) => {
137
+ const el = document.querySelector(sel);
138
+ if (!el) return { __notFound: true, selector: sel };
139
+ return getComputedStyle(el).getPropertyValue(prop);
140
+ },
141
+ [resolved.selector, property]
142
+ );
143
+ if (typeof value === 'object' && value !== null && '__notFound' in value) {
144
+ throw new Error(`Element not found: ${(value as any).selector}`);
145
+ }
146
+ return value as string;
147
+ }
148
+
149
+ case 'state': {
150
+ const selector = args[0];
151
+ if (!selector) throw new Error('Usage: browse state <selector>');
152
+ const resolved = bm.resolveRef(selector);
153
+ const locator = 'locator' in resolved
154
+ ? resolved.locator
155
+ : page.locator(resolved.selector);
156
+
157
+ const state: Record<string, unknown> = {};
158
+
159
+ // Core state checks — each wrapped individually since not all
160
+ // apply to every element type (e.g. isChecked only for checkbox/radio)
161
+ try { state.visible = await locator.isVisible(); } catch { state.visible = null; }
162
+ try { state.enabled = await locator.isEnabled(); } catch { state.enabled = null; }
163
+ try { state.checked = await locator.isChecked(); } catch { state.checked = null; }
164
+ try { state.editable = await locator.isEditable(); } catch { state.editable = null; }
165
+
166
+ // Properties that require evaluate — grouped in one call for efficiency
167
+ try {
168
+ const domProps = await locator.evaluate((el) => {
169
+ const input = el as HTMLInputElement;
170
+ return {
171
+ focused: document.activeElement === el,
172
+ tag: el.tagName.toLowerCase(),
173
+ type: input.type || null,
174
+ value: input.value ?? null,
175
+ };
176
+ });
177
+ Object.assign(state, domProps);
178
+ } catch {
179
+ state.focused = null;
180
+ state.tag = null;
181
+ state.type = null;
182
+ state.value = null;
183
+ }
184
+
185
+ // Bounding box — null when element is not visible
186
+ try { state.boundingBox = await locator.boundingBox(); } catch { state.boundingBox = null; }
187
+
188
+ return JSON.stringify(state, null, 2);
189
+ }
190
+
191
+ case 'attrs': {
192
+ const selector = args[0];
193
+ if (!selector) throw new Error('Usage: browse attrs <selector>');
194
+ const resolved = bm.resolveRef(selector);
195
+ if ('locator' in resolved) {
196
+ const attrs = await resolved.locator.evaluate((el) => {
197
+ const result: Record<string, string> = {};
198
+ for (const attr of el.attributes) {
199
+ result[attr.name] = attr.value;
200
+ }
201
+ return result;
202
+ });
203
+ return JSON.stringify(attrs, null, 2);
204
+ }
205
+ const attrs = await page.evaluate((sel) => {
206
+ const el = document.querySelector(sel);
207
+ if (!el) return { __notFound: true, selector: sel };
208
+ const result: Record<string, string> = {};
209
+ for (const attr of el.attributes) {
210
+ result[attr.name] = attr.value;
211
+ }
212
+ return result;
213
+ }, resolved.selector);
214
+ if (typeof attrs === 'object' && attrs !== null && '__notFound' in attrs) {
215
+ throw new Error(`Element not found: ${(attrs as any).selector}`);
216
+ }
217
+ return JSON.stringify(attrs, null, 2);
218
+ }
219
+
220
+ case 'dialog': {
221
+ const last = bm.getLastDialog();
222
+ if (!last) return '(no dialog detected)';
223
+ return JSON.stringify(last, null, 2);
224
+ }
225
+
226
+ case 'console': {
227
+ const cb = (buffers || bm.getBuffers()).consoleBuffer;
228
+ if (args[0] === '--clear') {
229
+ cb.length = 0;
230
+ return 'Console buffer cleared.';
231
+ }
232
+ if (cb.length === 0) return '(no console messages)';
233
+ return cb.map(e =>
234
+ `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
235
+ ).join('\n');
236
+ }
237
+
238
+ case 'network': {
239
+ const nb = (buffers || bm.getBuffers()).networkBuffer;
240
+ if (args[0] === '--clear') {
241
+ nb.length = 0;
242
+ return 'Network buffer cleared.';
243
+ }
244
+ if (nb.length === 0) return '(no network requests)';
245
+ return nb.map(e =>
246
+ `${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
247
+ ).join('\n');
248
+ }
249
+
250
+ case 'cookies': {
251
+ const cookies = await page.context().cookies();
252
+ return JSON.stringify(cookies, null, 2);
253
+ }
254
+
255
+ case 'storage': {
256
+ if (args[0] === 'set' && args[1]) {
257
+ const key = args[1];
258
+ const value = args[2] || '';
259
+ await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]);
260
+ return `Set localStorage["${key}"] = "${value}"`;
261
+ }
262
+ const storage = await page.evaluate(() => ({
263
+ localStorage: { ...localStorage },
264
+ sessionStorage: { ...sessionStorage },
265
+ }));
266
+ return JSON.stringify(storage, null, 2);
267
+ }
268
+
269
+ case 'perf': {
270
+ const timings = await page.evaluate(() => {
271
+ const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
272
+ if (!nav) return 'No navigation timing data available.';
273
+ return {
274
+ dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
275
+ tcp: Math.round(nav.connectEnd - nav.connectStart),
276
+ ssl: Math.round(nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0),
277
+ ttfb: Math.round(nav.responseStart - nav.requestStart),
278
+ download: Math.round(nav.responseEnd - nav.responseStart),
279
+ domParse: Math.round(nav.domInteractive - nav.responseEnd),
280
+ domReady: Math.round(nav.domContentLoadedEventEnd - nav.startTime),
281
+ load: Math.round(nav.loadEventEnd - nav.startTime),
282
+ total: Math.round(nav.loadEventEnd - nav.startTime),
283
+ };
284
+ });
285
+ if (typeof timings === 'string') return timings;
286
+ return Object.entries(timings)
287
+ .map(([k, v]) => `${k.padEnd(12)} ${v}ms`)
288
+ .join('\n');
289
+ }
290
+
291
+ case 'devices': {
292
+ const filter = args.join(' ').toLowerCase();
293
+ const all = listDevices();
294
+ const filtered = filter ? all.filter(d => d.toLowerCase().includes(filter)) : all;
295
+ if (filtered.length === 0) {
296
+ return `No devices matching "${filter}". Run "browse devices" to see all.`;
297
+ }
298
+ return filtered.join('\n');
299
+ }
300
+
301
+ default:
302
+ throw new Error(`Unknown read command: ${command}`);
303
+ }
304
+ }